Getting started
This page is the orientation an external agent needs before calling any mutating tool. Read it once; the conventions here apply to every tool.
Browse before mutating
The first call of any session is `describe_video`. It returns a cheap structural overview — canvas meta, the backdrop, and a z-ordered tree of every layer with its id, type, name, label (filename/clip/text/kind), geometry, and which properties are animated. It deliberately omits keyframe values and styles (the unbounded part). It is free and never mutates anything.
You need describe_video because you must not invent element ids or filenames. Read them from the tree. The single most common way an agent gets lost is skipping this step and constructing an id like image.title from intuition — the real id is whatever the overview says it is.
When you need a layer's full detail — keyframe values, colour tracks, track-loop modes, styles, every field — call `inspect_layers([elementId, …])` for just the layers you're about to change. Don't guess values from the overview; don't pull detail for layers you won't touch.
A typical session is small:
describe_video— see the overview tree.inspect_layers([elementId, …])— full detail on the layers you'll change (skip if only adding new layers).- Call one or more mutating tools.
save_versiononce at the end.
With the SDK each step is a callTool(projectId, name, args):
await morpha.callTool(id, "describe_video", {});
await morpha.callTool(id, "inspect_layers", { elementIds: ["text.7f3a2c"] });
await morpha.callTool(id, "add_keyframe", { elementId: "text.7f3a2c", property: "opacity", frame: 0, value: 0 });
await morpha.callTool(id, "save_version", { name: "add fade-in" });
The save-version bracket
Versions are user-visible: the editor's Versions panel lists every saved version, and the user flicks between them to compare states or roll back. An edit you make without saving a version is one the user can't easily revisit.
Wrap a session like this:
save_version(name="baseline before <task>") ← optional, for comparing
… your mutations …
save_version(name="<short description>") ← end-of-task marker
The baseline is optional belt-and-braces: every whole-project restore_version automatically checkpoints the state it overwrites (a before restore: … version, skipped when nothing is unsaved), so a restore never loses work. A deliberate named baseline is still worth taking when the user will want to compare before/after by name.
Call save_version once per logical change-set, not once per tool call. A version named "add fade-in for stars" is useful; thirty versions each named "change" are noise. Use a short imperative-mood label — the user sees it verbatim.
On multi-page projects, restore surgically: list_versions marks which pages each version changed (cp), and restore_version(…, page_index) brings back just that page — other pages keep their edits. See Tools → restore_version.
Element ids
Every layer is addressed by a prefixed id. Six shapes appear across the catalog:
| Prefix | Element |
|---|---|
video.<id> | A video layer — carries a clip filename, renders its source mp4 into the layer box. |
image.<id> | An image layer — renders an uploaded bitmap. |
text.<id> | A text layer — renders live typeset text (multi-line, auto-fit). |
shapes.<id> | A shape layer — a native vector primitive (rect, ellipse, triangle, star, and ~30 more; see the tool reference). |
group.<id> | A layer group — holds an ordered children[] and composes a transform onto every descendant. |
image.background | The pinned canvas-backdrop sentinel. Exactly one per project; always painted at the back. The legacy alias background.canvas is still accepted. |
<id> is a 6-char lowercase hex token ([a-f0-9]{6}) generated when the layer is created — opaque, not meaningful. It is never derived from the layer's name, filename, or text content. Matches the pattern used by After Effects, Premiere, Final Cut, Motion, Figma, and Illustrator: ids are storage keys; the user-facing handle is the layer's name. Never construct an id by hand; never expect it to look meaningful; always pull ids from describe_video's output.
Groups take a bare id in a few places. ungroup_layers, rename_group, and set_group_parent's parentGroupId argument take the bare id (e.g. "header"). Everywhere else — add_keyframe, move_layer, set_layer_fill, set_group_parent's elementId — use the full group.<id> form.
The layer tree
project.layer_order is the root-level z-ordering only. A group's children live under that group's children[], not in layer_order. The composition is a tree, not a flat list. describe_video surfaces groups[] and a top-first ordering so you don't have to re-derive the structure.
The canvas backdrop (background.canvas) is not in layer_order — it is always painted first, behind everything.
Structure your composition — group, and use native features
A good Morpha project is a tree of grouped units, not a flat list of forty unconnected layers. Two habits keep it that way:
Group related elements — anything you drew as one thing gets one named group. Two cases, both mandatory:
- A composed object: several primitives that together depict a single thing — a butterfly (wings + body + antennae + spots), a rocket, a tree, an icon, a crest. If a human would point at it and name it with one noun, it's one group named that noun. This is the case most often missed, and it leaves the user with a dozen loose shapes where they expected
butterfly. - A layout unit: a card with its heading, body, and badge; a lower-third; a logo lockup; a labelled diagram.
Wrap each with group_layers as soon as its pieces are placed (the elements must already be siblings; set_group_parent them into a common parent first if they aren't) — not after the whole scene is built and forty siblings sit at the root. Name the group what the thing is; that name is the user's only handle on it, and three objects means three named groups, not one bag of shapes. Grouping keeps the layer list legible and sets up the animation win below. One carve-out: many copies of a single element (confetti, a starfield, a grid built with duplicate_layer) are fine left flat — that's one repeated element, not one composed thing — and ungroup_layers discards the group's own animation tracks, so group the finished thing, not speculatively.
Animate the group, not each child, for a unison move. A group's transform (x/y/scale/rotation/opacity) composes onto every descendant, so a single track on group.<id> gives every child the move, scale, spin, or fade for free — a card that slides in, a scene that pulses, a lockup that spins. That's fewer calls than keyframing each child, and the user can re-time the whole unit from one track. Reach for the per-child batch tools (apply_preset_stagger, set_keyframes_batch) only when the children animate independently — a staggered one-per-frame cascade, a rippling grid.
Use the native feature instead of faking it. Morpha has real primitives for the things agents most often reconstruct by stacking layers:
- A border / frame is a stroke on the shape itself —
set_styleborderWidth+borderColor(+borderRadius). Never a slightly-larger rectangle stacked behind the shape. - A button, CTA, chip, tag, pill, or labelled badge is ONE text layer, not a shape with a text layer on top of it:
set_text_backgroundgives it the fill, thepaddingthat sizes the box around the label, thecornerRadius, and an optionalstrokeWidth+strokeColoroutline. New text layers are alreadytext_autofit: "hug", so padding alone shrink-wraps the box to the words. Padding is what makes a button a button; a rect stacked under a text layer has none, so the two drift apart the moment the label changes or the layer scales, and the user has to move both. (One exception: the box isn't painted on curved text, so an arc-shaped chip really is a shape plus a text layer.) - A gradient is one
linear/radialFill viaset_layer_fill. Never a stack of solid bands. - A mask / crop — clip a layer to a shape, or reveal video through letterforms — is a track matte (
set_matte_source: the host shows only where the source is opaque). Never opaque shapes stacked over the parts you want hidden.
Faked versions double the layer count and drift apart the moment the composition scales or animates. The native version is one layer that stays correct.
The coordinate system
The canvas defaults to 1080 × 1920 (9:16) and can be resized per project — 1080×1080 square, 1920×1080 landscape, 1080×1350 (4:5), or a custom size.
A layer's (x, y) is the CENTRE of its bounding box in canvas pixels — not the top-left. This matches Premiere / Final Cut / Motion. Consequences:
- To centre a layer on a 1080×1920 canvas,
(x, y) = (540, 960). - To place a 200×80 label flush against the top-left corner, its centre is
(100, 40)— half its width and half its height in. (x, y) = (0, 0)puts the layer centre at the canvas corner, so three-quarters of the layer ends up off-canvas.
width and height are pixel dimensions of the bounding box. rotation is in degrees, clockwise.
Keep key content inside the platform safe area. TikTok and Instagram Reels draw buttons, a caption block and a tab bar over the edges of a full-screen 9:16 video, and Instagram's feed crops tall videos to 4:5. describe_video returns platform_safe_area, the rect that stays visible: { x: 0, y: 285, width: 940, height: 1155 } on 1080×1920, and null when nothing is covered or cropped. Put text, captions, logos and buttons inside it. Backgrounds, footage and decoration can run to the edge.
Frames vs seconds
The timeline runs at 30 fps. Every frame: argument is an integer, 0-indexed frame number. Convert with frames = round(seconds × 30):
- A 1-second fade is frames
0..30. - A 2.5-second hold is frames
0..75. - "Two seconds in" is frame
60.
The composition length isn't something you set — it's derived from content, auto-fitting the furthest keyframe / video-window / audio end (1-second floor).
Animation tracks
Each video / image / text / shapes / group layer carries its animation tracks on its own animations field, keyed by property: x, y, width, height, scale, rotation, opacity. Each property's track is a sorted array of { frame, value, easing? } keyframes. (A layer's per-element data — animations, style, color_tracks, track_loops — lives on the layer record itself; inspect_layers returns all four alongside the layer's fields.)
The key rule, After Effects / Premiere / Final Cut style: when a track exists on a property, it overrides the layer's static value at every frame. So move_layer sets the un-animated default; if the layer has an x track, the track wins. To animate, use add_keyframe. To set a value that isn't animated, use move_layer.
Track values are absolute for leaf layers: x/y are the layer centre's canvas-space pixel position, width/height are pixel dimensions, rotation is degrees, scale orbits the layer centre (1 = no change), opacity is 0..1.
Groups have no static body — their x/y track values are translation offsets applied around the group's frozen pivot, and a group transform composes onto every descendant. A group rotating 30° rotates everything inside it 30° on top of each child's own rotation.
Extrapolation past the ends
A separate per-property setting controls what happens before the first keyframe and after the last. Set it with set_track_loop:
hold(default) — keep the boundary keyframe's value forever.loop— wrap past the last keyframe back to the first; the animation restarts.ping-pong— alternate direction each cycle, bouncing back and forth.cycle— wrap likeloop, but each cycle adds the boundary delta (endless rotation or scrolling).
Tracks with fewer than two keyframes ignore the loop mode.
Assets must exist first
add_image_layer, set_image_filename, add_video_layer, set_video_clip, and add_audio_overlay all reference a filename that must already be uploaded. If you reference a filename that isn't uploaded, the tool fails. describe_video only lists layers, not the asset bucket — confirm the upload before referencing a new filename.
Use the `filename` the upload returns. Every upload answers with { filename, name }. Morpha names each stored file itself, with an opaque id such as 3f2a9c1e-5b7d-4e8f-9a0b-1c2d3e4f5a6b.png, the same way it mints a project's id. Pass that filename to the layer tools exactly as returned, and never show it to the person. name is what people see: the file's own name, or the name you passed, and the label to give the layer or track. You never choose a stored filename, so logo.png is not a filename you can reference just because the file was called that. Two uploads under one name are two files with two ids, and uploading again never replaces a file: to swap a layer's picture, upload the new one and pass its filename to set_image_filename.
upload_image(url="https://upload.wikimedia.org/…/logo.png", name="Company logo.png")
→ { filename: "3f2a9c1e-5b7d-4e8f-9a0b-1c2d3e4f5a6b.png", name: "Company logo.png", … }
add_image_layer(filename="3f2a9c1e-5b7d-4e8f-9a0b-1c2d3e4f5a6b.png", name="Company logo.png", x=540, y=300, width=400, height=400)
Video clips are not uploaded over MCP — clip ingest lives in the npm SDK (client.addVideo), which uploads and processes the clip in a real local browser and returns the stored filename to pass to add_video_layer as clip (see the tool catalog). Images upload over MCP — upload_image(url, name?) fetches a direct image link (.png/.jpg/.jpeg/.gif/.webp/.svg; SVGs carrying scripts or external references are rejected) and returns the stored filename for add_image_layer. There is no image search over MCP: pass upload_image a direct file URL, such as one the person has given you (the SDK's findPublicImage searches Openverse from your machine). Audio uploads over MCP too — upload_audio(url, name?) fetches a direct audio link (.mp3/.m4a/.wav/.ogg/.aac) and returns the stored filename for add_audio_overlay or update_audio_overlay (or drag-drop in the editor / POST /api/upload-asset/<projectId> with raw bytes and the display name in an X-Upload-Name header). A file on your own disk (image, audio or font) goes through create_upload_link(name): it returns a curl line that PUTs the file to a signed link good for 15 minutes, and the stored filename to pass to the layer tools.