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 20 tools: 17 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. This guide is the complete playbook: connecting, the workflow
loop, Autopilot, 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 trvrse-cinema https://www.trvrse.com/api/mcp/mcp \
--header "Authorization: Bearer trv_YOUR_KEY_HERE"
Generic MCP client (JSON config)
Most MCP clients (Claude Desktop, other agent runtimes) accept a config block like:
{
"mcpServers": {
"trvrse-cinema": {
"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. There is no OAuth/dynamic-registration flow; the
bearer token is the credential.
Sanity-check the connection
Once connected, ask the agent to call list_projects (or run
get_project_status against an existing project id). A working connection
returns real JSON; a bad key 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.
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.
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.
4. The tool catalog
20 tools, 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. 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. Free. |
| 5 | rewrite_scenes | Stage 1: targeted LLM rewrite of specific scenes. Synchronous, ~30–60s. Free. |
| 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. |
| 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. |
| 16 | get_film | Assembly readout: final videoUrl + duration. 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. |
| 19 | get_run | Autopilot's polling workhorse — status, current stage, spend, events, nextActions for a run made with make_film. Free. |
| 20 | cancel_run | Stop an active autopilot run; stops future spend only. |
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.
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.
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: characters + locations + the visual bible's pending style frames, whenever a bible is still owed. This is the exact ceilinggenerate_referenceswill spend. Caveat: a quote taken before any bible exists doesn't yet know it needs one — ifgenerate_referencesitself creates the bible (no bible was approved yet), the style-frame leg is priced and spent server-side beyond yourconfirmedCost. Quote after a bible exists if you need an exact before-the-fact total.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. - 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. Other thrown errors (malformed input, ownership violations) surface asisErrortoo, with their coded message intact.
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.
5. 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 }
6. 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.