Common mistakes
These are the recurring ways an agent gets a Morpha session wrong. Most trace back to skipping describe_video or misreading a convention.
Inventing element ids
Always pull element ids from describe_video. Don't construct image.title or shapes.0 from intuition — element ids are opaque 6-char hex tokens generated when the layer is created (image.a1b2c3, text.7f3a2c), with no relationship to the layer's name, filename, or content. They are storage keys, not meaningful identifiers. Address layers by name in your reasoning; address them by id only after reading them out of describe_video. An invented id makes the tool fail or, worse, silently target nothing.
Treating (x, y) as top-left
A layer's (x, y) is the centre of its bounding box, not the top-left corner (Premiere / Final Cut / Motion convention). To place a 200×80 label flush against the canvas's top-left, its centre is (100, 40) — half its width and half its height in. Setting (x, y) = (0, 0) parks three-quarters of the layer off-canvas.
Confusing frames and seconds
The timeline is 30 fps and every frame: argument is an integer frame number. A 2-second fade is frames 0..60, not 0..2. Convert with frames = round(seconds × 30). (There's no set_duration — the composition length is derived from content and auto-fits the furthest keyframe / video / audio end.)
One version per tool call
save_version is for logical change-sets, not individual mutations. Versions are user-visible in the editor's Versions panel — thirty versions each named "change" bury the user. Bundle a session's mutations and save one version at the end with a short, descriptive, imperative-mood label.
Building a flat pile instead of a grouped tree
Leaving a composition as forty unconnected layers — and rebuilding native features by stacking more of them — is the quiet failure mode that makes an agent's output hard for the user to touch afterwards. Two fixes:
- Group each thing you drew. Anything that is one thing gets one named group — both a composed object (a butterfly made of wings + body + antennae + spots, an icon built from primitives, a rocket, a crest: if a human would name it with one noun, it's one group named that noun) and a layout unit (a card + its heading + badge, a lower-third, a lockup). The composed-object case is the one agents skip, and it hands the user a dozen loose ellipses where they expected
butterfly.group_layersthem as soon as that thing's pieces are siblings, and name the group what the thing is — the name is the user's only handle on it. Then animate the group, not each child — a group transform composes onto every descendant, soapply_preset("group.<id>", "slide-up", 0)slides the entire card in with one call instead of keyframing four layers. (Reserve the per-child batch tools for independent motion — staggered cascades, rippling grids. The one carve-out is many copies of a single element: aduplicate_layerscatter is fine flat. Andungroup_layersdiscards the group's own animation tracks.) - Use the native feature, don't fake it. A border is
set_styleborderWidth/borderColoron the shape — not a larger rect behind it. A button / CTA / chip / labelled badge is ONE text layer withset_text_background(fill +padding+cornerRadius+ optional stroke; new text layers already hug, so padding shrink-wraps the box) — not a rounded-rect shape with a text layer parked on top, which has no padding relationship and drifts apart the moment the label or the scale changes. Only curved text needs a shape behind it — the box isn't painted on a bent baseline. A gradient is one Fill — not stacked bands. A mask isset_matte_source— not opaque shapes covering what you want hidden. Every faked version doubles the layer count and drifts apart under scale/animation.
Animating with move_layer
move_layer sets a layer's static base value. If the property already has a keyframe track, the track overrides that static value at every frame — your move_layer call appears to do nothing. To animate, use add_keyframe. To change an un-animated default, use move_layer. The describe_video overview flags which properties are animated (each node's animated list); call inspect_layers([elementId]) to read the actual keyframe values before deciding which.
remove_layer on a group
remove_layer deletes leaf layers (video / image / text / shape) and errors on a group. To get rid of a group use ungroup_layers, which dissolves the group but keeps its children alive, spliced into the group's old parent.
Losing keyframes on a swap
To change a layer's image or clip, use set_image_filename / set_video_clip — they keep the layer's id and every animation track. remove_layer followed by add_image_layer mints a new id and drops all the keyframes. Same trap, different tool: ungroup_layers discards the group's own animation tracks (the children survive, but the group's keyframes are gone) — save a version first if the user might want them back.
Mixed-parent grouping
group_layers requires every listed element to currently share the same parent — all at the root, or all inside one existing group. To group elements that live in different parents, first set_group_parent them into a common parent, then group_layers.
set_style fields that don't apply
set_style accepts image-only fields — fit, anchorX, anchorY, tintColor, tintStrength, alphaMask. They land in the JSON on a shape or video but the renderer ignores them there (tintColor/tintStrength are image-only; fit/anchor* are image+video). set_style on a group.<id> is an outright error — groups have no styled body; colour a group with set_group_box + set_layer_fill instead.
Referencing an asset that isn't uploaded
add_image_layer, set_image_filename, add_video_layer, set_video_clip, and add_audio_overlay all reference a filename that must already exist in the project's asset/clip bucket. describe_video lists layers, not the asset bucket, so confirm the upload before referencing a new filename. For video clips there are MCP upload tools — upload_clip(url) for a direct link, or upload_clip_presign + upload_clip_finalize for a local file (PUT the bytes to the returned presigned URL). For images use upload_image(url) or find_public_image(query). For audio use upload_audio(url) (.mp3/.m4a/.wav/.ogg/.aac) — or drag it into the editor / POST /api/upload-asset/<projectId> with the raw bytes and an X-Filename header — first.
Telling the user to reload when they don't need to
An open editor tab picks up your changes on its own. When you mutate a project the user is watching, the change lands in their editor within a few seconds — merged into whatever they're doing, not swapped over it, so their unsaved edits and their undo history both survive. Don't tell them to refresh; just tell them what you changed.
Two caveats worth knowing:
- If they're mid-gesture — dragging a layer, typing on the canvas — the change waits for them to finish and lands a moment later.
- If you edit the same layer they're editing, yours wins. Changes to different layers both survive.
What still needs a reload is the project list, not the project: a project you create, duplicate, delete, or re-id won't appear or disappear in their picker until they reload.
Emptying the embed allowlist by accident
set_embed_origins replaces the whole allowlist with the array you pass. Passing [] — or removing the last entry with remove_embed_origin — turns embedding off: the public embed endpoint then 404s the project. If you only mean to add or drop one hostname, use add_embed_origin / remove_embed_origin rather than rebuilding the list.
Confusing pure dispatch with the hosted client
The SDK ships two layers. dispatch.<tool>(project, args) is pure and local — it mutates an in-memory project object and persists nothing. morpha.callTool(projectId, name, args) is the hosted path — it loads from storage, dispatches, and writes back, exactly like MCP / POST /api/tool. Reach for dispatch to build a project offline that you save yourself; reach for callTool to edit a project the editor will see. Calling dispatch and expecting the change to show up in the editor is the giveaway you wanted callTool.