Studio Productions API
/v1/studio/productions is the platform’s own reader and writer of a studio
production: a Nodaro workflow whose settings.studio holds an ordered list of
shots, each with a framed still, an optional animated clip, and the plan, looks,
cast bindings, frames and voice that made them.
Before these routes existed, the only code that could read that document ran in the studio app’s browser tab. Now a script, an agent and the app all read and write the same production through one contract — which is what makes “generate the still for shot 3 and keep the second candidate” something you can do from a cron job.
- SDK:
client.studio.productions.*(@nodaro/sdk). - Types: the SDK types the ENVELOPES —
version,rebased,receipts,warnings, a quote’scredits, a run’sjobIds— and leaves the production DOCUMENT as open JSON. Its field-level types ship with the studio app, the one consumer that narrows them; a second spelling in the SDK would drift from it. - Auth: a bearer token like every other
/v1route. OAuth tokens needworkflows:readto read andworkflows:writeto write. - Availability: Nodaro Cloud only. A deployment that does not serve these
routes answers
404on all of them. Feature-detect once withlist()— a deployment without the routes 404s that too, where a deployment with them answers an empty page.
Every route answers 404 — never 403 — to a caller who cannot reach the
production, so an id cannot be probed for existence.
The envelope
Every response is wrapped:
{ "data": { "production": { "…": "the production" } } }
The SDK unwraps it for you: a method that returns “the production” resolves to the production object itself, and a method that returns a report resolves to the whole report object.
Errors are the platform’s usual shape:
{ "error": { "code": "production_busy", "message": "…" } }
The read shape
Every route returns the production in the same shape. Its top level:
| Field | Meaning |
|---|---|
id, name, version, updatedAt |
Identity and the change counter every write swaps on. |
thumbnailUrl, shared, archived |
The poster frame, the share-by-link flag, the soft hide. |
film, cast, folders, storyboard, music, musicPlan, cuts |
The production’s own settings: the film look, the roles, the timeline folders, the brief, the score and the exported cuts. |
trash |
{ count, items? } — deletes are recoverable. |
pending |
{ stills, clips, music, draft } — what is in flight right now. |
shots[] |
In timeline order: still, clip, startFrame, endFrame, plan, beats, look, voice, … |
Two levels of detail:
detail=summary(default) — counts and the active urls. This is what a list or a preamble wants.detail=full— every result in each shot’s history, with the context that regenerates it.
shot_id returns a single shot, which is the cheap way to re-read after a
generation.
Result histories accumulate. Generating never replaces: a shot’s stills are every candidate it has ever had, and the same for its clips. Stills and clips are independent — deleting one never touches the other. A result is addressed by its result key: its job id when it has one, its url otherwise. Never by position.
Routes
| Method | Path | Body | Returns |
|---|---|---|---|
GET |
/v1/studio/productions/skill |
— | { skill, catalog, schema, operating, generatedFrom } |
POST |
/v1/studio/productions/validate |
{ plan } |
{ valid, errors[], warnings[], summary? } |
GET |
/v1/studio/productions |
?limit&cursor&includeArchived |
{ data[], nextCursor? } |
POST |
/v1/studio/productions |
{ name?, plan? } |
{ production, warnings?, summary? } |
GET |
/v1/studio/productions/:id |
?detail&shot_id |
{ production } |
POST |
/v1/studio/productions/:id/ops |
{ ops[], baseVersion?, strict?, dryRun?, clientRequestId? } |
{ production, version, rebased, receipts[], warnings[] } or the preview |
POST |
/v1/studio/productions/:id/reconcile |
— | { landed[], pending[], failed[], warnings[], production, version } |
POST |
/v1/studio/productions/:id/import |
{ plan, mode: "append" } |
{ production, warnings, summary } |
POST |
/v1/studio/productions/:id/describe |
{ brief, llmModel, mode?, label?, clientRequestId? } |
{ jobId, production } |
POST |
/v1/studio/productions/:id/generate |
{ kind, shotId, count?, mode?, overrides?, dryRun?, clientRequestId? } |
{ jobIds[], lane?, deduped?, production? } or the quote |
POST |
/v1/studio/productions/:id/frame |
{ shotId, mode?, timestamp?, target?, clientRequestId? } |
{ production, url? } |
POST |
/v1/studio/productions/:id/voice |
{ shotId, text, voiceId?, voiceType?, ttsProvider?, delivery?, clientRequestId? } |
{ production } |
POST |
/v1/studio/productions/:id/revoice |
{ shotId, plan, clientRequestId? } |
{ jobId, production } |
POST |
/v1/studio/productions/:id/music |
{ prompt, duration?, instrumental?, vocalGender?, model?, clientRequestId? } |
{ jobId, production } |
GET |
/v1/studio/productions/:id/export-plan |
?upscale |
{ canExport, steps[], resultStepId, estimate, unpriced[] } |
POST |
/v1/studio/productions/:id/share |
{ shared } |
{ production } |
POST |
/v1/studio/productions/:id/unshare |
— | { production } |
POST |
/v1/studio/productions/:id/clone |
{ name? } |
{ production } |
There is deliberately no delete route. The soft hide the dashboard honours is an operation, and it is reversible.
Writing: operations, not patches
Every change to a production is an operation: a named, self-describing edit
— rename this shot, move it, bind this cast role, keep that candidate — that the
route validates and applies. The vocabulary is served, not printed here:
GET …/skill returns it as the operating part, rendered from the version that
is live, so what you read is what the deployment you are talking to accepts.
An operation addresses by stable key: a shot by its id, a folder or cut by its id, a cast row by its role slug, a result by its result key. Not by position — because the editor, a script and an agent can hold the same production open at once, and an index is stale the moment any of them inserts something.
const { production, version, rebased, receipts } =
await client.studio.productions.ops(productionId, {
// The operations, in order, in the vocabulary `GET …/skill` serves.
ops,
baseVersion: 7,
})
Rules that hold for every batch:
- Atomic. A batch applies completely or not at all. One invalid operation refuses the whole batch and writes nothing. Up to 100 operations per batch.
- Rebasing by default.
baseVersionis informational: a batch composed against an older version is still applied to the newest document, and the response reportsrebased: true. Passstrict: trueto refuse instead and get a409conflict carrying the current state. - Ids you will address later are yours to mint. An operation that creates something — a shot, a folder, a cut, a copy — takes the id you give it rather than minting one. Your optimistic local copy and the server then agree on every id.
- Deletes are recoverable. A removal moves what it removed to the production’s bin, and there is an operation that restores it. Only an explicit purge destroys anything.
- Sharing is not an operation. Who may see the work is changed through the
shareroute by the owner, never as a side effect of a batch that was editing something else. A batch that tries to set it is refused. - Adopt the response.
productionis canonical after the batch — replace your copy with it rather than merging into it — andversionis the nextbaseVersion.
receipts is one line per operation — { op, summary, ids? }, past tense,
naming the target the way a person would — which is what you show someone who
wants to know what an agent just did. warnings is a flat list of things worth
saying that are not failures: an operation that changed nothing, a rename that
rewrote four prompts.
Previewing a batch
dryRun: true asks what a batch WOULD do. The route runs the write’s own
attempt — the same operations, against the stored document, in the same
context, with the same refusals in the same order — and stops before the swap.
The preview is not a description of the write; it IS the write, up to the swap,
which is why the two cannot say different things. It is what you put in front
of a person before an assistant’s edits land.
{
"dryRun": true,
"version": 7,
"receipts": [
{
"op": "remove_shot",
"summary": "Deleted Shot 3 (in the bin).",
"ids": ["6f1c9b2e-…"],
"class": "D",
"restorable": true
}
],
"warnings": []
}
There is no production and no rebased: no document was produced, and nothing
was rebased onto anything. version is the version the preview was computed
AGAINST — still the production’s current one — so it is the baseVersion for the
batch you send next.
A previewed receipt is the apply’s own receipt plus the two things the person deciding needs:
| Field | Meaning |
|---|---|
op, summary, ids, impact |
The receipt the apply reports, built by the same projection — so summary reads in the same past tense there and here, and impact carries the dependency scope ({ keyframeIds, shotIds }) where the operation reports one. |
class |
The operation’s confirmation class, read from the vocabulary’s own table: S safe, D deletes, P changes who can reach the work, $ spends. |
restorable |
Present, and true, only where the operation put something in the bin that can be restored. Absent everywhere else — including a delete that destroys, because there would be no entry to fetch back. |
The refusals the batch itself earns, the preview earns too: strict: true still
answers 409, a bad operation is still op_invalid / op_target_missing with
its opIndex, and a body that names an audience key is still refused. The one
refusal a preview cannot reach is contention: the apply retries the swap and
gives up busy where a writer keeps beating it, and a preview never swaps. What
the preview never does is write — no document is stored, no version is bumped, no
timestamp moves. That is narrower than “touches nothing”, deliberately: the
batch is resolved through the write’s own context, so a batch that reads
planned-frame inputs retains those inputs exactly as the write it previews
would, one moment earlier.
Prove the flag before you send the batch. This route parses its body in
strip mode, so a deployment that predates the preview DROPS dryRun and applies
the batch — which means a caller that learns by sending has already written the
thing it meant to ask about, and no answer read afterwards undoes that.
So ask with an empty batch first. An empty batch changes nothing on every deployment ever built, which is what makes it safe to send blind:
POST /v1/studio/productions/:id/ops
{ "ops": [], "dryRun": true }
Key on the MARKER, not on the shape:
dryRun: truein the answer — with the currentversionand no receipts — is your “yes”. Only then send the real batch.- Anything else is your “no”, whether it is the ordinary apply shape (the
production, its
version,rebased: false) or a refusal the deployment makes in its own words. Tell the user you cannot preview here, and do not send the batch.
Read the marker on the real batch’s answer too. The ping proved the deployment
that answered the ping, and a fleet mid-rollout can serve the next request from
a different one. An answer that carries a production instead of the marker is
a batch that was APPLIED: adopt its production and version the way an
ordinary apply’s caller does, and do not send it again.
Send the ping as its own bare body — no operations, no clientRequestId,
nothing of the batch it is asking about. Its only job is to make the deployment
say whether it previews.
Generating, and landing the results
Generation is run-then-poll. A still or clip request submits the job(s), records a pending marker on the production, and returns immediately — nothing here blocks for minutes.
One route runs both kinds — POST …/:id/generate with kind: "still" | "clip"
and the shot in the body. The SDK’s generateStill / generateClip are the
same call with kind filled in.
const run = await client.studio.productions.generateStill(id, "shot-2", {
count: 2,
clientRequestId: crypto.randomUUID(),
})
// …later, once the jobs finish:
const { landed, pending } = await client.studio.productions.reconcile(id)
- The request is assembled server-side from the shot’s own plan, looks, bound
references and direction, so a generate from a script and a press of the
button in the app produce the same image.
overrideschanges one run without changing the shot. dryRun: trueprices the run and writes nothing. The reply is the QUOTE —{ dryRun: true, provider, count, credits, lane? }— rather than a started run;creditsisnullwhen the model has no price, which is “unknown”, not “free”. In the SDK, narrow it withisStudioGenerateEstimate(result).clientRequestIdmakes a retry safe: the same token answers with the jobs the first call started anddeduped: true, having submitted nothing and charged nothing. Never retry a spend without it. It is 8–128 characters ofA-Za-z0-9_.:-, minted by you — never derived from the request.reconcileturns finished jobs into results and clears the markers of jobs that failed.GET …/:idnever writes, so callreconcilewhen you are waiting on something.- For a clip, the lane (
generate-videoortext-to-video) is chosen from the shot’s inputs — a start frame, references, a prompt — and reported back to you aslane. You never pick it.modeonly says which SET of inputs to direct from ("start"or"references"); omit it and the inputs decide.
frame and voice are the short ones: they wait for their job (seconds) and
return the changed production. revoice and music are the long ones: they
answer jobId and land through their markers.
Story → production
const { production } = await client.studio.productions.create({ name: "The Lighthouse" })
const { jobId } = await client.studio.productions.describe(production.id, {
brief: "A keeper, a storm, and a light that will not start.",
llmModel: "gpt-5-mini",
mode: "replace",
})
// poll jobId with client.jobs.getStatus, then:
await client.studio.productions.reconcile(production.id)
describe writes the plan from a brief. A plan you already have skips the run entirely: validate it for free, then land
it with create({ plan }) or add its scenes to an existing production with
importPlan. GET …/skill serves the authoring guide, the full catalog and the
plan’s JSON Schema, rendered from the version that is live — so they describe
the platform you are actually talking to.
Exporting
GET …/export-plan returns the ordered steps that assemble the film — the
per-shot audio merges, the join, the optional 4K finish:
{
"canExport": true,
"steps": [
{
"id": "voice-shot-2",
"node": "merge-video-audio",
"label": "Voice over shot 2",
"creditModel": "merge-video-audio",
"credits": 2,
"params": { "videoUrl": "https://…/shot-2.mp4", "audioUrl": "https://…/vo.mp3" }
},
{
"id": "combine",
"node": "combine-videos",
"label": "Join 3 shots",
"creditModel": "combine-videos",
"credits": 4,
"params": {
"videoUrls": [{ "fromStep": "voice-shot-2" }, "https://…/shot-3.mp4"],
"transition": "cut",
"audioMode": "keep"
}
}
],
"resultStepId": "combine",
"estimate": 6,
"unpriced": []
}
Run the steps in order with the ordinary generation verbs and record the
finished file back on the production as a cut, with the operation that adds
one. A videoUrl of { "fromStep": "…" } is the output of an earlier step —
substitute the url that step produced. Step
ids are derived from the production, not minted, so re-planning the same film
gives the same references.
canExport is false when there is nothing to assemble (fewer than two clips).
estimate is null when ANY step is unpriced — the unpriced models are named
in unpriced, and a partial sum presented as the total would understate the
spend. Assembly is step-wise on purpose: a single call that chained several
minutes-long jobs would outlive any reasonable request timeout.
Errors
| Status | code |
What to do |
|---|---|---|
400 |
validation_error |
The body or the plan is wrong; path names the field. |
400 |
op_invalid, op_target_missing |
On …/ops: one operation was wrong; opIndex names it (zero-based). Nothing was written — fix that operation and send the batch again. |
402 |
insufficient_credits |
Not enough credits for the batch. |
403 |
forbidden |
Only the owner or a workspace admin can change who a production is shared with. |
404 |
not_found |
No such production for this caller. |
404 |
op_target_missing |
On the generating and media routes: the shot, result or role the call named is not in the production. No opIndex — there is no batch to index into. |
409 |
workflow_conflict |
strict: true and the production had moved on. Re-read and re-apply. |
409 |
production_busy |
The row kept changing under the write. Re-read and retry. |
413 |
storage_exceeded |
The account’s storage limit. |
In the SDK these arrive as typed errors: StudioOpError (with opIndex),
WorkflowConflictError (both 409s — its code tells you which),
InsufficientCreditsError, StorageExceededError, NotFoundError. The
mapping is by SHAPE, not by code: any 4xx whose body carries a numeric
opIndex is a StudioOpError. So the same op_target_missing reaches you as
a StudioOpError off …/ops and as a NotFoundError off a generating or
media route, because only the batch answer indexes.
import { StudioOpError, WorkflowConflictError } from "@nodaro/sdk"
try {
await client.studio.productions.ops(id, { ops, baseVersion, strict: true })
} catch (err) {
if (err instanceof StudioOpError) {
console.error(`operation ${err.opIndex} was refused: ${err.message}`)
} else if (err instanceof WorkflowConflictError) {
const fresh = await client.studio.productions.get(id, { detail: "full" })
// re-compose the batch against `fresh`, then retry
}
}
SDK reference
| Method | Route |
|---|---|
skill() |
GET …/skill |
validatePlan(plan) |
POST …/validate |
list({ limit?, cursor?, includeArchived? }) |
GET … |
create({ name?, plan? }) |
POST … |
get(id, { detail?, shotId? }) |
GET …/:id |
ops(id, { ops, baseVersion?, strict?, dryRun?, clientRequestId? }) |
POST …/:id/ops |
reconcile(id) |
POST …/:id/reconcile |
importPlan(id, plan, { mode? }) |
POST …/:id/import |
describe(id, { brief, llmModel, mode?, label?, clientRequestId? }) |
POST …/:id/describe |
generate(id, { kind, shotId, … }) |
POST …/:id/generate |
generateStill(id, shotId, { count?, overrides?, dryRun?, clientRequestId? }) |
POST …/:id/generate (kind: "still") |
generateClip(id, shotId, { mode?, overrides?, dryRun?, clientRequestId? }) |
POST …/:id/generate (kind: "clip") |
frame(id, { shotId, mode?, timestamp?, target? }) |
POST …/:id/frame |
voice(id, { shotId, text, voiceId?, … }) |
POST …/:id/voice |
revoice(id, { shotId, plan }) |
POST …/:id/revoice |
music(id, { prompt, duration?, … }) |
POST …/:id/music |
exportPlan(id, { upscale? }) |
GET …/:id/export-plan |
share(id) |
POST …/:id/share |
unshare(id) |
POST …/:id/unshare |
clone(id, { name? }) |
POST …/:id/clone |
See also: API Integration, SDK Reference.
Retake one linked clip
Check GET /v1/studio/productions/capabilities for
operations.retakeLinkedClips. To price a native linked take, post to
/:id/generate with kind: "clip", shotId, retakeResultKey and
dryRun: true. Submit the same take with the quote’s inputHash in
expectedInputHash and a fresh clientRequestId. Do not include mode,
overrides, or count.
The server verifies the original job request and retained endpoint images.
Current frame acceptance and scene plans do not replace these inputs. A changed
quote returns 409 sequence_quote_changed before submission. Missing original
metadata or retained images refuses the retake; this can include older or copied
takes. Each accepted submission adds one take and keeps existing history.