trvrse cinema — MCP guide for agents
You are (or are configuring) an LLM agent connected to the trvrse cinema
MCP server. It exposes a five-stage film pipeline — Script → References →
Storyboards → Videos → Assembly — as 31 tools: 18 stage-by-stage tools you
drive yourself, plus 3 Autopilot tools (make_film, get_run,
cancel_run) that hand the whole pipeline to the server for one-call film
production, plus make_long_film (multi-minute films with consistent
characters, §3) and get_gate_review_link (mint a no-login human review
link for a gated stage), plus 5 cast tools (save_character_to_cast, list_cast,
delete_cast_member, set_cast_voice, refresh_cast_portrait) for episodic reuse of
approved characters, plus refresh_character_portrait for re-minting a
non-cast character's expired trusted portrait, plus
set_shot_face_trust_override for per-scene face-moderation control.
Separately, 13 asset tools (estimate_generation_cost, generate_image,
edit_image, generate_storyboard, promote_storyboard_panel,
upload_image, list_images, generate_video, get_video_job,
get_tool_call, list_videos, cancel_video_job, get_credit_balance) generate reference images and
Seedance 2.5 videos without a cinema project (§4). A third surface, 6 video-plan tools
(estimate_video_plan_cost, create_video_plan, get_video_plan,
list_video_plans, approve_video_plan, reject_video_plan, §5), builds
a full Seedance 2.x manifest — brief, refs, prompt, cost — that a human OR
the agent itself approves before it dispatches. This guide
is the complete playbook: connecting, the workflow loop, Autopilot,
standalone assets, video plans, cost and error contracts, and a worked
end-to-end transcript.
Endpoint: https://www.trvrse.com/api/mcp/mcp · Keys: minted by a human at
Settings → Agent access · Raw markdown version
of this page: https://www.trvrse.com/docs/agents.md
1. Quick start
Mint a key
- Sign in to the app and go to Settings → Agent access (https://www.trvrse.com/settings/agent).
- Click New key, give it a name (and, optionally, a spend cap in credits — leave blank for uncapped).
- The plaintext key is shown exactly once in a modal you must
explicitly dismiss with Done. Copy it now — it is never shown again
(only a
trv_+ 8-char prefix is kept for display). If you lose it, revoke the key and mint a new one.
The key looks like trv_ followed by 43 base64url characters, e.g.
trv_9F2k7q....
Endpoint
https://www.trvrse.com/api/mcp/mcp
(mcp-handler's basePath is /api/mcp; it appends the mcp transport
segment itself. Streamable HTTP only — SSE is disabled.)
Claude Code
claude mcp add --transport http --scope user trvrse https://www.trvrse.com/api/mcp/mcp \
-H "Authorization: Bearer trv_YOUR_KEY_HERE"
Every per-client recipe (Claude Code, Claude.ai, ChatGPT, Cursor, mcpServers JSON) with the key pre-filled lives on Settings → Agent access → Connect and at https://www.trvrse.com/docs/agents/connect.
Generic MCP client (JSON config)
Most MCP clients (Claude Desktop, other agent runtimes) accept a config block like:
{
"mcpServers": {
"trvrse": {
"url": "https://www.trvrse.com/api/mcp/mcp",
"transport": "http",
"headers": {
"Authorization": "Bearer trv_YOUR_KEY_HERE"
}
}
}
}
The exact key name/shape (url vs command, headers vs env) varies by
client — anything that supports a streamable-HTTP MCP transport with a
custom header works. The same canonical endpoint also supports the OAuth
flow used by the OpenAI Plugins Directory; existing trv_ keys remain valid.
claude.ai and legacy ChatGPT custom connectors
claude.ai and ChatGPT custom connectors support only OAuth or "no
authentication" — never a custom Authorization header, so the two
connect methods above don't work there. Use the keyed-URL endpoint instead
(shown alongside the plaintext key when a new one is minted, on Settings →
Agent access):
https://www.trvrse.com/api/mcp/k/trv_YOUR_KEY_HERE/mcp
claude.ai — Settings → Customize → Connectors → Add custom connector → paste the URL above, leave authentication as None.
For a legacy ChatGPT developer-mode custom connector, the same keyed URL
continues to work with authentication None. The Plugins Directory uses
the canonical /api/mcp/mcp URL and OAuth instead; never submit a keyed URL.
The key rides in the URL path; the endpoint injects it as the bearer token internally and forwards to the same authed handler above — no second server, no different tool catalog. The URL is bearer-equivalent: treat it like a password (don't paste it somewhere public), prefer a spend-capped key per connector, and revoke it any time from Settings → Agent access if it leaks.
Sanity-check the connection
A malformed, unknown or revoked key is refused at initialize, so
claude mcp list shows ✗ immediately. To see WHY, and what the key has left:
curl -sS https://www.trvrse.com/api/mcp/health -H "Authorization: Bearer trv_YOUR_KEY_HERE"
# 200 {"ok":true,"keyName":"…","spendCap":500,"spent":0,"remaining":500,"lastSeen":null,"lastSeenClient":null}
# 401 {"ok":false,"reason":"malformed"|"unknown"|"revoked"} 429 rate-limited
Then ask the agent to call list_projects. A working connection returns
real JSON; a key revoked mid-session returns an isError result naming
BRIDGE_AUTH_FAILED (see the error contract below).
Install this guide as a skill (optional)
Claude Code and other skills-capable agents can carry this playbook in-context instead of re-reading it each session:
SKILLS_DIR=~/.claude
mkdir -p "$SKILLS_DIR/skills/trvrse-cinema" && \
curl -fsSL https://www.trvrse.com/docs/agents/skill.md \
-o "$SKILLS_DIR/skills/trvrse-cinema/SKILL.md"
2. The workflow loop
Every film follows the same loop per stage:
- Check state —
get_project_status: read the five stage statuses andnextActions(it names exactly the tool calls the pipeline admits next; trust it over guessing). - Quote —
get_cost_estimatefor the stage you are about to generate. - Generate — call the stage's
generate_*tool withconfirmedCostset to the quote's exacttotal. - Poll —
get_project_statusevery 15–30 seconds until the stage's entities leaveGENERATING(typically 1–3 minutes). - Review & approve — inspect results (statuses, failure reasons),
regenerate anything that failed, then call the stage's
approve_*tool to unlock the next stage.
Script is the exception: generate_script and rewrite_scenes run the
LLM in-request and return the full screenplay synchronously (~30–60
seconds), and script work is free — no quote needed.
3. One-call films (Autopilot)
Instead of driving the five-stage loop above yourself, make_film hands
the whole pipeline to the server: submit a brief and a budget, then poll
get_run until the film is done.
make_film
briefText(required) — the production brief the whole film is generated from.profile(required) —"cinematic"(16:9 default) or"short"(see below).budgetCredits(required) — a hard ceiling for the run's total spend (see "Budget is a hard ceiling" below).gates(optional, default[]= fully unattended) — which of"script","references","storyboards","videos","assembly"pause the run for human review (see "Gates" below).webhookUrl(optional,https://only) — receives run events (see "Webhooks" below).title,aspectRatio,videoModel(optional) — same shapes ascreate_project;profilesupplies defaults you can override.
Returns {runId, projectId, webhookSecret?}. webhookSecret is present
only when webhookUrl was set, and is shown exactly once, here — save
it immediately, it is never returned again and you need it to verify
webhook deliveries.
make_long_film — multi-minute films with consistent characters
Use this instead of make_film when the user wants a long-form piece
whose characters must stay recognisably the same person throughout.
It runs the same pipeline, but changes how video is generated. make_film
renders one independent clip per storyboard page and concatenates them, so
a 3-minute film is 20–40 separate generations — each an independent roll of
the dice on character identity. make_long_film renders each page as ONE
continuous multi-shot Seedance 2.5 generation and continues the previous
page through native video extension, so the same film is about 6
generations. Every segment re-sends the same approved character portraits:
the extension carries continuity, the portraits carry identity.
- Pinned to
seedance-2.5-reference.videoModelis not selectable. - Total runtime is capped at 180 seconds. A storyboard that plans more is refused with the exact overage — cut scenes and regenerate.
gatesdefaults to all four review points ("script","references","storyboards","videos"), because the point of this tool is that a human signs off on the cast and the plot before the expensive video leg. Pass[]only if the user explicitly asks for an unattended run.- Other arguments match
make_film:briefText,budgetCredits,title,castIds,webhookUrl,aspectRatio.
Budget guidance: a 3-minute film at 720p is roughly 4,500 credits of video alone, before reference images.
Set expectations about the cuts. Extension preserves each character's appearance and the surrounding location across segments, but every segment boundary is a cut — the model continues the story, not the exact frame. The result reads as a normally edited film, not a single unbroken take.
get_gate_review_link — hand a human a no-login review link
When a gated stage is waiting (get_run reports WAITING_GATE, or a stage
sits in REVIEW), call this to mint a review URL and give it to whoever is
deciding. The page opens without a login and shows exactly what that gate
covers — the scenes, the character portraits, the boards, or the generated
segments — with Approve and Reject.
projectIdandstage("script"|"references"|"storyboards"|"videos").- The URL is returned exactly once and is not recoverable afterwards. It is deliberately never written into run events or webhook payloads, so pass it straight to the user rather than expecting to find it later.
- Minting supersedes any earlier link for that stage, so a URL you handed out before regenerating cannot approve the new work. Unlocking a stage supersedes outstanding links too.
- Valid 7 days. Refused with
CINEMA_INVALID_STATEif that stage is not awaiting review, since a link would approve nothing.
You may still clear a gate yourself with approve_script,
approve_references, approve_storyboards or approve_videos when the user
has delegated the decision to you.
Refusals: AUTOPILOT_RUN_LIMIT (this key already has 3 active runs —
cancel_run one or wait for it to finish), BRIDGE_SPEND_CAP
(budgetCredits exceeds the key's remaining spend cap), CINEMA_INVALID_INPUT
(unknown gate name, non-https webhookUrl, or budgetCredits <= 0).
Poll with get_run
Call get_run({ runId }) every 30–60 seconds instead of
get_project_status while a run is active. It returns status,
currentStage, attentionReason?, spentCredits/budgetCredits,
profile, gates, per-stage project detail (the same shape
get_project_status returns), film (once COMPLETED), the last 20
events, and nextActions.
status meanings:
RUNNING— actively progressing; nothing to do but poll.WAITING_GATE— paused atcurrentStagefor human approval in the trvrse app (see "Gates" below).NEEDS_ATTENTION— paused on the budget ceiling, an error, or exhausted retries; readattentionReasonandevents.COMPLETED/FAILED/CANCELLED— terminal.
Budget is a hard ceiling
budgetCredits caps the run's entire spend, not just one stage.
Before dispatching any stage's paid work, the server checks whether that
stage's cost would push the run's spend over budgetCredits; if it
would, the run pauses NEEDS_ATTENTION instead — it never overspends and
it never silently truncates or downgrades work to fit under the ceiling.
There is no tool to raise budgetCredits on an existing run: a
budget-paused run resumes only if the blocking stage's cost drops (e.g. a
cheaper model chosen for that stage in the app) or you cancel_run it.
Retries
Each stage gets one automatic retry wave before pausing
NEEDS_ATTENTION — this includes the LLM generation steps (script,
visual bible, storyboard plan), which get 2 attempts total (the original
try + the one retry) the same as every other stage. After the retry wave
is used, a further failure pauses the run for a human to fix (regenerate
or approve in the app) or cancel_run.
Paused runs (WAITING_GATE or NEEDS_ATTENTION) are not stuck waiting on
you to nudge them: the server automatically re-checks every paused run on
a short interval and resumes it within roughly 2 minutes of the blocker
clearing (a gate being approved, a retry-eligible fix landing) — you never
need to call anything to "wake" a run yourself.
Your own retries are safe too — if you pass idempotencyKey. make_film
and make_long_film book a whole budget the moment they run, so a call whose
response was lost to a client timeout and then blindly re-sent would start a
SECOND full-budget run. Pass a unique idempotencyKey (e.g. a UUID) on every
call: a retry with the same key replays the original {runId, projectId, webhookSecret?} verbatim and starts nothing; a retry while the original is
still running is refused IDEMPOTENCY_IN_PROGRESS (keep the key, try again
shortly); a same-key call with a changed brief is refused
IDEMPOTENCY_ARGS_CHANGED (use a fresh key for the new request). get_tool_call
reads any key's stored result back without re-sending the args — check it
before ever re-firing after a timeout. generate_script and rewrite_scenes
take the same idempotencyKey and replay their original screenplay/scenes
instead of paying for a second generation.
Gates
gates lists which stages pause the run WAITING_GATE for human review
before continuing — approval happens in the trvrse app, not through
this bridge (there is no MCP "approve this gate" tool). Once approved
there, the orchestrator resumes automatically (poll get_run to see the
pause and the resume). Omit gates (or pass []) for a fully unattended
run.
"assembly" is the final-review gate: when it's included, the run
pauses WAITING_GATE with the finished film already rendered, and
completes only once a human approves the assembly stage in the app.
Webhooks
When webhookUrl (https:// only) is set, the server POSTs a JSON body
to it after every run-visible event:
{ "runId": "...", "type": "stage.completed", "stage": "references", "detail": "...", "timestamp": 1732000000000 }
Event types: stage.completed, gate.pending, needs_attention,
run.completed, run.failed, run.cancelled.
Every delivery carries X-Trvrse-Signature: sha256=<hex>, an
HMAC-SHA256 of the raw POST body keyed with the webhookSecret
make_film returned. Verify against the raw bytes you received — not a
JSON.parse'd-then-JSON.stringify'd copy, which is not guaranteed to
byte-match:
import crypto from "node:crypto";
function verifyTrvrseWebhook(rawBody, signatureHeader, secret) {
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}
Delivery is retried at 0s / 30s / 5min after a non-2xx response or a
network failure; if the final attempt still fails, deliveries stop for
that run (a "dead letter") — the events themselves remain readable via
get_run, only the push channel goes quiet.
cancel_run
Stops future spend and dispatch only (RUNNING/WAITING_GATE/
NEEDS_ATTENTION → CANCELLED). Anything already dispatched settles
normally and its credits stand — nothing is refunded, and a cancelled run
cannot be resumed. Refused with AUTOPILOT_NOT_ACTIVE if the run is
already COMPLETED/FAILED/CANCELLED.
Short profile
profile: "short" is the vertical-clip profile: aspectRatio defaults to
9:16 (still overridable), the script targets 2–4 scenes and a 15–60
second film, and videoModel defaults to a budget model chosen for cost
(pass videoModel yourself to override it). Everything else — character
and location consistency across shots, face-trust routing, review gates —
is identical to the "cinematic" profile; "short" only changes
length, pacing, and the default model, never the consistency guarantees.
Persistent cast (episodic films)
The cast library holds frozen snapshots of APPROVED characters — portrait, sheets, identity prompt, face-trust flag — reusable across films for identical faces every episode. The loop:
- Save: once a character is APPROVED with a portrait in any project,
call
save_character_to_castwith itsprojectId+characterId(fromget_project_statusrefs). The snapshot is immutable — a new look means a new cast entry. Cast names are unique per account (case-insensitive); passnameto override on collision. - List:
list_castreturns every member (castId, name, role,containsRealFace,usageCount) — text only, no image URLs. - Reuse: pass the ids as
make_film'scastIds(up to 15). Each member is written into the screenplay prompt by name, then seeded into the film's roster with its exact saved look — references for seeded characters cost 0 credits and are never regenerated.
Two boundaries to know:
- The guarantee is the roster row + frozen face, NOT screen time. If
the screenplay leaves a cast member out of every scene, the run reports
a
cast.unfulfilledevent inget_runand continues — give the member screen time by naming it in the brief (or accept the roster-only presence). - Deletion is guarded.
delete_cast_memberis refused while any of your ACTIVE runs lists the member incastIds(CINEMA_INVALID_STATE) —cancel_runthe run or wait for it to finish first. Renaming a member in the app is guarded the same way, because runs match cast by name. - Trusted portraits expire (~24h). A member reused days later can park
your run NEEDS_ATTENTION with a "stale cast portrait" reason. The fix
loop:
list_cast→ any member withportraitStale: truecarries its exactportraitRefreshCost→ callrefresh_cast_portraitwith that number asconfirmedCost(one portrait charge; the same frozen face re-rendered and fanned out to every film using it) → the run resumes on its own next sweep. - A cast member can carry a locked voice.
set_cast_voicebinds one of your voice samples to the member;generate_voiceoverwithcastMemberIdthen always speaks in it (modelchatterbox, quote with that model). It locks voiceovers only: the voices a video model invents in a shot's own dialogue are not locked yet. Only where the deployment has switched locked voices on. - Non-cast portraits expire too. The same staleness applies to
characters that were never saved to the cast:
get_project_status→refs.characters[]carriesportraitStale/portraitRefreshCostper character → callrefresh_character_portraitwith the project's id, the character's id, and that cost asconfirmedCost(same one-portrait charge, same confirmed-cost contract). - Real-face characters on animated projects trip Seedance face
moderation (an untrusted gpt-image-2 portrait), and a keyframe-less
scene has no kling fallback. Scenes featuring one are auto-seeded a
"require-trusted"face-trust override at materialization —generate_videos/regenerate_scenethen REFUSE pre-charge (free, no wasted submission) naming the character and its remedies: refresh that portrait viarefresh_character_portrait(the likeness-preserving fix), or callset_shot_face_trust_overridewith"exclude-untrusted"to drop the untrusted reference (identity rides the shot text — a deliberate likeness sacrifice), or regenerate with a non-Seedance model. Read the current value fromget_project_status→video.scenes[].faceTrustOverride(hasRealFaceCharactermarks the affected scenes);"auto"clears it.
4. Reference assets + Seedance 2.5 videos
These tools work without a cinema project: generate reference images into your library, then feed their ids to Seedance 2.5 video generation. This is the surface for one-off viral shorts; the five-stage pipeline above is for scripted films.
The loop
-
Quote —
estimate_generation_costwithkind:"image"(model,numImages) orkind:"video"(model,duration,multiPromptShots,resolution,aspectRatio,generateAudio,hasReferenceVideos). SethasReferenceVideos:truewhen thegenerate_videocall this quote is for will attachreferenceVideoUrls— Seedance 2.x billing floors at 5s whenever reference videos are attached, even for a shorter requested duration, and this flag is what lets the quote see that floor ahead of time. Free. -
Generate refs —
generate_image(text-to-image); useedit_imageto iterate on a library image you already have instead of re-rolling from scratch.Content checkers differ by model. A refusal
{success:false, error:"ASSET_CONTENT_REFUSED: …"}means THIS model's checker said no — not that the picture is unacceptable everywhere. On one real job a cartoon bee holding a shield, drawn for a children's immunisation leaflet, was refused bynanobanana-pro-img2imgand rendered without complaint bygpt-image-2-img2img, so anedit_imagerefusal names that model and it is worth retrying there rather than guessing down the catalog. Forgenerate_imageno alternative is named: only that one image-to-image pair has been observed, and the obvious text-to-image candidate uses a STRICTER checker, so reword the prompt instead. Nothing is charged for a refused render, and no model is ever substituted for you. -
upload_imageimports an image into the library, free — pass eitherimageUrl(a public http(s) URL, ≤50MB, fetched server-side) orimageBase64(the raw base64 / adata:URI of a local file that isn't publicly hosted, ≤5MB decoded); exactly one, never both. UseimageBase64for files on disk (e.g.upload_image({ imageBase64: "iVBORw0KGgo…" })); for anything over 5MB decoded, host it and useimageUrl.list_imagesreads the library back as{success:true, images:[…], nextCursor?}(newest first,limit≤50, default 20); whennextCursoris present, pass it back ascursorfor the next page, and filter withstatus:"PENDING"|"PROCESSING"|"COMPLETED"|"FAILED"(a cursor is opaque and bound to its status filter — a malformed or mismatched one is refusedASSET_INVALID_INPUT). Chat attachments: an image the user attaches in claude.ai / ChatGPT is NOT delivered to you as bytes or as a public URL, so it cannot be imported byupload_image— never pass the attachment's in-app handle asimageUrland never invent a URL. Instead give the user this exact one-step instruction: open https://www.trvrse.com/workspace/drop, drop the image, and paste the image id back here — the page uploads it into their library and shows a copyableimageId. That id is directly usable assourceImageIdsonedit_image, or assourceImageId/referenceImageIdsongenerate_video, so nolist_imagescall is needed. (Images dropped there are NOT face-scanned: a real person's photo may still be refused as a video reference — use acontainsRealFace:truegenerate ref for real people.) Alternatives, if they prefer: paste a publichttps://link to the image (thenupload_imagewithimageUrl), or — only when YOU can read the file's bytes yourself (Claude Code / a local agent) — pass them asimageBase64. What a generation returns:{imageId, images:[{imageId, url, width, height, status}], creditsCharged, reservedCredits}—imageIdis the first successful image,imageslists every billed row (aFAILEDrow carries an emptyurl), andcreditsChargedis what was actually charged after refunds for failed rows, which can be belowreservedCredits(the quote reserved against your key's cap). Timeouts:generate_imageandedit_imageare synchronous by default — the call blocks until the render finishes, which can exceed 60s (2K renders have been observed at ~120s) while many MCP clients give up at a 60s default per-request timeout. Preferasync:true(it requiresidempotencyKey): the call returns{success:true, accepted:true, idempotencyKey, reservedCredits}immediately; pollget_tool_callwith that key every 10–15 seconds untilstatusisCOMPLETED— itsresultis then exactly the payload a synchronous call returns, or{success:false, error:"ASYNC_JOB_FAILED: …"}if the render failed (credits refunded; that key is settled and replays the failure, so retry with a freshidempotencyKey).quality:"medium"is the speed lever for drafts ongpt-image-2(same price, a fraction of the render time). If you stay synchronous: raise your client's per-request timeout to 180s+, always passidempotencyKey, and treat a client-side timeout as an UNKNOWN outcome, not a failure: the credits may already be spent and the image in the library. Checkget_tool_callwith the same key (orlist_images, or re-call with the sameidempotencyKey, which replays the original result) before dispatching a fresh attempt — a blind retry double-spends. -
Generate the video —
generate_videowith the image ids andconfirmedCostset to the quote's exacttotal. -
Poll —
get_video_jobevery 15–30 seconds untilCOMPLETED(videoId+videoUrl) orFAILED(errorMessage,refunded:true). An unknown or foreignjobIdreturns{success:false, error:"ASSET_JOB_NOT_FOUND"}. -
Iterate — tweak the keyframe with
edit_image, re-quote, re-render.
The cost contract is the same shape as the cinema pipeline: echo the
estimate's total as confirmedCost; a drifted quote is refused with
{success:false, error:"COST_CHANGED", newEstimate} — re-quote and retry.
Domain refusals (BRIDGE_SPEND_CAP when the key's cap is exhausted,
insufficient credits, validation, provider errors) come back as readable
{success:false, error} results, never protocol errors.
Asset strategy
gpt-image-2(thegenerate_imagedefault, 20 credits per image) for hero keyframes, style frames, and scene refs. Useresolution:"2K"for the keyframe — it's the first frame the viewer sees. It is also the most expensive option here, so it is worth knowing what the alternatives cost before boarding a whole film with it:nano-banana-2(10),gpt-image-1.5(10),seedream-4.5(10),flux-2-pro(10),flux-2-klein(5),seedream-5-0-pro(9).estimate_generation_costis free and authoritative.- A Seedream 5.0 model is REQUIRED for any ref showing a real person's
face —
seedream-5-0-lite(4 credits) orseedream-5-0-pro(9 credits). Either lands the BytePlus trust URL Seedance 2.x needs to accept a real-face reference; Lite is the default and the cheap one, Pro is the higher-fidelity opt-in. The trust window is ~24h;list_imagessurfaces it per image asseedanceReady— check it before spending video credits on a stale face ref. seedream-5-0-procaveats:resolutionis1K/1.5K/2Konly (higher values clamp to2K, and1.5Kis accepted for this model alone), and it returns exactly ONE image per call no matter whatnumImagessays.edit_image(image-to-image from owned library image ids) to iterate:gpt-image-2-img2img(default, ≤4 sources),nanobanana-pro-img2img(≤14),seedream-5-0-lite-img2img,seedream-4.5-img2img,flux-2-pro-img2img.characterRefImageIdsongenerate_videocarries a character's identity across videos — reuse the same character ref image in every render of a series.
Storyboards: board the sequence in ONE render
Do not render one image per panel to decide a direction. Twenty single-panel renders at 20 credits each is 400 credits spent answering a question a single sheet answers for 20.
generate_storyboardrenders 2–9 shot sketches as ONE numbered grid with a caption strip under each panel — one provider call, one image's credits, ≈ 1/N the cost of N single renders. Quote it as one image (kind:"image",numImages:1, the same model). Leavelayouton"auto": it picks the smallest grid that holds your beats with no wasted cells, laid out for the sheet aspect — on 16:9, 1 beat is1x1, 2 is1x2, 3 is1x3(one row of three, each panel twice as tall), 4 is2x2, 5–6 a2x3, 7–9 a3x3; on1:1the same shapes stack. Layouts read ROWS x COLS. Naming"2x3"for three beats renders a blank bottom row you paid full price for. The sheet is 16:9 or 1:1 (a 3-wide grid at 9:16 squeezes the captions past legibility, so vertical sheets are refused).quality:"medium"is the right draft setting.- The panel numbers are the interface. A human reads "panel 4" off the
sheet and hands that number back. The response's
panelsarray carries each panel'sbboxas fractions of the sheet — real grid geometry, not a guess from the picture — so you can also crop client-side. promote_storyboard_panelthen takes the panels that survived the pick.mode:"crop"is FREE (no provider, no credits);mode:"rerender"redraws that panel standalone at the shot's aspect for one standard image-to-image edit. Feed the returnedimageIdintogenerate_videolike any other reference.- When NOT to use it. A sheet panel is a sketch on a shared canvas: it
is smaller and looser than a dedicated render, and it can carry a hint
of the caption lettering at its edges. For the hero keyframe a viewer
actually sees — and for any real-person face ref, which must come from a
Seedream 5.0 model — render it properly with
generate_image.
The walkthrough, call by call
Six beats, board them, keep one panel, redraw it vertical:
1 get_cost_estimate is for PROJECTS — an asset sheet is quoted as one image:
estimate_generation_cost({kind:"image", numImages:1, aspectRatio:"16:9"})
-> {total: N} # N credits, one image
2 generate_storyboard({beats:[b1..b6], layout:"auto", aspect:"16:9",
confirmedCost:N, idempotencyKey:"board-1"})
-> {imageId, layout:"2x3", aspect:"16:9",
panels:[{index:1..6, bbox}], creditsCharged:N} # N credits, ONE render
3 show the sheet, ask WHICH PANEL. The human answers with a number.
4 promote_storyboard_panel({storyboardImageId, panel:3}) # mode defaults to crop
-> {imageId, mode:"crop", creditsCharged:0} # 0 credits, no provider
5 estimate_generation_cost({kind:"image", model:"gpt-image-2-img2img",
numImages:1, numSourceImages:1}) -> {total:M}
promote_storyboard_panel({storyboardImageId, panel:3, mode:"rerender",
aspect:"9:16", confirmedCost:M})
-> {imageId, mode:"rerender", croppedImageId, creditsCharged:M}
The credits are exactly N + 0 + M — one image plus one edit — against
6 × N for boarding the same six beats one render at a time. Read N and M
from the quote rather than assuming: both tools refuse COST_CHANGED if the
price moved under you, and a same-key retry replays instead of re-rendering.
Every panel is promote-able for 0 credits, so crop all the keepers before you
pay to redraw any of them.
Cinema projects: contact sheet first (opt-in)
The same idea exists inside the cinema pipeline. By default stage 3 renders
every page of every scene before a human has seen one — 20 credits a page,
so a 20-page film is 400 credits sunk before the first "that's the wrong
look". Create the project with storyboardMode:"contact_sheet_first" and
stage 3 changes shape while the default stays exactly as it was:
generate_storyboardsfirst renders the film's signature panel per scene as one or two contact sheets (≤ 9 scenes each, the same numbered gridgenerate_storyboarddraws, 20 credits a sheet). Quote it withget_cost_estimate(stage:"storyboards")as usual;breakdown.phaseis"sheets_pending".- Poll
get_project_status:storyboards.contactPhasebecomes"awaiting_decision"andstoryboards.contactSheets[]carries each sheet'simageUrlplusentries[]mapping the printed panel number to asceneNumber. Show the human the sheet. - Quote the decision —
get_cost_estimate(stage:"storyboards", decisions:[{sceneNumber, decision:"keep"|"drop"|"redo"}, …])— and pass the same array plus itstotaltoapprove_storyboards. Unlisted scenes are kept.keeprenders that scene's full page boards through the normal page path;dropremoves the scene for good (stage 4 never shoots it);redore-sheets its signature panel once (a second pass, never a third). - Poll until the kept pages reach REVIEW, then
approve_storyboardsagain withoutdecisions— from here stage 3 and stage 4 are the ordinary pipeline.
The arithmetic for the 20-page job above: 400 credits up front becomes 40 credits to decide (two sheets, 20 scenes), then 20 credits per page you actually keep — keep half and the whole stage costs 240 instead of 400; keep everything and it costs 440, the 40 being the price of having looked first. The sheet is a staging artefact, not an identity one: character drift on it is expected and is not a stage-2 problem.
Legibility is the caveat, and you should check it, not assume it. A panel of
a 2x3 16:9 sheet is a third of the sheet wide and 41% of it tall (≈ 512×420 on a
1536×1024 render); on a 3x3 it is ≈ 512×273, with a caption strip of 18% of the
cell under it and captions cut to 56 characters — at a word boundary, with an
…, so a caption never ends mid-word, and a caption you pass yourself is cut
the same way. That is small type. When
you show a sheet, ask the human whether they can read the panel numbers — if
not, board fewer beats (2x3 beats 3x3), raise quality, or fall back to
individual generate_image renders. The panels[].bbox geometry is exact
regardless of how the lettering came out, so promote_storyboard_panel still
cuts the right rectangle even from a sheet whose numbers are mushy.
Viral video craft
- 9:16 vertical (
aspectRatio:"9:16") for TikTok/Reels/Shorts. - The hook is the FIRST second. Put the most striking visual/motion up front — when you pass a keyframe, the keyframe IS the hook, so make the keyframe the most arresting frame of the clip, not a neutral pose.
- One clear subject + one camera move per shot. Seedance follows simple, declarative direction better than multi-action choreography.
multiPromptShots(≤10 shots) renders a 2–4 beat multi-scene story in a single 10–15s video — each shot gets its own prompt (and optional duration).generateAudio:true(the default) — sound is half the scroll-stop.- Name refs in the prompt as
@Image Nwith their role — refs bind in ref-pool order (@Image 1,@Image 2, …; a combined keyframe takes slot 1). Say what each one is: "@Image 1 is the opening frame; @Image 2 is the dog's identity."
generate_video models: seedance-2.5-reference (default — text-to-
video plus the full multimodal ref pool) and seedance-2.5-fast
(keyframe image-to-video / first-last-frame — pass sourceImageId, or
firstImageId+lastImageId as a pair; a lone frame is REFUSED, not
ignored, because it cannot be interpolated). Reference caps are
per-model: seedance-2.5 accepts up to 30 reference images, 10
referenceVideoUrls, 10 referenceAudioUrls; seedance-2.0 accepts
9/3/3. Two combinations refuse rather than silently dropping what you
attached: referenceAudioUrls require an accompanying image or video
ref (the provider rejects audio-only requests), and first+last frames are
mutually exclusive with multimodal refs (the keyframes carry the
identity). Every one of these refusals is free — it lands before the
quote, the cap reservation and the debit.
Duration "5s"…"30s" (Seedance 2.5 renders 4–30s; a "3s" request
clamps to "4s"); resolution 480p/720p/1080p with 1080p coerced to 720p
on Seedance 2.x. Attaching referenceVideoUrls floors the BILLED
duration at 5s regardless of the requested duration — pass
hasReferenceVideos:true to estimate_generation_cost to see this
reflected in the quote before spending.
The call is async — it returns {jobId} (plus reservedCredits and
the normalized effectiveDurationSeconds); poll get_video_job.
(Seedance 2.5 is GA. A MODEL_UNAVAILABLE refusal exists only as an
operator rollback lever and always fires before any charge.)
Worked example (viral short)
> estimate_generation_cost({ kind: "image", model: "gpt-image-2", numImages: 1 })
< { success: true, estimate: { kind: "image", model: "gpt-image-2", numImages: 1, creditsPerImage: 20, total: 20 } }
> generate_image({ prompt: "Low-angle hero shot of a golden retriever wearing aviator goggles, neon sunset, cinematic, mid-head-turn", aspectRatio: "9:16", resolution: "2K", confirmedCost: 20 })
< { success: true, imageId: "img_key1", reservedCredits: 20 }
> generate_image({ prompt: "Clean studio portrait of the same golden retriever, face clearly visible", model: "seedream-5-0-lite", confirmedCost: 4 })
< { success: true, imageId: "img_face1", reservedCredits: 4 }
# seedream-5-0-lite lands the BytePlus trust URL Seedance wants for
# real-face refs — verify with list_images → seedanceReady before spending.
> estimate_generation_cost({ kind: "video", model: "seedance-2.5-reference", duration: "10s", aspectRatio: "9:16", generateAudio: true })
< { success: true, estimate: { kind: "video", total: 230, ... } }
> generate_video({ prompt: "@Image 1 is the opening keyframe; @Image 2 is the dog's identity. Slow push-in as the dog snaps its head to camera, ears flapping, synth sting hits.", sourceImageId: "img_key1", characterRefImageIds: ["img_face1"], aspectRatio: "9:16", duration: "10s", generateAudio: true, confirmedCost: 230 })
< { success: true, jobId: "job_1", reservedCredits: 230, effectiveDurationSeconds: 10, generateAudio: true }
# poll every 15–30s...
> get_video_job({ jobId: "job_1" })
< { success: true, status: "COMPLETED", videoId: "vid_1", videoUrl: "https://.../goggles.mp4", ... }
5. Human-approved video plans
Six more tools (estimate_video_plan_cost, create_video_plan,
get_video_plan, list_video_plans, approve_video_plan,
reject_video_plan) build a full Seedance 2.x video plan — a brief, a
model, and a reference manifest that mixes library assets, not-yet-
generated images, and external video/audio URLs — quote and reserve its
entire cost up front, let the server assemble the missing references
and compose the final prompt asynchronously, and only dispatch the actual
video job once the plan is approved. This is the multi-step,
higher-ceremony sibling of §4's single-shot generate_video: use it when
you want a reviewable manifest — and, optionally, human sign-off — before
credits are spent on the video itself, not just the refs.
Policy note: the five-stage cinema pipeline's gates (§2 step 5) are
human-only — there is no MCP tool to approve a cinema stage. Video plans
are different on purpose: approve_video_plan/reject_video_plan let
the agent itself decide, alongside the hosted approval page. Prefer
routing the approvalUrl to a human when one is available; call
approve_video_plan yourself only when running unattended.
The loop
- Quote —
estimate_video_plan_costwith the full manifest (brief,model,durationSeconds,resolution,aspectRatio,generateAudio,prompt?,refs). Free. - Create —
create_video_planwith the IDENTICAL args plusconfirmedCostset to the quote's exacttotal. A drifted quote is refused with{success:false, error:"COST_CHANGED", newEstimate}— re-quote and retry. On success this reserves the full grand total and returns{planId, approvalUrl, expiresAt, reservedCredits}.approvalUrlis shown exactly once, in this response — save it immediately.get_video_plannever re-surfaces it. - Poll —
get_video_plan({ planId })every 15–30 seconds. Whilestatus:"ASSEMBLING_REFS",refs[].statusandrefsCompleted/refsTotalshow reference-generation progress. - Approve — once
status:"READY_FOR_REVIEW"(approvalPending:true), surface the savedapprovalUrlto a human, or callapprove_video_plan({ planId })yourself. First decision wins — whichever happens first (a human opening the page, or an agent calling the tool) resolves the plan; the other is refusedALREADY_DECIDED.reject_video_plan({ planId, reason? })releases the reservation instead — it also works mid-ASSEMBLING_REFS, cancelling assembly in flight. - Poll to completion — after approval,
statusmovesAPPROVED→GENERATING→COMPLETED/FAILED;get_video_plan'svideo.jobId/jobStatus/videoId/videoUrlappear once generation starts (jobStatusmirrorsget_video_job'sQUEUED/PROCESSING/COMPLETED/FAILED). - Discover —
list_video_plans({ limit?, statusFilter? })returns newest-first summaries (planId,status, truncatedbrief,model,createdAt,grandTotal) to rediscover a plan id later. Free.
The reference manifest
Each refs[] entry is { kind, source, imageId?, videoId?, url?, generateSpec?, declaredDurationSeconds? }:
kind:"image","video", or"audio".source:"library"(an existing image/video you own — setimageId/videoId),"generate"(an image the assembly pipeline will create fromgenerateSpec— image refs only), or"url"(an externalhttp(s)URL — video/audio refs only; raw image URLs are unsupported —kind:"image"+source:"url"refusesIMAGE_URL_REFS_UNSUPPORTED; use"library"or"generate"instead).generateSpec(source:"generate"only):{ prompt, model?, containsRealFace }.containsRealFace:trueroutes generation to a Seedream 5.0 model for BytePlus face-trust —seedream-5-0-liteby default, orseedream-5-0-proif you pin it, the same requirement as §4's asset tools — and refusesREAL_FACE_REQUIRES_SEEDREAMif paired with an explicitmodel:"gpt-image-2".declaredDurationSeconds: required forurl-sourced video/audio refs on a Seedance 2.5 plan (2–30s each, ≤30s total per kind, server-enforced). Library video refs read their own known duration instead and don't need this.- Caps (Seedance 2.5, the default model family): up to 50 total refs,
≤30 images / ≤10 videos / ≤10 audio.
create_video_planonly accepts Seedance 2.x models with multimodal reference support — a non-qualifying model refusesMODEL_UNSUPPORTED. - Refs bind into the final prompt in manifest order, per kind —
@Image 1,@Video 1,@Audio 1, … — the samelabelget_video_planreports per ref.
Prompt sourcing
Omit prompt (promptSource:"composer") to let assembly write the final
Seedance prompt from brief + the resolved reference manifest once every
ref is done — this is the default and the recommended path. Pass a
pre-written prompt yourself (promptSource:"agent") to skip composition
entirely; a lint failure on an agent-supplied prompt fails the plan
outright (no retry, since you authored it) with the lint errors surfaced
in get_video_plan's errorMessage — recreate the plan with a fixed
prompt.
Lifecycle and expiry
ASSEMBLING_REFS → READY_FOR_REVIEW → APPROVED/REJECTED →
(APPROVED only) GENERATING → COMPLETED/FAILED, or EXPIRED if a
READY_FOR_REVIEW plan sits undecided past its expiresAt (12 hours
after creation). Rejection, expiry, and any assembly/dispatch failure all
release the plan's unspent reserved credits back to the key's spend cap
automatically — nothing above confirmedCost is ever charged, and nothing
is left permanently reserved on a dead plan.
Worked example
> estimate_video_plan_cost({ brief: "A neon-drenched courier weaves through a rain-slick night market, glancing back at pursuers.", model: "seedance-2.5-reference", durationSeconds: 10, resolution: "720p", aspectRatio: "9:16", generateAudio: true, refs: [
{ kind: "image", source: "generate", generateSpec: { prompt: "Courier character sheet: leather jacket, neon-lit face, determined expression", containsRealFace: false } }
] })
< { success: true, total: 75, breakdown: { refGeneration: [{ label: "Image 1", model: "gpt-image-2", credits: 20 }], video: { model: "seedance-2.5-reference", durationSeconds: 10, resolution: "720p", credits: 55 } } }
> create_video_plan({ /* identical args */, confirmedCost: 75 })
< { success: true, planId: "plan_1", status: "ASSEMBLING_REFS", approvalUrl: "https://www.trvrse.com/approve/tok_9F2k...", expiresAt: 1732050000000, reservedCredits: 75 }
# approvalUrl saved locally — it will not be shown again.
# poll every 15-30s...
> get_video_plan({ planId: "plan_1" })
< { success: true, plan: { status: "READY_FOR_REVIEW", refs: [{ label: "Image 1", status: "DONE" }], refsCompleted: 1, refsTotal: 1, prompt: "...", promptSource: "composer", ... }, approvalPending: true }
# a human opens approvalUrl and approves it there — OR, running unattended:
> approve_video_plan({ planId: "plan_1" })
< { success: true, decidedBy: "agent", status: "APPROVED" }
# poll until COMPLETED...
> get_video_plan({ planId: "plan_1" })
< { success: true, plan: { status: "COMPLETED", video: { jobId: "job_1", jobStatus: "COMPLETED", videoId: "vid_1", videoUrl: "https://.../courier.mp4" }, ... } }
6. The tool catalog
57 tools (31 cinema + 14 asset + 6 video-plan + 6 audio), one call = one pipeline action. Every result is either:
- A domain payload (including
{success:false, error}refusals — these are readable, non-error results the agent should react to), or - An
isErrortool result — auth failures or malformed input (see the error contract below).
| # | Tool | One-liner |
|---|---|---|
| 1 | create_project | Start a new film: brief text, aspect ratio, optional render style/video model, optional storyboardMode:"contact_sheet_first" (decide from contact sheets before paying for full page boards — §4). Free. |
| 2 | get_project_status | THE polling workhorse — all 5 stage statuses, entity statuses, nextActions hints. Free. |
| 3 | get_cost_estimate | Quote a stage in credits before spending it. |
| 4 | generate_script | Stage 1: LLM screenplay from a brief. Synchronous, ~30–60s. Costs the standard text-generation rate (refused before the model if unaffordable). Pass idempotencyKey — a timed-out-then-retried call with the same key replays the original screenplay instead of paying for a second generation. |
| 5 | rewrite_scenes | Stage 1: targeted LLM rewrite of specific scenes. Synchronous, ~30–60s. Same cost + idempotencyKey contract as generate_script. |
| 6 | approve_script | REVIEW → APPROVED, unlocks stage 2. |
| 7 | set_character_face_flag | Mark a character as a real human face before generating references (face-trust routing). |
| 8 | generate_references | Stage 2: character sheets + location plates (+ visual bible if missing). Async. |
| 9 | approve_references | Approve entities ("all_pending" or explicit ids), then the stage. |
| 10 | generate_storyboards | Stage 3: storyboard page images (+ panel plan if missing). Async. |
| 11 | approve_storyboards | Approve pages, then the stage — triggers materialization into video scenes. On a contact-sheet project, first the per-scene decisions (keep/drop/redo) + confirmedCost — that call spends, and takes idempotencyKey. |
| 12 | generate_videos | Stage 4: generate a video clip per scene. Async, 720p only. |
| 13 | regenerate_scene | Stage 4: regenerate ONE scene (after a failure or content-safety rejection). Async. |
| 14 | approve_videos | Approve stage 4 — unlocks assembly. Requires every live scene COMPLETED. |
| 15 | render_film | Stage 5: assemble + render. Idempotent settle-first — never silently re-renders. Optional mix (where the film mix is switched on): voiceovers for the whole film or a numbered scene, a music bed, captions of the voiceover's words, and scenes that play their matched sound. The picture's own sound is ducked under a voiceover, never muted. A voiceover that runs past its scene or the film, or two that overlap, is refused before anything runs; the render price is unchanged. |
| 16 | get_film | Assembly readout: final videoUrl + duration, and captionsSrt (the burned-in captions as an SRT file) when the film has captions. Free. |
| 17 | list_projects | Connection sanity check + project discovery — your cinema projects with per-stage statuses. Use get_project_status for detail. Free. |
| 18 | make_film | Autopilot: one call, full pipeline (script → references → storyboards → videos → assembly), unattended by default. See §3 above. Pass idempotencyKey: a timed-out-then-retried call with the same key replays the ORIGINAL {runId, projectId, webhookSecret?} and never starts (or budgets) a second run. |
| 19 | get_run | Autopilot's polling workhorse — status, current stage, spend, events, nextActions for a run made with make_film. Free. An unknown or foreign runId returns {success:false, error:"CINEMA_NOT_FOUND: unknown runId, or not yours"}. |
| 20 | cancel_run | Stop an active autopilot run; stops future spend only. Unknown/foreign runId → the same CINEMA_NOT_FOUND refusal as get_run. |
| 21 | save_character_to_cast | Freeze an APPROVED character (portrait + identity) into your persistent cast library for episodic reuse. Returns {castId}. |
| 22 | list_cast | List your cast members (ids, names, roles, usage counts) — feed castIds into make_film. Free; no image URLs. |
| 23 | delete_cast_member | Permanently delete a cast member (refused while an active run references it). |
| 24 | refresh_cast_portrait | Re-mint a cast member's expired trusted portrait (~24h) — same face, fresh URL, one portrait charge. |
| 25 | refresh_character_portrait | The non-cast twin of refresh_cast_portrait — re-mint a project character's expired trusted portrait (quote from get_project_status → refs.characters[]). |
| 26 | set_shot_face_trust_override | Set/clear a scene's face-trust override (auto/require-trusted/exclude-untrusted) — the per-scene answer to real-face moderation on animated projects. Free. |
| 27 | estimate_generation_cost | Asset pipeline: pre-spend quote for image/video generation (kind:"image" or "video"). Free — echo total as confirmedCost. See §4. |
| 28 | generate_image | Text-to-image reference asset (gpt-image-2 default; a Seedream 5.0 model — seedream-5-0-lite (4 cr) or seedream-5-0-pro (9 cr, 1K/1.5K/2K, one image per call) — REQUIRED for real-person faces). 1–4 images, billed per image. Returns {imageId, images:[…], creditsCharged, reservedCredits}. Synchronous by default and can run >60s — prefer async:true + idempotencyKey and poll get_tool_call every 10–15s; quality:"medium" is the speed lever (see §4). |
| 29 | edit_image | Image-to-image from your own library image ids — iterate on a ref instead of re-rolling. Same cost contract, same return shape, same async:true / >60s timeout guidance as generate_image (see §4). |
| 30 | upload_image | Import an image into the library. Free. Pass exactly one of imageUrl (public http(s) URL, ≤50MB) or imageBase64 (raw base64 / data: URI of a local file, ≤5MB decoded — use it for files that aren't publicly hosted; larger files: host + use imageUrl). Chat attachments are NOT importable: an image the user attached in claude.ai/ChatGPT never reaches you as bytes or a public URL — tell them to open https://www.trvrse.com/workspace/drop, drop the image, and paste the image id back here, or to paste a public link; never pass the attachment's in-app handle. |
| 31 | list_images | Newest-first library read — {success:true, images:[…], nextCursor?} with per-image seedanceReady face-trust flag. limit ≤50 (default 20), page with cursor = the previous nextCursor, filter with status. Free. |
| 32 | generate_video | Seedance 2.5 video from text + keyframes/reference images. Async — returns {jobId}; poll get_video_job. |
| 33 | get_video_job | Poll a video job: COMPLETED → videoUrl, FAILED → errorMessage + refunded:true, unknown job → ASSET_JOB_NOT_FOUND. Free. |
| 34 | estimate_video_plan_cost | Video-plan pipeline: pre-spend quote for a full plan manifest (brief/model/duration/resolution/aspectRatio/generateAudio/prompt/refs). Free — echo total as confirmedCost. See §5. |
| 35 | create_video_plan | Propose a full video plan (brief + Seedance 2.x model + reference manifest) for human/agent approval. Reserves the grand total; returns {planId, approvalUrl, expiresAt, reservedCredits} — approvalUrl is shown exactly once. |
| 36 | get_video_plan | Poll a plan's assembly/approval/generation progress — ref statuses, approvalPending, and (once dispatched) the video job. Free. |
| 37 | list_video_plans | Newest-first summaries of this key's plans (id, status, brief, model, createdAt, grandTotal). Free. |
| 38 | approve_video_plan | Approve a READY_FOR_REVIEW plan — spends the reservation and dispatches the video job immediately. Agent-callable, unlike cinema's stage gates. |
| 39 | reject_video_plan | Reject a plan (or cancel it mid-assembly) — releases the reserved credits. Optional reason stored for audit. |
| 40 | make_long_film | Long-form autopilot for a multi-minute film with CONSISTENT CHARACTERS: each storyboard page renders as ONE continuous multi-shot Seedance 2.5 generation, each page continuing the previous via native video extension. Pinned to seedance-2.5-reference. Same idempotencyKey replay contract as make_film (a key is bound to the tool that first used it — reusing a make_film key here is refused IDEMPOTENCY_KEY_CONFLICT, never a replay of the wrong run). |
| 41 | get_gate_review_link | Mint a shareable, login-free review link for a stage awaiting human approval — shows exactly what that gate covers, with Approve and Reject. Call when get_run reports WAITING_GATE. |
| 42 | estimate_voiceover_cost | Voiceover pipeline: free pre-spend quote for generate_voiceover. Echo total back as confirmedCost. |
| 43 | generate_voiceover | Text-to-speech. Returns a durable {audioId, url, durationSec} audio asset. chatterbox voice cloning takes ~2 min server-side — raise your tool-call timeout above 60s, and check list_voiceovers before retrying. |
| 44 | upload_voice_sample | Register a 1–60s voice sample for chatterbox cloning (direct sampleUrl, or a two-step uploadUrl POST then storageId + uploadToken). Must be a voice you are authorised to clone. |
| 45 | list_voiceovers | Free read. kind:"voiceover" (default) lists audio you generated; kind:"sample" lists voice samples available for cloning. |
| 46 | get_credit_balance | Free read. Account credit balance (total + subscription/purchased/bonus breakdown), tier, this key's spend-cap headroom, and a topUpUrl. Check before a multi-step pipeline; lowBalance:true (or a spending result's lowBalanceWarning) means tell your human to top up BEFORE calls start failing INSUFFICIENT_CREDITS. |
| 47 | get_tool_call | Free read. Look up ANY prior call by its idempotencyKey — status + the original success payload verbatim, with no arg matching and no second bill. THE timeout-recovery tool: use it before ever re-firing a spending call. |
| 48 | list_runs | Free read. This key's autopilot runs newest-first (runId, status, stage, spend). The recovery route for a lost make_film runId, and the way to find which run to cancel_run when AUTOPILOT_RUN_LIMIT blocks. |
| 49 | list_videos | Free read. Newest-first finished videos (videoId, url, prompt, model, duration). Recovery route for a generate_video response lost without an idempotencyKey. |
| 50 | cancel_video_job | Cancel a QUEUED/PROCESSING video job — marks it FAILED and refunds its credits. A delivered or already-failed job refuses (JOB_ALREADY_DELIVERED / JOB_ALREADY_TERMINAL). |
| 51 | generate_storyboard | Render a WHOLE storyboard — 2-9 numbered shot sketches with caption strips — as ONE image instead of one paid render per panel (≈ 1/N the cost). Returns panels:[{index, bbox}], the sheet's real grid geometry. Use it for direction-picking, then promote_storyboard_panel for the panels you keep. Same cost/idempotencyKey/async:true contract as generate_image. |
| 52 | promote_storyboard_panel | Take one panel of a sheet into its own image. mode:"crop" (default) cuts it out server-side and costs NOTHING; mode:"rerender" re-draws it at the aspect you ask for (9:16 for a vertical shot) as one standard image-to-image edit. aspect/prompt/model are refused on a crop — a crop returns exactly the pixels on the sheet. |
| 53 | estimate_music_cost | Free pre-spend quote for generate_music. Echo total back as confirmedCost. elevenlabs bills 60 credits per started minute (3–600 s); lyria-2 is a flat 10 credits for exactly 30 s. |
| 54 | generate_music | Text-to-music background bed. Returns a durable {audioId, url, durationSec} asset (re-hosted, non-expiring); list with list_voiceovers kind:"music". Describe style/mood/tempo/instruments — never artists, songs or franchises (the provider filter refuses). Synchronous, ~20–60 s; raise your tool-call timeout above 90 s and always pass idempotencyKey. |
| 55 | upload_video | Bring your OWN clip into the library (no credits). Call with no arguments for {uploadUrl, uploadToken}, POST the bytes, call again with storageId + uploadToken. Plain MP4/MOV, 3–10 s, ≤200 MB; duration is read from the file and is what a later kling-o1-v2v-edit render is billed on. Returns {videoId, url, durationSec} — use it as generate_video's sourceVideoId. |
| 56 | generate_scene_sound | Film mix: sound that matches the picture for every shot in one scene, so render_film's mix.sceneSounds can play it instead of the clips' own audio. Quote first — confirmedCost: 0 answers COST_CHANGED with the total. 3 credits per shot that lacks matched sound for its current picture, charged only after that shot's sound is delivered; shots over 30 s are refused before anything runs. Matched sound REPLACES a shot's own audio; a regenerated shot plays its own audio again until this is re-run. Only where the deployment has switched the film mix on. |
| 57 | set_cast_voice | Voice lock: bind one of your voice samples (upload_voice_sample) to a cast member, or pass voiceSampleId: null to clear it. Every generate_voiceover with that member's castMemberId then speaks in that voice and no other — the locked sample is used and chatterbox is implied, and a different model, sample or stock voice is refused, not ignored. Charges nothing. Only where the deployment has switched locked voices on. |
An MCP client that lists tools sees each tool's full argument schema and
description — those are authoritative for exact shapes; this catalog and
the contracts below cover what the schemas cannot say. Every tool also
carries MCP annotations: the free reads (estimate_*, list_*, get_*,
get_credit_balance, get_tool_call) are readOnlyHint:true, the four
delete/cancel tools (delete_cast_member, cancel_run, cancel_video_job,
reject_video_plan) are destructiveHint:true, and every spending or
stage-advancing tool is left at the spec's default (may be destructive) —
credits spent and stages advanced are not reversible, so a client that
prompts before those is prompting at the right moment.
Async / polling pattern
generate_references, generate_storyboards, generate_videos,
regenerate_scene, and render_film enqueue work and return
immediately. After calling one:
- Call
get_project_status(orget_filmfor render) repeatedly. - Stop polling once the relevant stage/entity leaves
GENERATING(typically 1–3 minutes). get_project_status'snextActionsfield always names the tool calls the pipeline legally admits next — trust it over guessing.
generate_script and rewrite_scenes are the exception: they call an LLM
in-request and return synchronously (~30–60s) with the full script
content already in the result — no polling needed for those two.
generate_image and edit_image are synchronous by default but accept
async:true (with an idempotencyKey): the call returns
{success:true, accepted:true, idempotencyKey, reservedCredits} at once,
and get_tool_call with that key — polled every 10–15 seconds — is where
the finished payload (or an ASYNC_JOB_FAILED refusal) appears. See §4.
Cost contract
- Call
get_cost_estimatebefore any paidgenerate_*/render_filmcall and pass its exacttotalback as that tool'sconfirmedCost. The server recomputes at execution time and refuses a mismatch with{success:false, error:"COST_CHANGED", newEstimate}— re-quote and retry with the fresh total. stage:"references"quotes are ALL-IN only once an APPROVED bible exists: characters + locations + the visual bible's pending style frames. With a bible already in place, that total is the exact ceilinggenerate_referenceswill spend. Caveat: a quote taken before any bible exists doesn't yet know it needs one — and whengenerate_referencesitself creates the bible, creating it settles the project render style (and can demote character face flags), which re-prices the entity legs. Your quote is stale at that point, so the enqueue refuses withCOST_CHANGEDhaving enqueued nothing, and the result carriesrepricedConfirmedCost— the fresh ALL-IN total. Callgenerate_referencesagain with that value; the bible persists, so the retry goes straight to the enqueue. Nothing is ever auto-spent above yourconfirmedCost.scopeIds,model,durationSeconds,forRegenare videos-only. Passing any of them forstage:"references"orstage:"storyboards"is refused, not silently ignored — the bridge would rather tell you a scoping arg doesn't apply than let you believe it scoped something it didn't.resolutionis not a parameter anywhere on this bridge. All quotes and generations are 720p; there is no way to request a different resolution through MCP.regenerate_scenequotes must setforRegen:true. Regeneration snaps durations server-side, so a quote taken withoutforRegen:truediverges from whatregenerate_scenewill actually charge — always quote with the samemodel/durationSecondsyou intend to pass to the write call.
Spend-cap behavior
- Each key can carry an optional
spendCapCredits(set at mint time or edited later in Settings → Agent access). It is a safety valve, not a ledger — the bridge checks-then-reserves against the cap atomically before spending, and reservations only ever move up (or up-then-back-down when a multi-leg call ends up doing zero billable work). There is no reconciliation against actual per-entity failures. - When a call would push
spentCreditsoverspendCapCredits, the bridge action returns (never throws){success:false, error:"BRIDGE_SPEND_CAP"}— a domain refusal, not a protocol error. Raise the cap in Settings → Agent access, or use a different key, to proceed. - Reservations happen per-leg against each leg's own server-computed
estimate (not the caller's shared
confirmedCost), so a multi-leg call (e.g. references' characters + locations + frames) can't over-reserve by reserving the same total three times.
Error contract
Two, and only two, shapes an agent needs to distinguish:
-
Domain refusal — a normal (non-error) tool result whose JSON payload is
{success:false, error, ...}. Covers stage-gate violations,COST_CHANGED(+newEstimate),BRIDGE_SPEND_CAP, materialization errors, and — notably — a mid-flight key revocation surfacing from some enqueue actions as{success:false, error:"BRIDGE_AUTH_FAILED"}(still a return, not a throw). Read theerrorfield and react; this is not a protocol failure. Ownership and not-found are domain refusals too: every cinema tool that takes aprojectId(orrunId) returns{success:false, error:"CINEMA_NOT_FOUND: unknown projectId, or not yours"}for an id that does not exist or belongs to someone else — the two are deliberately indistinguishable (so a key cannot enumerate other tenants' ids), and the reaction is the same either way: check the id (list_projects/list_runs) rather than retrying. The same holds for every other id argument, naming the one you passed:unknown scriptId, or not yours(rewrite_scenes,approve_script), and likewise forcharacterId,locationIdandpageId. Coded failures from inside the pipeline come back the same way, and there are exactly four codes:Code Means Do CINEMA_NOT_FOUNDThe id does not exist, or is not yours — indistinguishable on purpose. Re-read the id from list_projects/get_project_status/list_runs. Never retry the same id.CINEMA_INVALID_INPUTAn argument is malformed or violates a documented bound (too many scenes, a deleted page, a cost mismatch). Fix the argument. The message names the bound. CINEMA_INVALID_STATEThe pipeline is busy or an entity is not in a usable state (generating, no portrait, stage not awaiting review). Usually wait and retry, or do the one thing the message names first. CINEMA_STAGE_BLOCKEDA stage gate: you asked for stage N while stage N−1 is not APPROVED. Read get_project_status.nextActions; approve the named upstream stage, then retry. -
Auth/malformed-input failure — an
isError:truetool result. Pre-flight auth failures (malformed/unknown/revoked key, rate limit) are thrown by the Convex auth check and mapped to anMcpErrornamingBRIDGE_AUTH_FAILEDexplicitly, with a pointer back to Settings → Agent access. Missing the bearer token entirely fails the same way before any Convex call happens. Schema-level malformed input (a non-id string where an id is required, a missing required arg) surfaces asisErrortoo, with its message intact. Anything else that arrives as an opaque[Request ID: …] Server Erroris a genuine server fault, not something your call caused.
The underlying MCP SDK converts every handler throw into an isError
tool result — there is no separate JSON-RPC protocol-error channel reachable
from a tool handler. So in practice: readable non-error result = domain
refusal you can act on; isError result = something is wrong with the
call itself (bad key, bad input) and retrying the same call won't help
without fixing that.
Stage preconditions map
| Stage | Precondition to enter | Unlocked by |
|---|---|---|
| Script (stage 1) | none (project just created) | generate_script → REVIEW; approve_script → APPROVED |
| References (stage 2) | script APPROVED | generate_references → entities REVIEW; approve_references → APPROVED |
| Storyboards (stage 3) | references APPROVED | generate_storyboards → pages REVIEW; approve_storyboards → APPROVED (triggers materialization) |
| Videos (stage 4) | storyboards APPROVED and materialized (video scenes visible in get_project_status) | generate_videos / regenerate_scene → scenes COMPLETED; approve_videos → APPROVED |
| Assembly (stage 5) | videos APPROVED | render_film → poll get_film until REVIEW + videoUrl |
Calling a tool before its precondition holds is a domain refusal (readable
{success:false}), not a crash — get_project_status.nextActions is the
fastest way to confirm what's legal right now.
7. Worked end-to-end transcript
A representative agent session (abbreviated JSON; ids/values illustrative).
> create_project({ briefText: "A lighthouse keeper discovers the tide brings back what the sea took.", aspectRatio: "16:9" })
< { projectId: "proj_abc123" }
> generate_script({ projectId: "proj_abc123", logline: "A lighthouse keeper discovers the tide brings back what the sea took.", genre: "drama", tone: "melancholic, hopeful" })
< { saved: { scriptId: "scr_1" }, screenplay: { title: "The Tide Keeper", scenes: [ /* 6 scenes */ ], characters: [...], locations: [...] } }
# Agent reviews the screenplay JSON, decides scene 3 is too on-the-nose.
> rewrite_scenes({ projectId: "proj_abc123", targets: [{ sceneNumber: 3, revisionNotes: "Cut the dialogue in half; let the silence carry it." }] })
< { applied: { scriptId: "scr_1" }, scenes: [ /* only scene 3, rewritten */ ] }
> approve_script({ scriptId: "scr_1" })
< { approved: true }
> get_project_status({ projectId: "proj_abc123" })
< { stages: { script: { status: "APPROVED" }, refs: { status: "DRAFT" }, ... },
refs: { bibleApproved: false, characters: [{ id: "char_1", name: "Mara" }] },
nextActions: ["set_character_face_flag (optional)", "generate_references"] }
> set_character_face_flag({ characterId: "char_1", value: true })
< { updated: true }
> get_cost_estimate({ projectId: "proj_abc123", stage: "references" })
< { total: 40, breakdown: { characters: 10, locations: 10, styleFrames: 20 } }
> generate_references({ projectId: "proj_abc123", confirmedCost: 40 })
< { bible: { created: true, renderStyle: "photoreal", renderStyleDecision: "confirmed" },
references: { enqueued: true } }
# poll...
> get_project_status({ projectId: "proj_abc123" })
< { refs: { characters: [{ id: "char_1", status: "REVIEW" }], locations: [{ id: "loc_1", status: "REVIEW" }] } }
> approve_references({ projectId: "proj_abc123", scope: "all_pending" })
< { approved: true, stage: { approved: true } }
> get_cost_estimate({ projectId: "proj_abc123", stage: "storyboards" })
< { total: 60, breakdown: { pages: 6 } }
> generate_storyboards({ projectId: "proj_abc123", confirmedCost: 60 })
< { plan: { pages: 6, panels: 12, unresolvedNames: [] }, generation: { enqueued: true } }
# poll until pages leave REVIEW...
> approve_storyboards({ projectId: "proj_abc123", scope: "all_pending" })
< { approved: true, stage: { approved: true } }
# poll get_project_status until video scenes appear (materialization)...
> get_cost_estimate({ projectId: "proj_abc123", stage: "videos" })
< { total: 180, breakdown: { shots: 6, perShot: 30 } }
> generate_videos({ projectId: "proj_abc123", confirmedCost: 180 })
< { enqueued: true }
# poll...
> get_project_status({ projectId: "proj_abc123" })
< { video: { scenes: [
{ pageId: "pg_4", status: "FAILED", suggestedAction: "REPHRASE_PROMPT" },
/* the other 5 COMPLETED */
] } }
> get_cost_estimate({ projectId: "proj_abc123", stage: "videos", scopeIds: ["pg_4"], forRegen: true })
< { total: 30 }
> regenerate_scene({ projectId: "proj_abc123", pageId: "pg_4", confirmedCost: 30, tweakPrompt: "Remove the storm — calm sea, wide shot." })
< { enqueued: true }
# poll until pg_4 is COMPLETED...
> approve_videos({ projectId: "proj_abc123" })
< { approvedCount: 6 }
> render_film({ projectId: "proj_abc123" })
< { status: "GENERATING" }
# poll get_film...
> get_film({ projectId: "proj_abc123" })
< { status: "REVIEW", videoUrl: "https://.../the-tide-keeper.mp4", durationSeconds: 96 }
8. Gotchas
- Approving a stage exits it. After a stage-level approve you cannot regenerate that stage's entities unless an upstream edit marks the stage STALE — in that case review and re-approve.
- Keys are shown once. If a key is lost there is no recovery: a human must revoke it and mint a new one at Settings → Agent access.
regenerate_scenere-charges the scene's cost on every attempt — quote withforRegen: truefirst.- Resolution is fixed at 720p through this bridge; no parameter changes it.
- Rate limit: 60 tool calls/min per key (burst 10). Poll at 15–30
second intervals. A rate-limited call surfaces as
BRIDGE_AUTH_FAILED— back off and retry the same key; do not treat it as revocation. make_film'sbudgetCreditscannot be raised later. A run pausedNEEDS_ATTENTIONfor budget stays paused until the blocking stage gets cheaper (fixed in the app) or youcancel_runit — there is no "increase the budget" call.- Gate approvals happen in the app, not the bridge.
gatespauses a runWAITING_GATE; there is no MCP tool to approve a gate — a human approves the stage in the trvrse app and the run resumes on its own within a couple of minutes.