Nodaro Docs
DocumentationNode ReferenceModelsAI Agents (MCP)DevelopersSelf-hostingResearch
REST API

Studio productions

Read and edit Studio productions over REST: apply atomic operations, generate stills and clips, land finished jobs, plan the export and share or copy a film.

Available on Nodaro Cloud

The Studio productions API reads and writes the films you build in Nodaro Studio. A production is a Nodaro workflow whose settings hold an ordered list of shots. Each shot has a framed still, an optional animated clip, and the plan, looks, cast and voice that made them. A script, an AI assistant and the Studio editor all work on the same production through these routes, at the same time.

Studio productions run on Nodaro Cloud only. A deployment that does not serve them answers 404 on every route, so feature-detect once with the list route. On a self-hosted install, build the film as a workflow with nodes such as Scene, Generate Video and Combine Videos. The routes take a bearer token; OAuth app tokens need workflows:read to read and workflows:write to write. See Authentication.

Every route answers 404, never 403, when you cannot reach a production, so an id cannot be probed.

The editor's words and the document's keys

The editor and the document name the same things differently. Use the document's keys in code and the editor's words with people.

In the editorIn the document
filmthe production
scene, for example Scene 3a shots[] entry, addressed by its shotId
a scene's frame, and its takesthe shot's still results
a scene's motion, and its takesthe shot's clip results
the shots inside a motionthe shot's beats[]

Endpoints

MethodPathWhat it does
GET/v1/studio/productions/skillThe authoring guide, the catalog, the plan's JSON Schema and the operation vocabulary. Free.
GET/v1/studio/productions/capabilitiesWhich optional operations this deployment supports.
POST/v1/studio/productions/validateValidate a plan. Free, and it stores nothing.
GET/v1/studio/productionsList your productions, newest first.
POST/v1/studio/productionsCreate a production, optionally from a plan.
GET/v1/studio/productions/:idRead a production. It never writes.
POST/v1/studio/productions/:id/opsApply a batch of operations, or preview it.
POST/v1/studio/productions/:id/reconcileTurn finished jobs into results.
POST/v1/studio/productions/:id/importAdd a plan's shots to the production.
POST/v1/studio/productions/:id/describeWrite the plan from a brief.
POST/v1/studio/productions/:id/generateGenerate a still or a clip for a shot, or quote it.
POST/v1/studio/productions/:id/frameTake a frame from a clip.
POST/v1/studio/productions/:id/voiceSpeak a line over a shot.
POST/v1/studio/productions/:id/revoiceChange the voices in a shot's clip.
POST/v1/studio/productions/:id/musicGenerate the soundtrack.
GET/v1/studio/productions/:id/export-planThe priced steps that assemble the film.
POST/v1/studio/productions/:id/shareTurn link sharing on or off.
POST/v1/studio/productions/:id/unshareTurn link sharing off.
POST/v1/studio/productions/:id/cloneCopy a production.

There is no delete route. Archiving is an operation, and it can be undone.

Read a production

Every response is wrapped as { "data": { "production": { … } } }. The SDK unwraps it: a method that returns a production resolves to the production itself. The production's top level holds:

FieldWhat it holds
id, name, version, updatedAtThe identity, and the counter every write checks.
thumbnailUrl, shared, archivedThe poster frame, whether link sharing is on, and whether it is archived.
film, cast, folders, storyboard, music, musicPlan, cutsThe film look, the roles, the timeline folders, the brief, the score and the exported cuts.
trash{ count, items? }: what was removed and can be restored.
pending{ stills, clips, music, draft }: what is generating right now.
shots[]The shots in timeline order, each with still, clip, startFrame, endFrame, plan, beats, look, voice and more.

GET /v1/studio/productions/:id takes detail=summary (the default: counts and the active URLs) or detail=full (every result in each shot's history, with the context that made it). shot_id returns one shot, the cheap way to read again after a generation. GET /v1/studio/productions takes limit, cursor and includeArchived.

Results accumulate. Generating never replaces: a shot's stills are every candidate it ever had, and the same for its clips. Deleting a still never touches the clips. A result is addressed by its result key, which is its job id when it has one and its URL otherwise, never by its position.

curl "https://app.nodaro.ai/v1/studio/productions/5d8f2b4a-7c1e-4a9d-8b3f-2e6c9a1d4f7b?detail=summary" \
  -H "Authorization: Bearer $NODARO_API_KEY"
import { createClient, StaticTokenAuth } from '@nodaro/sdk'

const client = createClient({
  baseUrl: 'https://app.nodaro.ai',
  auth: new StaticTokenAuth(process.env.NODARO_API_KEY!),
})

const production = await client.studio.productions.get(id, { detail: 'full' })
const shot = await client.studio.productions.get(id, { shotId: 'shot-2' })

The SDK types the envelopes (version, rebased, receipts, warnings, a quote's credits, a run's jobIds) and leaves the production document as open JSON.

Edit with operations

Every change to a production is an operation: a named edit, such as renaming a shot, moving it, binding a cast role or keeping a candidate. POST /v1/studio/productions/:id/ops validates and applies a batch of them. The vocabulary is served, not printed here: read the operating part of GET …/skill, which always matches the deployment you are talking to.

# batch.json: { "ops": [ ...operations from GET /v1/studio/productions/skill... ], "baseVersion": 7 }
curl -X POST https://app.nodaro.ai/v1/studio/productions/5d8f2b4a-7c1e-4a9d-8b3f-2e6c9a1d4f7b/ops \
  -H "Authorization: Bearer $NODARO_API_KEY" \
  -H "Content-Type: application/json" \
  -d @batch.json
const { production, version, rebased, receipts } =
  await client.studio.productions.ops(id, { ops, baseVersion: 7 })

The response is { production, version, rebased, receipts, warnings }. Six rules hold for every batch:

  1. All or nothing. One invalid operation refuses the whole batch and writes nothing. The error names it by its zero-based opIndex. A batch holds up to 100 operations.
  2. Rebased by default. baseVersion is informational: a batch written against an older version is applied to the newest document, and the response says rebased: true. Send strict: true to get 409 workflow_conflict instead.
  3. Address 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. Never by position, because the editor and a script may edit the same production at once.
  4. Mint your own ids. An operation that creates a shot, a folder, a cut or a copy takes the id you give it. Your local copy and the server then agree on every id.
  5. Deletes go to the bin. A removal moves what it removed to the production's trash, and an operation restores it. Only an explicit purge destroys anything.
  6. Adopt the response. Replace your copy with production rather than merging, and send version as the next baseVersion.

Sharing is not an operation; a batch that tries to change it is refused. receipts has one past-tense line per operation, { op, summary, ids?, impact? }: show it to anyone who wants to know what an assistant did. warnings lists things worth saying that are not failures, such as an operation that changed nothing.

Preview a batch before it lands

dryRun: true asks what a batch would do. The server runs the write itself, in the same context and with the same refusals, and stops before saving. The reply is { dryRun: true, version, receipts, warnings }, with no production. Each receipt adds class (S safe, D deletes, P changes who can reach the work, $ spends) and, where a delete can be undone, restorable: true.

A deployment that predates previews ignores dryRun and applies the batch. So prove the flag first, with an empty batch, which changes nothing on any deployment:

{ "ops": [], "dryRun": true }

Only an answer that carries dryRun: true is a yes. Anything else is a no: tell the person you cannot preview here, and do not send the batch. Check the marker on the real preview's answer too, because a deployment being updated can serve the next request from another server. An answer with a production instead of the marker means the batch was applied: adopt it and do not send it again. The SDK does both checks for you and throws StudioPreviewUnavailable or StudioPreviewAppliedError.

const preview = await client.studio.productions.ops(id, { ops, baseVersion, dryRun: true })
for (const r of preview.receipts) console.log(r.class, r.summary, r.restorable ?? false)

Generate stills and clips

Generation is run-then-poll. POST /v1/studio/productions/:id/generate with kind: "still" or kind: "clip" and a shotId submits the jobs, records them as pending on the production, and returns at once with { jobIds, lane?, deduped?, production? }.

  • Built from the shot. The server assembles the request from the shot's plan, looks, cast and direction. A call from a script and a click in the editor therefore produce the same image. overrides changes one run without changing the shot.
  • Quote first. dryRun: true prices the run and writes nothing. The reply is { dryRun: true, provider, count, credits, lane? }. credits: null means the model has no price, which is unknown, not free.
  • Retry safely. clientRequestId, 8 to 128 characters from A-Za-z0-9_.:- that you mint, makes a retry safe: the same id returns the first call's jobs with deduped: true and charges nothing. Never retry a paid call without it.
  • Land the results. POST /v1/studio/productions/:id/reconcile turns finished jobs into results and clears failed ones. It returns { landed, pending, failed, warnings, production, version }. A GET never writes, so reconcile when you are waiting on something.
  • The clip lane is chosen for you. For a clip, the model route (generate-video or text-to-video) follows from the shot's inputs and comes back as lane. mode only says which set of inputs to direct from: "start" or "references".
curl -X POST https://app.nodaro.ai/v1/studio/productions/5d8f2b4a-7c1e-4a9d-8b3f-2e6c9a1d4f7b/generate \
  -H "Authorization: Bearer $NODARO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "still", "shotId": "shot-2", "count": 2, "clientRequestId": "still-shot-2-take-1" }'

curl -X POST https://app.nodaro.ai/v1/studio/productions/5d8f2b4a-7c1e-4a9d-8b3f-2e6c9a1d4f7b/reconcile \
  -H "Authorization: Bearer $NODARO_API_KEY"
import { isStudioGenerateEstimate } from '@nodaro/sdk'

const quote = await client.studio.productions.generateStill(id, 'shot-2', { count: 2, dryRun: true })
if (isStudioGenerateEstimate(quote)) console.log(quote.credits)

const run = await client.studio.productions.generateStill(id, 'shot-2', {
  count: 2,
  clientRequestId: crypto.randomUUID(),
})
// ...once the jobs finish:
const { landed, pending } = await client.studio.productions.reconcile(id)

The other media routes work on one shot:

RouteBodyWhat it does
frame{ shotId, mode?, timestamp?, target?, clientRequestId? }Takes a frame from a clip. Waits a few seconds and returns { production, url? }.
voice{ shotId, text, voiceId?, voiceType?, ttsProvider?, delivery?, clientRequestId? }Speaks a line over the shot. Waits and returns { production }.
revoice{ shotId, plan, clientRequestId? }Changes the voices in the shot's clip. Returns { jobId, production }; lands through reconcile.
music{ prompt, duration?, instrumental?, vocalGender?, model?, clientRequestId? }Generates the soundtrack. Returns { jobId, production }; lands through reconcile.

clientRequestId is accepted on every paid route here, frame and voice included.

Linked clips and retakes

Some deployments support clips that run between two reviewed frames. Check GET /v1/studio/productions/capabilities first: operations.generateLinkedClips and operations.retakeLinkedClips say whether they are available.

  • Quote a linked clip. generate with kind: "clip", the shotId and dryRun: true checks both frames and returns a quote with an inputHash. Send it back as expectedInputHash on the real request. When the settings or the frames changed in between, the route answers 409 sequence_quote_changed before it submits anything.
  • Retake a clip. Add retakeResultKey to quote a new take of an existing linked clip, with its original settings and frames. Then submit it with the quote's inputHash and a fresh clientRequestId. Do not send mode, overrides or count. A take whose original request or frames were not kept cannot be retaken.

From a brief to a production

Read the guide

GET /v1/studio/productions/skill returns { skill, catalog, schema, operating, generatedFrom }: how a plan is written, every picker, model and allowed value, the plan's JSON Schema, and the operation vocabulary.

Validate a plan

POST /v1/studio/productions/validate with { plan } returns { valid, errors, warnings, summary? }. Fix each error's path until valid is true. Cast names are checked against your own library.

Create the production

POST /v1/studio/productions with { name?, plan? } creates it and lands the plan. POST …/:id/import with { plan, mode: "append" } adds a plan's shots to an existing production. Neither generates media.

Or let the director write the plan

POST …/:id/describe with { brief, llmModel, mode?, label?, clientRequestId? } starts a director run and returns { jobId, production }. When the job is done, reconcile to land the drafted shots.

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)

Plan the export

GET /v1/studio/productions/:id/export-plan returns the ordered steps that assemble the film: the per-shot audio merges, the join, and an optional 4K finish with upscale. It runs nothing.

{
  "canExport": true,
  "steps": [
    {
      "id": "voice-shot-2",
      "node": "merge-video-audio",
      "label": "Voice over shot 2",
      "credits": 2,
      "params": { "videoUrl": "https://cdn.nodaro.ai/studio/shot-2.mp4", "audioUrl": "https://cdn.nodaro.ai/studio/vo.mp3" }
    },
    {
      "id": "combine",
      "node": "combine-videos",
      "label": "Join 3 shots",
      "credits": 4,
      "params": {
        "videoUrls": [{ "fromStep": "voice-shot-2" }, "https://cdn.nodaro.ai/studio/shot-3.mp4"],
        "transition": "cut",
        "audioMode": "keep"
      }
    }
  ],
  "resultStepId": "combine",
  "estimate": 6,
  "unpriced": []
}

Run the steps in order with the ordinary node routes, such as Merge Video & Audio and Combine Videos. A value of { "fromStep": "…" } is the output of an earlier step: substitute the URL that step produced. Then record the finished file on the production as a cut, with the operation that adds one.

canExport is false when there is nothing to assemble, that is, fewer than two clips. estimate is null when any step has no price, and unpriced names those models, because a partial sum would understate the cost.

Share and copy a production

  • Share. POST …/:id/share with { shared: true } turns on the read-only share link; { shared: false } or POST …/:id/unshare turns it off. Only the owner or a workspace admin may change sharing (403 forbidden otherwise). When operations.revisionedSharing is available, add expectedVersion to tie the change to the version you reviewed; a newer edit then answers 409 workflow_conflict.
  • Allow editable copies. With operations.editableSharedCopies, the owner can also send allowEditableCopy: true with shared: true and expectedVersion. Signed-in link viewers may then copy the plan, the prompts, the cast descriptions, the kept inputs and the take history. The bin and private review notes are not copied, and unsharing stops new copies.
  • Copy. POST …/:id/clone with { name? } copies a production through your own view of it. The copy starts private and visible, and it uses your storage.

Export a timeline to an editing app

POST /v1/freecut-export turns a timeline of scene clips into an editing-project file, so you can finish the cut in an external editing app. It writes a FreeCut JSON (freecut-v1) or a Final Cut Pro XML (fcpxml-v1.10) file to your storage and returns its URL. It costs no credits and allows 10 requests per minute. Like the rest of this page, it needs Nodaro Cloud; other editions answer 404.

curl -X POST https://app.nodaro.ai/v1/freecut-export \
  -H "Authorization: Bearer $NODARO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "format": "json",
    "name": "The Lighthouse, cut 1",
    "timeline": {
      "musicAssetUrl": "https://cdn.nodaro.ai/studio/score.mp3",
      "scenes": [
        {
          "sceneEntityId": "scene-1",
          "compositeUrl": "https://cdn.nodaro.ai/studio/scene-1.mp4",
          "shots": [{ "shot_id": "s1", "duration_seconds": 4 }]
        },
        {
          "sceneEntityId": "scene-2",
          "compositeUrl": "https://cdn.nodaro.ai/studio/scene-2.mp4",
          "shots": [{
            "shot_id": "s2",
            "duration_seconds": 6,
            "cut_decision": { "in_offset_sec": 0, "out_offset_sec": 0.5, "transition_to_next": "dissolve" }
          }]
        }
      ]
    }
  }'
const file = await client.request('POST', '/v1/freecut-export', {
  body: { format: 'json', name: 'The Lighthouse, cut 1', timeline },
})
{
  "url": "https://cdn.nodaro.ai/exports/7b2d4f6a/freecut-3c5e7a9b-1d2f-4a6c-8e3b-5f7a9c1e2d4b.json",
  "format": "json",
  "assetId": "1e3a5c7b-9d2f-4b4a-8c6e-5f7d9b1a3c2e"
}

assetId is the file's entry in your library, or null when that entry could not be created; the url is valid either way.

FieldWhat it holds
formatRequired. json for FreeCut JSON, or fcpxml for Final Cut Pro XML.
timelineRequired. The timeline, below.
nameA label for your records, up to 200 characters.
timeline.scenesRequired. At least one scene, in playback order. Each scene becomes one clip on the video track.
timeline.musicAssetUrlThe music track. An empty string, the default, leaves it out.
timeline.narrationAssetUrlA narration track, placed on its own audio lane.
timeline.fadeOutDurationSecThe fade at the end of the music, 0.8 seconds by default. FreeCut JSON only.
scene.sceneEntityId, scene.compositeUrlRequired. The scene's id and its merged clip.
scene.shotsRequired. At least one shot, { shot_id, duration_seconds, cut_decision? }. The shots' durations add up to the scene's length.
cut_decision.in_offset_sec, cut_decision.out_offset_secRequired in a cut decision. The trim at the start (read from a scene's first shot) and at the end (read from its last shot).
cut_decision.transition_to_nextRequired in a cut decision. hard_cut, dissolve, match_cut or overlap: the transition into the next scene.
cut_decision.transition_duration_secOverrides the transition's default length: 0 for hard_cut and match_cut, 0.5 seconds for dissolve, 1 second for overlap.

dissolve and overlap overlap the two clips by their length; hard_cut and match_cut butt them together. When no shot has a cut_decision, the export is a simple concatenation: one clip per scene, end to end, with hard cuts and the music across the whole timeline. Trims inside a scene are not applied, because each scene clip is already merged.

Share and remix records

A shot record saves the state of a shot you built: the picker choices, the prompts, the target models, the @-mentioned assets and the results. It sits behind a short, unguessable id that powers /s/:id share links and one-click remixes. These routes are separate from productions.

MethodPathAuthWhat it does
POST/v1/shotsBearer tokenCreate a record. Returns { id }.
GET/v1/shots/:idNoneRead a record. A private record answers 404 to everyone but its owner. Limited per IP address.
PATCH/v1/shots/:idOwnerUpdate any fields, including visibility.
DELETE/v1/shots/:idOwnerDelete the record.

The body carries mode (single, multi-shot, frame-to-motion or storyboard), selectionState (each picker's chosen value, as { pickerNodeType: valueId } or { pickerNodeType: { field: valueId } }) and, optionally, freeText, negativePrompt, assembledPrompt, perModelPrompts, models, entityRefs and resultUrls. visibility is private by default; set it to public to make the record shareable.

resultUrls accept only plain public http or https URLs. Signed URLs are refused, so a token never leaks into a shared record. Records carry a schemaVersion. When a record names a catalog entry that no longer exists, skip it with a note instead of failing the remix.

curl -X POST https://app.nodaro.ai/v1/shots \
  -H "Authorization: Bearer $NODARO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "single",
    "selectionState": { "mood": "melancholy", "lighting": { "timeOfDay": "blue-hour" } },
    "freeText": "a woman at a bus stop",
    "models": ["nano-banana-pro"],
    "visibility": "public"
  }'
const { id: shotId } = await client.shots.create({
  mode: 'single',
  selectionState: { mood: 'melancholy', lighting: { timeOfDay: 'blue-hour' } },
  freeText: 'a woman at a bus stop',
})
await client.shots.update(shotId, { visibility: 'public' })
const { shot } = await client.shots.get(shotId)
nodaro shots create --file shot.json --visibility public
nodaro shots get <id> --json
nodaro shots delete <id>

Use it from MCP

AI assistants use the same productions through tools such as get_studio_production_skill, validate_studio_plan, create_studio_production, edit_studio_production, generate_studio_still, generate_studio_clip and plan_studio_export. Reading a production with write access also lands finished jobs. See Studio productions over MCP.

Errors

StatusCodeMeaning
400validation_errorThe body or the plan is wrong. path names the field; on the export route, issues lists the problems.
400op_invalid, op_target_missingOn …/ops: one operation was wrong, and opIndex names it. Nothing was written. Fix it and send the batch again.
402insufficient_creditsThe account cannot cover the generation.
403forbiddenOnly the owner or a workspace admin can change sharing.
404not_foundNo such production for this caller, or the deployment does not serve productions.
404op_target_missingOn a generation or media route: the shot, result or role you named is not in the production.
409workflow_conflictstrict: true or expectedVersion was sent and the production changed. Read it again and reapply.
409production_busyThe production kept changing during the write. Read it again and retry.
409sequence_quote_changedA linked clip's settings or frames changed since the quote. Quote again.
413storage_exceededThe account is over its storage limit.
429rate_limit_exceededMore than 10 timeline exports in a minute. Wait for the time in Retry-After.

In the SDK these arrive as typed errors: StudioOpError (with opIndex), WorkflowConflictError for both 409 codes of a write, InsufficientCreditsError, StorageExceededError and NotFoundError.

Frequently asked questions

Last updated on

On this page