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.

The same trap has a second half. (x, y) is expressed in the element's own frame, which is canvas coordinates only at the root. Inside a group it is the group's space, so a canvas position written straight into a nested layer's x puts the layer somewhere else entirely. inspect_layers reports both: write back the x it gave you, and read canvas_x for where the layer actually sits on the canvas.

Putting text under the platform UI

On a 9:16 canvas, TikTok and Instagram Reels draw their own buttons, caption block and tab bar over the edges of the video, and Instagram's feed crops tall videos to 4:5. A caption along the bottom of the canvas, or a logo in a top corner, can sit fully on the canvas and still be hidden. describe_video returns platform_safe_area, the rect that stays visible; keep text, captions, logos and buttons inside it. People see the same area in the editor through the Safe zones overlay and the crop marks, but an agent can't, so read the numbers.

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).

Composition length is the one place seconds are the unit. It is derived from content by default, auto-fitting the furthest keyframe / video window / audio end. To pin an explicit length instead, call set_duration(seconds) (1-second floor, no ceiling), and fit_duration_to_content() releases the pin.

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.

Whole-project restore for a one-page change

On a multi-page project, restore_version(projectId, versionId) replaces every page — including pages edited since the version was saved. When you're unwinding a change on one page, check the version's cp field from list_versions (which pages it changed) and call restore_version(projectId, versionId, page_index) instead: only that page reverts, matched by stable page id, and the user's other pages keep their edits.

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:

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, and what happens next depends on which property you wrote. x, y, width, height and rotation land in the JSON and paint nothing, so the call appears to do nothing. scale and opacity are refused outright, on the grounds that a value nothing paints is worse than an error; pass clear_animation: true if you really want the static value to replace the animation. set_layer_fill and set_text_background refuse on the same grounds (see below). 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.

Recolouring a layer whose fill is animated

set_layer_fill writes the layer's static fill, and a colour track beats the static field at every frame — so on an animated layer that write would paint nothing. It is refused rather than silently swallowed. Change the colour at a frame with add_color_keyframe, or pass clear_animation: true if the user really wants the animation gone. set_text_background follows the same rule for a text layer's box. Check inspect_layers for colour keyframes before assuming a plain recolour will land.

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). On a group.<id>, set_style accepts blend_mode and nothing else: a group has no styled body of its own, but it does have a composite that blends onto the parent canvas. Every other field is rejected. 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 is no MCP upload tool — use the npm SDK's client.addVideo, which uploads and processes the clip in a real local browser. For images use upload_image(url) with a direct file link you found yourself (or the SDK's findPublicImage, which searches Openverse from your machine). 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 the display name in an X-Upload-Name header — first. For a file that is on your own disk, create_upload_link(name) returns a curl line to run; do not leave a placeholder shape and ask the person to drag the file in.

Referencing the name you gave a file instead of the filename it got

Morpha names every stored file itself. upload_image, upload_audio, create_upload_link and every SDK upload return { filename, name }, where filename is an opaque id such as 3f2a9c1e-5b7d-4e8f-9a0b-1c2d3e4f5a6b.png. Pass that exact value to add_image_layer, set_image_filename, add_video_layer, add_audio_overlay or set_custom_font. Referencing logo.png because the file was called that points at no file, and the tool fails.

Don't show the id to the person, either. name is what people see, so refer to the file by its name and pass it on as the layer's name. Uploading a file under a name that was used before adds a second file; it never replaces the first. To swap a layer's picture, upload the new file and pass its filename to set_image_filename.

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 in well under a second — 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.

Three caveats worth knowing:

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.

An anonymous account from registerAccount() or POST /api/auth/agent-register has no password, no email and no Google link, so nobody can sign into it. The API key is the whole credential. That is fine while you are working, and it strands the result if you stop there: you hand over an MP4, and the project it came from sits under an identity the person cannot reach. An anonymous account nobody claims is deleted after 30 days, and its project goes with it.

Finish the job by giving the person the claim link. Registration returns it as claimUrl, and every create_project, open_project and list_projects result from that account carries it again, so you can't lose it:

https://morphareels.ai/claim/…

They open it, sign in or sign up as themselves, and the project moves into their own account, editable, and they save the video by choosing Share, then Download (Render MP4 on a phone). You never need their email address.

Once the person claims the project, the anonymous account and its key are gone, so the claim link is the last thing you send.

The same account also has no AI credit envelope, on purpose. Calls to Morpha's own model are refused with agent-account-no-ai. Every tool in the catalog is free and unmetered, so drive them with your own model. A person who wants Morpha's in-editor AI uses their own account, which is another reason to hand the project over.

Confusing pure dispatch with the hosted client

The SDK ships two layers. dispatchOnProject(project, name, args) is pure and local: it returns { project, result } from an in-memory project and persists nothing. morpha.callTool(projectId, name, args) is the hosted path, which loads from storage, dispatches, and writes back, exactly like MCP / POST /api/tool. Reach for dispatchOnProject to build a project offline that you save yourself; reach for callTool to edit a project the editor will see. Calling the local dispatcher and expecting the change to show up in the editor is the giveaway you wanted callTool.

Pick the right local one, too. The lower-level dispatch[name] catalog is also exported, and it operates on a single page's Composition rather than on a Project, so handing it a project record edits nothing. dispatchOnProject is the router the hosted surfaces themselves use: it runs the page tools on the project and aims every content tool at the active page. Use it unless you are deliberately driving one page's composition.