Tool reference

This is the complete catalog. Every tool below is callable over MCP and over HTTP (POST /api/tool/<name>). Over HTTP the projectId is a top-level body field, not a tool argument; over MCP the MCP server injects projectId into each tool's input schema. The worked examples show the tool arguments — the args object.

Arguments marked ? are optional. Read Getting started first for the conventions (centre-anchored coords, 30 fps, element ids).

Workspace and lifecycle

These tools discover, create, rename, save, version, and delete projects. They don't transform an existing composition — list_projects and create_project don't even take an existing projectId.

list_projects()

List every project in your workspace as { id, name } pairs. The id is what every other tool's projectId takes; name is the editor picker label (null for older projects with no name set). Call this when you need to pick a project without being told its id.

// args
{}
// → [{ "id": "spring-promo", "name": "Spring promo" }, { "id": "2026-05", "name": null }]

create_project(projectId, fromProjectId?, name?)

Create a new project. With fromProjectId, clones that project's JSON, uploaded assets, and uploaded clips. Without it, clones the alphabetically-last existing project as the seed; if the workspace is empty, writes a stock minimal project. Refuses to overwrite an existing id. The editor doesn't auto-refresh — the user reloads to see the new project.

{ "projectId": "summer-teaser", "fromProjectId": "spring-promo", "name": "Summer teaser" }
// Creates "summer-teaser" as a full clone of "spring-promo".

rename_project(projectId, name)

Update a project's human-readable name (the picker label). An empty string clears the name and reverts to the id fallback. Doesn't touch layers, animations, or styles.

{ "projectId": "summer-teaser", "name": "Summer teaser — v2" }

save_version(projectId, name?)

Freeze the current project state as a named version the user can flick back to. Call this once after each meaningful change-set — it's how the user compares and rolls back. Use a short imperative-mood label; the user sees it verbatim. One version per logical change-set, never one per tool call.

{ "projectId": "summer-teaser", "name": "add 1s fade-in to title" }

list_versions(projectId)

Enumerate every saved version newest-first as summaries — id, name, timestamp, source, kind, version_number. Read-only; the inner project payload is not returned. kind is bookmark (a deliberate save, gets a stable v<N> number) or auto (an editor auto-snapshot, restore-only).

{ "projectId": "summer-teaser" }

restore_version(projectId, versionId)

Overwrite the live project with a saved version's payload. Destructive on the current state — call save_version first if you want a rollback point. Accepts a UUID id or the v<N> shorthand (bookmarks only for v<N>).

{ "projectId": "summer-teaser", "versionId": "v3" }

rename_version(projectId, versionId, name)

Relabel a saved version's display name. The v<N> identifier is unaffected — only the picker label changes. Empty names are rejected.

{ "projectId": "summer-teaser", "versionId": "v3", "name": "before clip swap" }

delete_version(projectId, versionId)

Permanently delete one saved version. The v<N> sequence keeps gaps — numbers never re-shuffle — so any external snippet pinned to the deleted v<N> stops resolving. Idempotent.

{ "projectId": "summer-teaser", "versionId": "v3" }

delete_project(projectId)

Permanently wipe a project's JSON, versions, uploaded assets, and clips. Refuses to delete the only remaining project. The editor doesn't auto-refresh.

{ "projectId": "old-draft" }

Identity and lookup

describe_video()

Return the full project JSON — layer ids, positions, sizes, the background, animations, styles, groups, embed origins. Free, no mutation. Call this first in every session so you address real ids instead of guessing.

{}
// → the full project: video_layers, image_layers, text layers, shapes,
//   groups, animations, styles, layer_order, embed_origins, ...

Layers

add_image_layer(filename, x, y, width, height)

Add an image layer. The asset must already exist at users/<userId>/assets/<projectId>/<filename> — uploaded via the editor's drag-drop or /api/upload-asset. To duplicate an existing layer, reuse its filename; a fresh id is assigned. (x, y) is the layer centre.

{ "filename": "star.png", "x": 540, "y": 400, "width": 120, "height": 120 }
// Adds star.png, 120×120, centred near the top of a 1080×1920 canvas.

add_video_layer(clip, x, y, width, height, name?)

Add a video layer. The clip must already exist at users/<userId>/clips/<projectId>/<clip> — uploaded via the editor's "+ Add video" button or /api/upload-clip. The layer renders the source mp4 into its box; audio mixes into preview and export.

{ "clip": "demo.mp4", "x": 540, "y": 960, "width": 1080, "height": 1920, "name": "main clip" }
// Adds demo.mp4 as a full-canvas video layer.

add_shape(kind, x?, y?, width?, height?, color?)

Add a shape layer. kind is rect, ellipse, triangle, or star — all share the same fill / border / shadow pipeline. If x/y/width/height are omitted the shape is placed in the canvas centre. color is a #rrggbb fill.

{ "kind": "star", "x": 540, "y": 960, "width": 200, "height": 200, "color": "#FF7A66" }
// Adds a coral star at canvas centre.

remove_layer(elementId)

Delete a video, image, text, or shape layer. Not for groups — call ungroup_layers instead. Removing a leaf is permanent (use a version as a safety net).

{ "elementId": "shapes.bg_glow" }

move_layer(elementId, x?, y?, width?, height?, rotation?)

Patch a layer's static base position, size, and rotation. Works on video/image/text/shapes. For group.<id>, x/y set the pivot (no width/height/rotation — use add_keyframe for group rotation). Note: if a property has a keyframe track, the track overrides this static value — move_layer sets the un-animated default.

{ "elementId": "image.logo", "x": 540, "y": 200, "rotation": 0 }

reorder_layer(elementId, newIndex)

Set a layer's z-order within its current parent's siblings — the root list when ungrouped, or the parent group's children[] when nested. newIndex is 0-based; 0 is the bottom of that subtree, the last index is the top.

{ "elementId": "image.logo", "newIndex": 0 }
// Sends image.logo to the bottom of its parent's stack.

set_layer_visible(elementId, visible)

Show or hide a layer instantly by writing a single opacity keyframe (1 or 0) at frame 0.

{ "elementId": "text.caption", "visible": false }

rename_layer(elementId, name)

Set the human-readable name of a video / image / text / shape layer — the Inspector label, and the basis for the layer's auto-derived <metamorpha-video> embed attribute (rename a layer to caption and its embed attribute becomes caption). Empty string clears it. For groups use rename_group.

{ "elementId": "text.t_01", "name": "caption" }

add_text_layer(text, x?, y?, width?, height?, font_family?, text_size?, text_color?)

Create a new text layer — a first-class leaf that animates, groups, and z-orders like an image or shape. The renderer draws live typeset text, multi-line, auto-fit to the box. Defaults: x/y = canvas centre, width 900, height 320, font_family "Anton", text_size derived from existing text layers (or ~10% of canvas height), text_color white. Newlines in text are hard line breaks. Returns the new text.<id>.

{ "text": "BIG NEWS", "x": 540, "y": 480, "font_family": "Anton", "text_color": "#14141B" }

set_layer_text(elementId, text?, text_size?, font_family?, text_color?)

Edit an existing text layer (text.<id>) — patch its content, font, size, or colour. Pass only the fields you want to change. Does not create layers and does not touch image layers; use add_text_layer for a new one. font_family is a Google Fonts family name.

{ "elementId": "text.headline", "text": "SOLD OUT", "text_color": "#E14A2E" }

set_image_filename(elementId, filename)

Repoint an existing image layer at a different uploaded asset — keeps the layer's id, position, size, animations, and styles; only the bitmap changes. Use this to swap an image without losing its keyframes (remove_layer + add_image_layer would mint a new id and drop the animations). The new asset must already be uploaded.

{ "elementId": "image.headshot", "filename": "raj-v2.png" }

set_video_clip(elementId, clip)

Repoint an existing video layer at a different uploaded clip — keeps the layer's id, position, size, animations, styles, and trim window; only the source mp4 changes. Use this to swap a clip without losing keyframes.

{ "elementId": "video.main", "clip": "demo-final.mp4" }

set_video_layer_trim(elementId, source_in_frame?, source_out_frame?, timeline_start_frame?)

Patch a video layer's trim window. source_in_frame is the frame in the source mp4 to start at; source_out_frame is where to stop (null = the source's natural end); timeline_start_frame is where on the project timeline the slice begins. Pass only the fields you want to change. To clip out a segment, duplicate the layer first, then give each copy a disjoint source window.

{ "elementId": "video.main", "source_in_frame": 90, "source_out_frame": 300, "timeline_start_frame": 0 }
// Plays source frames 90–300, starting at the top of the timeline.

set_matte_source(elementId, matte_source_id)

Set or clear a track matte. When matte_source_id is set, that element's alpha channel is multiplied onto the host layer at paint time — the host shows only where the matte source is opaque (After Effects "Alpha Matte"). Both host and source must be leaves (image/video/shapes); groups can't host or supply a matte. Pass null to clear.

{ "elementId": "video.main", "matte_source_id": "shapes.circle_mask" }
// The video shows only inside the circle shape's silhouette.

Groups

A group holds an ordered children[] and composes a translate/scale/rotate/opacity transform onto every descendant. Its pivot is frozen at the children's bounding-box centre at create time. Groups can nest.

group_layers(elementIds, name?)

Wrap sibling elements in a new group. Every listed id must currently share the same parent (the root, or one existing group). The pivot seeds to the children's centroid. The group's x/y track values become translation offsets around that pivot — groups have no static body of their own.

{ "elementIds": ["text.headline", "text.subhead", "image.logo"], "name": "header" }

ungroup_layers(groupId)

Dissolve a group: its children splice into the group's parent at the group's old position. The group's animation tracks are discarded — children survive at their last positions but inherit none of the group's keyframes. Takes the bare group id.

{ "groupId": "header" }

set_group_parent(elementId, parentGroupId, index?)

Move an element into a group, or out to the root with parentGroupId: null. elementId is the full element id; parentGroupId is a bare group id (or null). index is the 0-based insert position among the new parent's children (defaults to the end). Refuses to nest a group inside its own descendants.

{ "elementId": "shapes.badge", "parentGroupId": "header", "index": 0 }

rename_group(groupId, name)

Rename a group — purely cosmetic; the label shows in the Inspector and describe_video. Takes the bare group id.

{ "groupId": "header", "name": "title block" }

set_group_box(elementId, box_width, box_height)

Set a group's backdrop rect size. The rect is centred on the group's pivot in group-local space and transforms with the group. Either dimension at 0 hides the backdrop entirely. Pair with set_layer_fill on the group.<id> to colour it.

{ "elementId": "group.header", "box_width": 900, "box_height": 360 }

Animation

All frame arguments are 0-indexed; 30 fps. Tracks override the static value at every frame.

add_keyframe(elementId, property, frame, value, easing?)

Add or overwrite a keyframe on an animation track. property is one of x, y, width, height, scale, rotation, opacity. For leaves, x/y/rotation are absolute canvas-space values; for groups, x/y are translation offsets around the pivot and rotation is the group's absolute angle. scale orbits the layer/pivot centre (1 = no change), opacity is 0..1. easing is the interpolation to the next keyframe — linear, easeIn, easeOut, easeInOut, outQuart, outExpo, outBack, inBack, inOutBack, cubicBezier, or hold (a step function — the value holds flat and jumps only when the playhead crosses this keyframe).

{ "elementId": "image.logo", "property": "rotation", "frame": 60, "value": 360, "easing": "easeInOut" }
// With a rotation=0 keyframe at frame 0, the logo spins once over 2 seconds.

remove_keyframe(elementId, property, frame)

Remove the keyframe at an exact frame. Removing the last keyframe from a track restores the layer's static base value across the timeline.

{ "elementId": "image.logo", "property": "rotation", "frame": 60 }

shift_track(elementId, property, delta)

Bulk-shift every keyframe's value on one property by delta. Keyframe times are untouched — this slides the whole curve while preserving the animation's relative shape. Mirrors "select all keyframes and nudge" in a desktop NLE. Use for "move all x by −30px", "rotate an existing wobble by 10°".

{ "elementId": "image.logo", "property": "x", "delta": -30 }
// Every x keyframe shifts 30px left; the animation's shape is unchanged.

set_track_loop(elementId, property, mode)

Set the extrapolation mode for one property's track. mode is hold (keep the boundary value), loop (wrap to the first keyframe), ping-pong (alternate direction each cycle), or cycle (wrap and add the boundary delta each cycle — endless rotation/scrolling). No effect on tracks with fewer than two keyframes.

{ "elementId": "image.logo", "property": "rotation", "mode": "cycle" }
// The logo rotates endlessly instead of stopping at the last keyframe.

fade_layer(elementId, fromFrame, toFrame, fromOpacity, toOpacity)

Convenience tool: write two opacity keyframes in one call. fromOpacity and toOpacity are 0..1.

{ "elementId": "image.title", "fromFrame": 0, "toFrame": 30, "fromOpacity": 0, "toOpacity": 1 }
// A 1-second fade-in on the title.

apply_preset(elementId, preset, startFrame?)

Apply a canned animation. preset is fade-in, fade-out, pulse, slide-in-left, slide-in-right, slide-up, shake, or pop. startFrame anchors the preset (default 0).

{ "elementId": "shapes.badge", "preset": "pop", "startFrame": 45 }

add_speed_keyframe(elementId, frame, rate)

Add or overwrite a speed-ramp (time-remap) keyframe on a video layer. rate is the playback rate at frame: 1 = real-time, 0.5 = half-speed, 2 = double-speed. Range [0.1, 8]. Adjacent speed keyframes interpolate linearly; the renderer integrates the curve to pick the source frame.

{ "elementId": "video.main", "frame": 0, "rate": 1 }
// Paired with a rate=0.3 keyframe later, the clip ramps into slow motion.

remove_speed_keyframe(elementId, frame)

Remove the speed keyframe at frame. Removing the last one restores 1× playback.

{ "elementId": "video.main", "frame": 90 }

Fills

Every fill site — the canvas backdrop, a shape body, an image/video/group backdrop — takes the same Fill discriminated union (solid, linear, radial, mask). Wherever a fill is accepted, a "#rrggbb" hex shorthand also works and is promoted to a solid fill.

set_layer_fill(elementId, fill)

Set a layer's fill. For the canvas backdrop, use elementId: "background.canvas" (the literal is accepted as a synonym for the pinned background layer's id); null is rejected there. Shapes require a Fill (null rejected). Image / video / group layers accept a Fill or null (clears the backdrop). Shapes paint their body; image/video paint behind the bitmap; groups paint a rect sized by set_group_box.

// Solid colour on the canvas backdrop:
{ "elementId": "background.canvas", "fill": "#14141B" }

// A linear gradient on a shape:
{ "elementId": "shapes.panel", "fill": {
    "type": "linear", "angle": 90,
    "stops": [{ "offset": 0, "color": "#FF7A66" }, { "offset": 1, "color": "#A371F7" }]
} }

add_color_keyframe(elementId, property, frame, value, easing?)

Add or overwrite a colour keyframe on a fill track. property is "fill" (the only key today). elementId is a leaf (shapes/image/video/group) or "background.canvas". value is a Fill or #rrggbb. Adjacent keyframes crossfade stop-by-stop. 30 fps; frame is 0-indexed.

{ "elementId": "background.canvas", "property": "fill", "frame": 0, "value": "#FAFAFC" }
// Paired with a #14141B keyframe at frame 60, the backdrop fades to dark.

remove_color_keyframe(elementId, property, frame)

Remove the colour keyframe at an exact frame on a fill track. No-op when there's no track or no matching keyframe. Removing the last keyframe drops the track.

{ "elementId": "background.canvas", "property": "fill", "frame": 60 }

Style

set_style(elementId, ...patch)

Set style fields on a layer with a multi-field patch — only the fields you pass are changed. Available fields:

FieldApplies toPurpose
borderRadius (px)allRounded corners.
borderWidth (px) + borderColor (#rrggbb)allStroke.
boxShadow (CSS shadow string)alle.g. "0 4px 12px rgba(0,0,0,0.5)".
fit (stretch \cover \contain)image, videoObject-fit. Default stretch for images, cover for video.
anchorX, anchorY (0..1)image, videoObject-position under cover/contain. 0 = left/top, 1 = right/bottom, 0.5 = centre. Ignored under stretch.
tintColor (#rrggbb) + tintStrength (0..1)imageSource-atop colour overlay. 0 = none, 1 = silhouette filled with the tint.
alphaMaskimageLinear alpha-mask gradient — multiplies the layer's alpha along a gradient line. Pass null to clear.

set_style on a group.<id> is an error — groups have no styled body. Image-only fields land in the JSON but are ignored by the renderer on shapes (and tintColor/tintStrength on videos).

{ "elementId": "image.headshot", "borderRadius": 24, "borderWidth": 4,
  "borderColor": "#FF7A66", "fit": "cover", "anchorX": 0.5, "anchorY": 0.3 }

The alphaMask object is { type: "linear", angle: number, stops: [{ offset, alpha }, …] }angle is CSS-style degrees (0 = to top, 90 = to right, 180 = to bottom, 270 = to left), at least two stops ordered by offset:

{ "elementId": "image.frontHalf", "alphaMask": {
    "type": "linear", "angle": 180,
    "stops": [{ "offset": 0, "alpha": 1 }, { "offset": 1, "alpha": 0 }]
} }
// Fades the layer's bottom edge to transparent.

Project-level

set_duration(seconds)

Set the composition's first-class duration in seconds. This drives the timeline length and the export length — independent of any source mp4. Clamped to [1, 600].

{ "seconds": 12 }

set_canvas_size(width, height)

Resize the composition canvas. Every layer's position and size, group pivots, and x/y/width/height keyframes scale by the width/height ratio, so the composition keeps its proportions. Common sizes: 1080×1920 (9:16 Reels/TikTok/Shorts), 1080×1350 (4:5 Instagram), 1080×1080 (1:1 square), 1920×1080 (16:9 YouTube).

{ "width": 1080, "height": 1080 }
// Converts a 9:16 project to square; everything rescales.

set_loop(elementId, field?, values)

Set the project's loop section: the whole composition repeats once per value, with one field of one layer varying across the repeats. Each pass sets field of elementId to that value — e.g. a caption text layer cycling through several strings. field defaults to "text". An empty values array clears the loop (the comp plays once).

{ "elementId": "text.caption", "field": "text",
  "values": ["First tip", "Second tip", "Third tip"] }
// The comp plays three times, the caption text changing each pass.

Embedding

These tools control the public <metamorpha-video> embed allowlist — the hostnames permitted to load a project through the public embed. The worker mirrors the list into KV after every write, so these tools actually flip the public gate (a raw storage edit wouldn't). An empty allowlist turns embedding off — the embed endpoint 404s the project. Entries match exact hostname (no wildcards) and are normalised to a bare lowercased hostname (scheme/port/path stripped).

set_embed_origins(origins)

Replace the whole allowlist. Pass the full desired array; it overwrites the previous list. An empty array disables embedding.

{ "origins": ["shop.example.com", "blog.example.com"] }

add_embed_origin(origin)

Add one hostname. Idempotent — re-adding an existing entry is a no-op.

{ "origin": "https://landing.example.com/promo" }
// Normalised to "landing.example.com" before it's added.

remove_embed_origin(origin)

Remove one hostname. Idempotent. Removing the last entry turns embedding off.

{ "origin": "blog.example.com" }

Audio overlays

Independent sound clips on the project, played in the editor preview and mixed into the MP4 export. Audio assets (mp3/m4a/wav/ogg) are uploaded the same way as images — via the editor or /api/upload-asset, not MCP.

add_audio_overlay(filename, startFrame, gain?, fadeInFrames?, fadeOutFrames?, endFrame?)

Schedule an audio overlay at a frame-aligned startFrame. gain is linear 0..2 (default 1); fadeInFrames / fadeOutFrames are linear envelope lengths in frames (default 0); endFrame is optional — omit it to play the asset's natural length. The asset must already be uploaded. Returns the new overlay with an auto-assigned id (e.g. audio_1).

{ "filename": "swoosh.mp3", "startFrame": 0, "gain": 0.8, "fadeInFrames": 6, "fadeOutFrames": 12 }

update_audio_overlay(id, filename?, startFrame?, gain?, fadeInFrames?, fadeOutFrames?, endFrame?)

Patch an existing overlay — only the fields you pass change. Pass endFrame: null to clear an explicit end and revert to natural-length playback.

{ "id": "audio_1", "gain": 1.2, "endFrame": 300 }

remove_audio_overlay(id)

Delete an audio overlay by id.

{ "id": "audio_1" }