Kodwen docs
Gateway

Creatives

Editable creative documents in Kodwen Studio — create a video, image or deck as JSON, edit it with small, versioned changes, keep every version and render it to MP4.

A creative is one editable document per Studio artefact (video, image, deck, doc, sheet or audio — songs and podcasts, see Audio creatives), stored as plain JSON — the Editable Creative Document (ECD). Agents and editors never rewrite HTML: they send small, id-addressed ops. Every edit appends a version, so you can always look back, revert or fork. The renderer input is a pure, deterministic function of the document.

Creating, reading and editing creatives is free. Only renders cost money, and still frames beyond a daily free quota.

MethodPathResult
POST/v1/studio/creativesNew creative at version 1 (201)
GET/v1/studio/creatives?kind=&before=&limit={ object: "list", data, next_before }, most recently updated first
GET/v1/studio/creatives/{id}?seq=The document at the head (or seq) + signed assets
GET/v1/studio/creatives/{id}/outlineCompact text outline, formats and variables
POST/v1/studio/creatives/{id}/findNode ids matching a query
POST/v1/studio/creatives/{id}/subtreeFull JSON of nodes or a scene
POST/v1/studio/creatives/{id}/opsApply an op batch → new version
GET/v1/studio/creatives/{id}/versionsVersion history
POST/v1/studio/creatives/{id}/revertNew head with an older version's document
GET/v1/studio/creatives/{id}/branchesNamed branches (main first)
POST/v1/studio/creatives/{id}/branchesNew branch from a version, 201
DELETE/v1/studio/creatives/{id}/branches/{name}Delete a branch and its own versions
POST/v1/studio/creatives/{id}/filmstripThumbnail per clip (shared compositor, frame quota)
GET/v1/studio/jobsRunning and recent Studio jobs (queue)
POST/v1/studio/creatives/{id}/forkNew creative from a version (201)
POST/v1/studio/creatives/{id}/lintContrast, safe area, brand, accessibility, missing assets…
GET/v1/studio/creatives/{id}/compiled?format=&scene=Preview HTML with signed asset URLs
POST/v1/studio/creatives/{id}/renderMP4 render (202, codai-render; 200 when the identical render already exists)
POST/v1/studio/creatives/{id}/framePNG still frames at up to 6 times (200, or 202 + a job to poll)
GET/v1/studio/creatives/{id}/frames/{jobId}Poll a frame job (200 frames, 202 still running)
POST/v1/studio/creatives/{id}/commentsTurn a comment pinned on a frame into an edit (token-metered)
POST/v1/studio/creatives/{id}/variantsGenerate A/B variants as forks + draft renders (token-metered)
GET/v1/studio/creatives/{id}/variantsThe latest variant group with statuses and render state
POST/v1/studio/creatives/{id}/variants/{variantId}/pickApply the winning variant to the original
DELETE/v1/studio/creatives/{id}Delete
POST/v1/studio/brand-kitsCreate a brand kit from tokens or a DTCG file (201)
POST/v1/studio/brand-kits/extractBrand kit (palette, fonts, logo) from a web page or a logo image (201, token-metered)
GET/v1/studio/brand-kits?limit=Your brand kits, newest first
GET/v1/studio/brand-kits/{id}One brand kit with a signed logo URL
PATCH/v1/studio/brand-kits/{id}Edit a brand kit (name, tokens, DTCG, logo)
GET/v1/studio/brand-kits/{id}/dtcgDownload a brand kit as a DTCG .tokens.json file
DELETE/v1/studio/brand-kits/{id}Delete a brand kit
POST/v1/studio/creatives/{id}/restyleRe-apply a brand kit to a creative → new version
POST/v1/studio/creatives/{id}/captionsAuto-captions from a voice clip → new version (transcription billed per minute)
GET/v1/studio/creatives/{id}/captions.srt?node_id=&seq=Download the captions as SubRip
GET/v1/studio/creatives/{id}/captions.vtt?node_id=&seq=Download the captions as WebVTT
POST/v1/studio/creatives/{id}/adaptAdapt to platform presets (sizes + UI safe zones) → new version
POST/v1/studio/creatives/{id}/lint/fixFix lint issues automatically (free; optional paid agent pass)
GET/v1/studio/creatives/{id}/controlsTweak panel model: one control per variable
GET/v1/studio/creatives/{id}/locales?locale=&units=Languages: progress per language and, on request, the translation units
POST/v1/studio/creatives/{id}/localizeTranslate into up to 10 languages → new version (token-metered)
POST/v1/studio/creatives/{id}/locales/{locale}/forkStandalone creative in one language (201)
POST/v1/studio/creatives/{id}/templateSave a creative as a private template (201)
GET/v1/studio/templates?kind=&limit=Your templates, newest first
GET/v1/studio/templates/{id}One template with its document
DELETE/v1/studio/templates/{id}Delete a template
GET/v1/studio/creatives/{id}/export.fcpxml?format=&seq=Queue an FCPXML project zip (202)
GET/v1/studio/creatives/{id}/export.lottie?format=&formats=&seq=&container=&download=Lottie JSON / dotLottie export, free and ready at once (200)
GET/v1/studio/exports/{id}Export state; signed zip URL once succeeded
POST/v1/studio/documents/{id}/creativeEdit a deck, doc or sheet document as a creative (201, or 200 when already linked)
POST/v1/studio/creatives/{id}/buildBuild a deck, doc or sheet creative into a new PPTX / DOCX / XLSX version
POST/v1/studio/creatives/{id}/sharesCreate a view or review link (201, token shown once) — see Share, review and remix
GET/v1/studio/creatives/{id}/sharesActive share links
DELETE/v1/studio/creatives/{id}/shares/{shareId}Revoke a share link (204)
GET/v1/studio/creatives/{id}/reviewReview status and approved version
POST/v1/studio/creatives/{id}/reviewRequest a review or go back to draft
GET/v1/studio/creatives/{id}/review/threads?status=Review comment threads
POST/v1/studio/creatives/{id}/review/threadsNew anchored comment thread (201)
POST/v1/studio/creatives/{id}/review/threads/{threadId}/repliesReply to a thread (201)
POST/v1/studio/creatives/{id}/review/threads/{threadId}/resolveResolve a thread
POST/v1/studio/creatives/{id}/review/threads/{threadId}/reopenReopen a thread
DELETE/v1/studio/creatives/{id}/review/comments/{commentId}Delete a review comment (204)
POST/v1/studio/shares/{token}/remixCopy a shared creative into your account (201)

The document

{
  "schemaVersion": 1,
  "kind": "video",
  "title": "Launch teaser",
  "formats": [{ "id": "16x9", "width": 1920, "height": 1080 }],
  "defaultFormat": "16x9",
  "fps": 30,
  "brand": { "tokens": { "primary": "#ff5500" } },
  "vars": { "headline": { "type": "text", "value": "Hello world" } },
  "assets": { "3f2b8c1e-0000-4000-8000-000000000001": { "kind": "image", "mime": "image/png" } },
  "scenes": [
    {
      "id": "intro",
      "orderKey": "V",
      "durationMs": 4000,
      "nodes": [
        {
          "id": "title",
          "type": "text",
          "orderKey": "V",
          "layout": { "x": 200, "y": 400, "w": 1400, "h": 200 },
          "anim": [{ "preset": "fade-in" }],
          "props": { "spans": [{ "text": { "$var": "headline" } }], "size": 96, "color": { "$token": "primary" } }
        }
      ]
    }
  ],
  "audio": { "tracks": [] }
}
  • Scenes play in orderKey order; video scenes last 0.5–30 s and a video lasts at most 180 s.
  • Nodes are text, shape, image, video, chart, captions, group and html_block, each with an id, a layout and optional timing, anim (presets such as fade-in, slide-up, ken-burns, zoom-in, typewriter, or keyframes — see Motion) and per-format overrides.
  • Media refer to your assets by id. brand.tokens and vars are referenced as { "$token": "…" } and { "$var": "…" }.
  • A document is at most 2 MB of canonical JSON (413 too_large).

Without a doc, POST /v1/studio/creatives gives you an empty document for the kind: a 16:9 video with one empty 5 s scene, a 1080×1080 image, a 16:9 deck with one slide, or an empty doc / sheet.

curl https://ai.codai.ro/v1/studio/creatives \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "video", "title": "Launch teaser" }'

Tracks (schemaVersion 2)

Video documents use real tracks: schemaVersion: 2 adds tracks — visual tracks (video, overlay, text, captions) of clips placed at absolute milliseconds. A clip is a media clip (type: "video", its own in/out range), one node (type: "node") or a compound clip (type: "compound", an old scene with its nodes). scenes become named sections without nodes. Migration turns every scene into one compound clip with the same id, so comments, reviews and caches anchored on a scene id keep working, and renders stay byte-identical. Every stored document is version 2 (a version 1 document you send is migrated on save); the outline lists tracks → clips, anchors accept clip_id (with scene_id as its alias), and the track/clip ops (addVisualTrack, insertClip, moveVisualClip, reorderVisualClip, trimVisualClip, splitVisualClip, setClipProps, setClipAnim, flattenClip, …) edit them. reorderVisualClip { target, after } moves a clip earlier or later in its track and re-packs the starts (transitions keep their overlap), the way moving a scene did. Render segments follow runs of overlapping clips across all tracks.

Editing with ops

Send ops against the version you read (base_seq). A batch is all or nothing.

curl https://ai.codai.ro/v1/studio/creatives/$ID/ops \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "base_seq": 3,
    "intent": "Bigger headline, new background",
    "ops": [
      { "op": "setProps", "target": "title", "props": { "size": 120 } },
      { "op": "updateScene", "target": "intro", "patch": { "background": "#101010" } }
    ]
  }'
{ "seq": 4, "doc_hash": "9a3f…", "changed": ["title", "intro"], "warnings": [], "rebased": false }

The 25 ops: setProps, setLayout, setTiming, setAnim, insertNode, removeNode, moveNode, addScene, removeScene, updateScene, moveScene, setVar, removeVar, setAsset, replaceAsset, setAudioClip, removeAudioClip, setBrand, restyle, adaptFormat, setControl, setLocaleText, setLocale, setLocalization and setMeta. Translations are edited with setLocaleText, setLocale and setLocalization (see Localisation). Adapting to a platform is one adaptFormat (see Platforms); describing how a variable is edited in the tweak panel is one setControl (see Tweak panel). Swapping the brand tokens is one setBrand; re-applying a brand to literal colours and fonts is one restyle (see Restyle a creative); swapping an image everywhere is one replaceAsset.

Concurrent edits

If someone else edited the creative after you read version base_seq, the gateway rebases your ops: when none of the targets and properties you touch changed meanwhile, they are applied on top of the head (rebased: true). Otherwise you get 409 with what overlapped — reload and retry:

{
  "error": {
    "code": "stale",
    "type": "invalid_request_error",
    "message": "The creative changed since base_seq and 1 change(s) overlap; reload and retry.",
    "head_seq": 5,
    "conflicts": [{ "target": "title", "head_prop": "props.size", "prop": "props.size" }]
  }
}

Rejected ops

An invalid op, an unknown id or a locked node is 400 with the op's code (invalid, not_found, locked, duplicate_id), the path inside the op or document, and op_index. Nothing is saved. Send force: true to edit locked nodes and variables on purpose.

Motion

setAnim replaces the anim list of a node; each entry is a preset or a keyframe animation. A preset entry is { "preset", "atMs"?, "durMs"?, "ease"?, "params"? }; a keyframe entry is { "prop", "keyframes": [{ "tMs", "value", "ease"? }] } with prop one of opacity, x, y, scale and rotation.

{
  "op": "setAnim",
  "target": "bg",
  "anim": [{ "preset": "zoom-in", "params": { "scale": 1.2 }, "ease": "in-out-cubic" }]
}
PresetWhat it doesDefault duration
fade-in, fade-outFades in; fades out at the end of the node400 ms
slide-up, slide-down, slide-left, slide-rightSlides in over params.distance (default 48 px) with a fade500 ms
scale-popPops in from 0.6 with a small overshoot450 ms
typewriterReveals the text character by character1200 ms
word-staggerWords appear one after another (params.wordDurMs, default 350)600 ms
count-upCounts from params.from (default 0) to params.to (default: the number in the text)1200 ms
wipeReveals the node with a horizontal wipe600 ms
highlight-sweepSweeps the highlight colour across the text600 ms
ken-burnsSlow linear zoom towards the focal point (params.scale, default 1.08)Whole node
zoom-in, zoom-outCamera push / pull on full-bleed media about the focal point (params.scale, default 1.14)Whole node
pan-left, pan-rightCamera truck across slightly enlarged media (params.scale, default 1.12)Whole node
riseEntrance: moves up (params.distance, default 64 px) and scales from 0.96 with a fade700 ms
scale-outExit: shrinks to 0.92 and fades, placed at the end of the node like fade-out400 ms

Easing. ease, on a keyframe or on a preset, is one of the five CSS keywords (linear, ease, ease-in, ease-out, ease-in-out) or one of the curve tokens out-cubic, out-quart, out-expo, in-cubic, in-out-cubic, in-out-quart, in-out-sine, out-back and in-out-back. On a preset it overrides the main curve of the preset (typewriter ignores it).

Grade. Image and video nodes take grade in their props (setProps): { brightness?, contrast?, saturate?, blur? }. For brightness and contrast (0–2) and saturate (0–3), 1 leaves the media unchanged; blur is 0–200 document pixels. The grade is applied in the web preview, in MP4 renders and in the compositor, but not in the Lottie export (warning grade_not_exported).

{ "op": "setProps", "target": "bg", "props": { "grade": { "contrast": 1.1, "saturate": 0.9 } } }

Scene transitions. The transitionIn of a scene (updateScene) is { "type", "durMs" }: type is one of cut, fade, slide-left, slide-right, slide-up, slide-down, wipe, zoom and zoom-out, and durMs is at most 5000.

{
  "op": "updateScene",
  "target": "intro",
  "patch": { "transitionIn": { "type": "slide-right", "durMs": 500 } }
}

Reading for agents

Large documents do not need to travel whole. outline returns one line per scene and node (ids, types, labels, text clipped to 60 characters, timings); find returns node ids by text, type, scene, asset or lock state; subtree returns the full JSON of just the ids you ask for.

Versions

Every edit is a version; history is append-only. revert appends a new version with an older document (intent: "revert to N"), so undo and redo are both reverts. fork starts a new creative from any version. GET …?seq=N reads an old version.

Branches. main is the default line. POST …/branches {name, from?, seq?} starts a branch at any version; its own versions continue from that seq (history below it is shared). Pass branch (body) or ?branch= (query) to GET {id}, /ops, /versions, /revert, /fork, /frame, /filmstrip and /comments; answers name the branch. Names are a-z, 0-9, dashes, at most 40, at most 20 branches per creative. Errors: branch_not_found (404), branch_exists, too_many_branches, branch_protected (main) and branch_has_children (409). SDK: creatives.branches, createBranch, deleteBranch (Python branches, create_branch, delete_branch).

Filmstrip and queue. POST …/filmstrip draws one thumbnail per clip midpoint with the shared compositor (the same code as the editor preview) through the frame-job path: cached, free for the first 60 frame jobs a day, then the draft minimum. GET /v1/studio/jobs lists your running and last-15-minute jobs of every kind (renders, frames, exports, productions, media ops, Music Room, uploads) with progress and an ETA; it is free.

Preview and render

GET …/compiled returns the deterministic HyperFrames composition with your assets as 1-hour signed URLs, ready for an iframe srcdoc (Cache-Control: private, no-store). POST …/render compiles the head and queues a regular codai-render:

curl https://ai.codai.ro/v1/studio/creatives/$ID/render \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "format": "16x9", "quality": "draft" }'

It answers 202 with render_id; poll it with GET /v1/studio/renders/{id}, which reports creative_id. The render is billed like any render, once, when it has succeeded. The format must be 1920×1080, 1080×1920, 1080×1080 or 1080×1350 (4:5). Rendering works for video creatives (other kinds answer 400 not_supported_for_kind); compiling also previews deck, doc and sheet creatives (see Documents).

Renders are composed from per-scene segments: scenes that did not change since an earlier render are reused, so re-rendering after a small edit only renders the scenes you touched (the charge is still per second of output). Rendering the exact same version, format, quality and fps again answers 200 at once with the existing render (cached: true, status: "succeeded") and charges nothing; a new render's 202 body has cached: false.

ai_label (corner, end_card or none) burns a visible AI label into the video, as on renders. When omitted it is corner if the creative uses TTS narration or AI-generated images, else none; none with TTS narration answers 400 ai_label_required. Under EU AI Act art. 50(4) you, the deployer, stay responsible for labelling deepfakes you publish.

Still frames

POST …/frame returns PNG still frames of the creative at up to 6 times, for a format (default the document's default format) and a version (default the head):

curl https://ai.codai.ro/v1/studio/creatives/$ID/frame \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "t_ms": [0, 1500, 4200], "format": "16x9", "exact": false }'
FieldMeaning
t_ms1–6 times in milliseconds, each below the creative's total duration (required)
formatA formats[].id (default the document's default format)
exactfalse (default): a fast screenshot of the compiled HTML. true: frames cut from a draft render of the scenes covering those times — pixel-identical to the MP4, and it fills the scene cache for later renders
versionDocument version (seq); default the head

The call waits up to about 90 seconds. When the frames are ready it answers 200:

{
  "object": "studio.creative.frames",
  "frame_job_id": "5e6f7a8b-0000-4000-8000-000000000040",
  "status": "succeeded",
  "frames": [{ "t_ms": 1500, "url": "https://…signed, 1 h…", "width": 1920, "height": 1080, "cached": false }],
  "head_seq": 3,
  "billed_micro_usd": "0"
}

Otherwise it answers 202 with { "object": "studio.creative.frame_job", "frame_job_id": "…", "status": "running", "head_seq": 3 }; poll GET /v1/studio/creatives/{id}/frames/{jobId} until it returns the same 200 body (202 while running, 404 for an unknown job, 502 frame_failed if it failed).

Caching. Frames already rendered for the same document, format and time are served from the cache instantly and for free (cached: true); when every frame comes from the cache, frame_job_id is null.

Quota and price. 60 dispatched frame jobs per user per UTC day are free; answers served entirely from the cache are always free and do not count. After that, fast frames cost a flat $0.025 (25 000 µUSD) per job and exact frames the draft render price ($0.005 per second of newly rendered scenes, minimum $0.025). billed_micro_usd reports the charge.

Errors. 400 invalid_request (bad body, more than 6 times, a time outside the creative), 400 unsupported_format (exact frames need 1920×1080, 1080×1920, 1080×1080 or 1080×1350; fast frames up to 3840 px per side), 400 not_supported_for_kind, 429 too_many_frame_jobs (3 running frame jobs at most), 502 frame_failed, 503 renders_unavailable.

Comments on a frame

POST …/comments turns a reviewer comment pinned on a frame into an edit. One bounded server agent turn reads the outline, the commented node (or scene, or the scene playing at t_ms) and the comment, and proposes ops that are applied exactly like POST …/ops against your base_seq:

curl https://ai.codai.ro/v1/studio/creatives/$ID/comments \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "node_id": "title", "t_ms": 1200, "comment": "Make the headline bigger and white", "base_seq": 3 }'
FieldMeaning
t_msPlayhead time of the comment (required)
commentWhat to change, 1–2000 characters (required)
base_seqThe head_seq you last read (required; a stale base is rebased like /ops)
node_id / scene_id / clip_idThe selected node, scene or clip (default: the scene — on schemaVersion 2 videos the clip of the first video track — playing at t_ms; scene_id is an alias of clip_id)
applytrue (default) writes a version; false returns the proposed ops and their diff only
chipsUp to 8 context chips the edit should stay inside: { "kind": "clip" | "node", "id" }, { "kind": "range", "start_ms", "end_ms" } or a stage { "kind": "area", "t_ms", "x", "y", "w", "h", "format"? } (composition px)

It answers 200 { "head_seq": 4, "applied_ops": 1, "summary": "Made the headline bigger.", "version": { "seq": 4, "doc_hash": "…" } }. An overlapping change since base_seq answers 409 stale (reload, retry); when the comment is not an edit request the answer is 422 no_edit with the model's reply in error.text; after 60 s, 504 timeout. Rejected ops get one automatic repair round.

Op diff (SX-07). With apply: false the answer also carries diff: { scoped, entries }: one entry per proposed op with action (insert, remove, move, retime, set), targets (id + a short label), fields, text before / after, in_scope (inside the chips) and depends_on (earlier ops it needs). The Studio editors check in-scope ops by default, let you accept or reject each one, and send the accepted subset to POST …/ops with the proposal's head_seq as base_seq.

Price. Token-metered like any kodwen text call (usage rows codai::creative-comment, prompt caching on) — a typical comment costs well under $0.01.

A/B variants

POST …/variants asks one bounded server agent turn for A/B variants of the creative at base_seq. Each variant changes only the aspects in focus and is saved as a new creative forked from base_seq (title "<title> — <label>", the ops applied as version 2 by agent), so you can open, edit, render or delete it like any creative. The original does not change until you pick a winner.

curl https://ai.codai.ro/v1/studio/creatives/$ID/variants \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "count": 3, "focus": ["hook", "cta"], "brief": "Black Friday, young audience", "base_seq": 4 }'
FieldMeaning
base_seqThe version to fork from (required)
count1–5 variants (default 3)
focusAny of hook (the opening), title, cta, visual (colours, layout, imagery, motion), copy (default hook, title, cta)
briefOptional direction, up to 1000 characters
rendertrue (default, video only) queues a draft render of each variant
formatFormat id of the draft renders (default: the document default)

It answers 200 { "group_id": "…", "base_seq": 4, "variants": [{ "creative_id": "…", "label": "Question hook", "rationale": "…", "applied_ops": 2, "head_seq": 2, "render_id": "…" }] } without waiting for the renders — poll each render_id with GET /v1/studio/renders/{id}. Invalid proposals are dropped (one automatic repair round); when none is valid the answer is 422 no_edit; after 90 s, 504 timeout. A render that could not be queued leaves the variant with render_error.

GET …/variants returns the latest group: each variant with status (candidate, winner, rejected) and the stored state of its draft render (poster_url and url once it succeeded).

POST …/variants/{variantId}/pick with { "base_seq": <original head> } applies the winner's ops to the original exactly like POST …/ops (author agent, intent variant: <label>; a stale base is rebased, an overlapping change is 409 stale) and marks the winner winner and the others rejected. It answers { "head_seq", "version", "winner", "rebased" }.

Price. Generating is token-metered like any kodwen text call (usage rows codai::creative-variants, prompt caching on — well under $0.05 for three variants); each draft render is billed on its poll like every draft render ($0.005 per output second, min $0.025). Listing and picking are free.

Brand kits

A brand kit is a reusable set of brand tokens — five colour roles primary, secondary, accent, background, text, up to 12 extra named colours, the heading and body font families and an optional corner radius — plus an optional logo. Kits are stored as DTCG design tokens (format 2025.10), so you can import them from and export them to design tools.

The DTCG format

{
  "$schema": "https://www.designtokens.org/schemas/2025.10/format.json",
  "color": {
    "$type": "color",
    "primary": { "$value": { "colorSpace": "srgb", "components": [0.004, 0.439, 0.502], "hex": "#017080" } },
    "secondary": { "$value": { "colorSpace": "srgb", "components": [0.137, 0.671, 0.737], "hex": "#23abbc" } },
    "accent": { "$value": { "colorSpace": "srgb", "components": [0.902, 0.718, 0.192], "hex": "#e6b731" } },
    "background": { "$value": { "colorSpace": "srgb", "components": [0.969, 0.973, 0.98], "hex": "#f7f8fa" } },
    "text": { "$value": { "colorSpace": "srgb", "components": [0.059, 0.067, 0.082], "hex": "#0f1115" } },
    "sand": { "$value": { "colorSpace": "srgb", "components": [0.949, 0.902, 0.82], "hex": "#f2e6d1" } }
  },
  "font": {
    "$type": "fontFamily",
    "heading": { "$value": ["Manrope"] },
    "body": { "$value": ["Inter", "Arial"] }
  },
  "radius": { "$type": "dimension", "base": { "$value": { "value": 8, "unit": "px" } } }
}
  • The five colour roles are required. Extra colours are named with a lowercase letter followed by up to 31 lowercase letters, digits or - (at most 12).
  • Import is lenient: a colour $value may also be a "#abc" / "#aabbcc" string, { "hex" } or { "colorSpace": "srgb", "components" }; $type may sit on the token or on its group; a font $value may be a single string. Stored kits always use the canonical shape above.
  • A font $value lists 1–4 family names (an empty list means unknown); radius is 0–200 px and optional.
  • The canonical JSON is at most 16 KB. An invalid token file answers 400 invalid_tokens with the problems in error.details.issues.

Every kit answers with dtcg, the flat tokens written into creatives (below), contrast and updated_at: { "id", "name", "source_url", "dtcg": { … }, "tokens": { … }, "contrast": [ … ], "logo_asset_id", "logo_url", "logo_mime_type", "created_at", "updated_at" }.

Contrast. contrast lists the WCAG contrast ratio of five fixed pairs — text on background, primary on background, background on primary, accent on background and secondary on background — as { "foreground", "background", "ratio", "aa" } (ratio rounded to 2 decimals, aa is true from 4.5:1).

Flat tokens. Creatives keep a flat map in brand.tokens: the five colour roles, headingFont and bodyFont (the first family, or ""), every extra colour under its name, radius (px) when set, and the document aliases color.bg (= background), color.fg (= text), color.accent (= primary), font.heading and font.body, so converted deck, doc and sheet creatives follow the kit too.

Create, import, edit, export

POST /v1/studio/brand-kits creates a kit by hand. Send name (1–100 characters) and exactly one of tokens (the flat map: all five colours as #rrggbb, plus headingFont / bodyFont up to 100 characters) or dtcg (a DTCG file to import); optionally logo_asset_id (one of your ready image assets, otherwise 404 Asset not found.) and source_url (https). It answers 201 with the kit.

curl https://ai.codai.ro/v1/studio/brand-kits \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Acme", "tokens": { "primary": "#e4002b", "secondary": "#1d3557", "accent": "#ffb703",
        "background": "#ffffff", "text": "#111111", "headingFont": "Manrope", "bodyFont": "Inter" } }'

PATCH /v1/studio/brand-kits/{id} edits a kit: any of name, tokens (partial — merged onto the current kit), dtcg (replaces the tokens) and logo_asset_id (null removes the logo); at least one field, never both tokens and dtcg. It answers 200 with the kit; a kit that is not yours or was deleted is 404. Creatives that already use the kit keep their tokens until you restyle them.

GET /v1/studio/brand-kits/{id}/dtcg downloads the kit as a DTCG file (application/json, content-disposition: attachment; filename="<slug>.tokens.json") with "$extensions": { "ro.codai": { "kit_id", "name", "logo_asset_id" } } added, ready for any DTCG-aware design tool.

POST /v1/studio/brand-kits/extract builds a kit from exactly one of url (a public https page) or logo_asset_id (a logo image already in your library); name is optional in both cases.

curl https://ai.codai.ro/v1/studio/brand-kits/extract \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://acme.example/" }'

From a URL the gateway fetches the page and up to 3 of its stylesheets (every redirect is re-checked against private and internal addresses; 10 s, 3 MB, HTML only), collects colour, font and logo candidates (theme-color, CSS custom properties, font-family, Google Fonts links, logo images, touch icons, og:image) and one bounded server agent turn picks a coherent palette (text readable on the background) and fonts. The chosen logo (PNG, JPEG or WebP up to 5 MB — SVG is not imported) is stored in your Studio library as an image asset.

From a logo ({ "logo_asset_id": "<image asset id>" }) the image must be one of your ready PNG, JPEG or WebP assets up to 5 MB (otherwise 400 unsupported_media). One vision turn reads the palette and suggests fonts that suit the logo; the logo becomes the kit's logo and source_url is null.

Both answer 201 with the kit (stored as DTCG). Errors: 400 url_blocked (private or blocked host), 400 unsupported_media, 422 fetch_failed (the page could not be fetched or is not HTML), 422 extract_failed (no valid kit), 504 timeout after 60 s.

Apply a kit

  • At creation — POST /v1/studio/creatives with "brand_kit_id": "<kit id>": version 1 already has brand = { kitId, tokens } and the logo registered in assets (no extra version). An unknown kit is 404.
  • To an existing creative — POST /v1/studio/creatives/{id}/restyle (below).
  • By hand — send [{ "op": "setBrand", "kitId": "<kit id>", "tokens": { … } }] (plus a setAsset for the logo) to POST …/ops; this only replaces the tokens, literal colours stay as they are.

Nodes use the tokens as { "$token": "primary" }. GET /v1/studio/brand-kits lists your kits (newest first), GET …/{id} reads one, DELETE …/{id} deletes it (creatives keep the tokens they already have; the logo stays in your library).

Restyle a creative

POST /v1/studio/creatives/{id}/restyle re-applies a brand to a creative that was made before the kit, or with hard-coded colours:

curl https://ai.codai.ro/v1/studio/creatives/$ID/restyle \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "brand_kit_id": "<kit id>", "snap": true }'
FieldMeaning
brand_kit_id / tokensExactly one: one of your kits, or a flat token map
base_seqThe version you read (default: the head); a stale base is rebased like /ops
snaptrue maps every other literal colour to the nearest kit colour (default false)
fontsfalse leaves fonts alone (default true)
forcetrue restyles locked nodes too

The gateway sends one restyle op (plus a setAsset for the kit's logo when it has one) through the same edit path as POST …/ops (intent restyle with brand kit <name>) and answers 200 with the same body as /ops, including warnings. The rules:

  1. Token-ify. The new brand is the old brand.tokens with the kit's tokens on top. Every literal #rrggbb in scene backgrounds and node props (gradient stops included) that equals an old token value becomes a token reference such as { "$token": "primary" }, so it takes the new colour.
  2. Snap. With snap: true every other literal colour becomes a reference to the perceptually nearest of the five colour roles (OKLab distance). Colours with transparency (#rrggbbaa) are kept and counted in a warning.
  3. Fonts by role. A literal font equal to an old font token becomes its token. Any other literal font on a text, list or table node becomes headingFont for headings (roles title, subtitle, heading1–heading4, column-heading, stat-value, or text of size 48 and up outside documents) and bodyFont otherwise.
  4. Locked nodes (or nodes inside a locked group) are skipped with the warning restyle skipped locked node <id>, unless force: true.

The same op works in any /ops batch:

{ "op": "restyle", "kitId": "<kit id>", "tokens": { "primary": "#e4002b", "headingFont": "Manrope" }, "snap": false, "fonts": true }

Check the result with still frames and lint (contrast) afterwards.

SDKs and agent tools

const kit = await codai.brandKits.create({ name: 'Acme', dtcg });
await codai.brandKits.update(kit.id, { tokens: { accent: '#ffb703' } });
const tokensFile = await codai.brandKits.exportDtcg(kit.id);
const fromLogo = await codai.brandKits.extract({ logo_asset_id: logoAssetId });
const restyled = await codai.creatives.restyle(c.id, { brand_kit_id: kit.id, snap: true });
tokens_file = client.brand_kits.export_dtcg(kit["id"])
from_logo = client.brand_kits.extract(logo_asset_id=logo_asset_id)
restyled = client.creatives.restyle(c["id"], brand_kit_id=kit["id"], snap=True)

Agents on desktop, phone and VS Code use creative_brand_extract (url or logo_asset_id), creative_brand_kit_save (without brand_kit_id it creates a kit from name + tokens or dtcg; with it, it edits that kit) and creative_restyle (creative_id, brand_kit_id or tokens, snap, fonts). creative_edit also accepts the restyle op.

Price. Extracting is token-metered like any kodwen text call (usage row codai::brand-kit-extract, prompt caching on — typically well under $0.01, from a URL or a logo). Creating, editing, exporting, listing, reading, deleting, applying kits and restyling are free.

Captions

POST /v1/studio/creatives/{id}/captions adds auto-captions to a video creative: it transcribes a voice clip with word timings, fixes the spelling against the script when the narration is yours, and inserts a styled captions node in the safe area of the default format (above the platform interface when that format is bound to a platform, see Platforms).

curl https://ai.codai.ro/v1/studio/creatives/$ID/captions \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "preset": "karaoke-pop" }'
FieldMeaning
clip_idThe audio clip to caption (default: the first voice clip)
presetbold-pill (default), clean-outline, karaoke-pop or minimal
node_idId of the captions node (default captions, then captions-2…); an existing captions node with that id is replaced
scriptThe exact narration text, up to 20 000 characters (default: the text the speech asset was generated from)
languageTranscription language (default: the document locale)
retranscribetrue ignores the cached words and transcribes again
base_seq / forceAs for /ops (default: the head)

Word timings. The clip's audio (a ready speech, music or video asset of yours, at most 25 MB) is transcribed by Whisper with one timestamp per word. The words are relative to the clip: its inMs is subtracted and words outside the clip's trimmed range are dropped.

Script correction. When the clip is your own narration — speech generated in Studio keeps its source text — or you send script, the transcript is aligned word by word with the script: the timings come from the transcript, the spelling (names, diacritics, punctuation) from the script. When the script does not match what was said, the transcript is kept as it is (corrected: false).

Cached words. The words are stored on the speech asset (meta.caption_words), so captioning the same clip again — another preset, another creative that uses the same asset, a new node_id — reuses them for free (cached: true). Send retranscribe: true to force a new transcription.

The edit goes through the same path as POST …/ops (one insertNode in the scene that contains the clip start, or a setProps on the existing node) and answers 200 with the /ops body plus { "node_id", "words", "cached", "corrected", "cost_micro_usd" }.

Presets. Sizes are a fraction of the format's short side:

PresetStyleText sizeWords per lineColours
bold-pillPill behind the text5.5 %4White
clean-outlineOutlined text5 %6White
karaoke-popWord-by-word highlight6 %3White, highlight accent token (or #ffd60a)
minimalOutlined text4 %8White

Switching the preset later is one setProps on the captions node (style, words per line, size, colours).

SRT and VTT export. GET /v1/studio/creatives/{id}/captions.srt and GET …/captions.vtt download the captions on the document timeline (text/plain SubRip or text/vtt WebVTT, content-disposition: attachment), line by line exactly as the video shows them. node_id picks one captions node (default: all of them, merged in time) and seq a version (default: the head). No captions answers 404 No captions.. Exporting is free.

Errors. 400 unsupported_kind (not a video creative), 400 unsupported_media (audio over 25 MB), 404 Clip not found., 404 Asset not found., 403 residency_unavailable (EU data residency: transcription is not available), 503 transcription_unavailable.

Price. A fresh transcription costs about $0.006 per minute of clip audio (rounded up to the minute, usage row codai::captions, the same rate as /v1/audio transcription). Cached words, script correction, presets and SRT/VTT export are free.

Platforms

A platform preset is an output size plus the part of the frame the platform's own interface covers (caption bar, action buttons, progress bar). POST /v1/studio/creatives/{id}/adapt adapts a video or image creative to one or more platforms in one version:

curl https://ai.codai.ro/v1/studio/creatives/$ID/adapt \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "platforms": ["tiktok", "feed-portrait", "youtube"] }'

The safe zone is the share of each side kept free of text; the minimum text size is in output pixels:

PlatformLabelSizeSafe zone (top / right / bottom / left)Min. text
tiktokTikTok1080×19208 % / 13 % / 20 % / 5 %36 px
reelsInstagram Reels1080×19207 % / 10 % / 18 % / 5 %36 px
shortsYouTube Shorts1080×19207 % / 12 % / 17 % / 5 %36 px
feed-squareFeed 1:11080×10805 % on every side32 px
feed-portraitFeed 4:51080×13505 % on every side32 px
youtubeYouTube 16:91920×10805 % on every side28 px

The zones are deliberately conservative approximations of the platforms' published guides, which change often.

FieldMeaning
platforms1–6 distinct platform ids (required)
focalnone (default) or vision — see below
base_seq / forceAs for /ops (default: the head); force also adapts locked nodes

The gateway sends one adaptFormat op per platform through the same edit path as /ops. For each platform:

  1. A format of the same size is reused; otherwise one is added with the preset's aspect id (9x16, 1x1, 4x5, 16x9, suffixed -2, -3… when that id belongs to a format of another size) and recorded in meta.platforms[<format id>].
  2. Full-bleed images and videos stay full-bleed; text, captions and groups are moved and clamped into the platform's safe zone (shrunk to the safe width when wider). The default format is changed on the node itself, every other format through per-format overrides.
  3. Text below the platform's minimum gets a larger size in that format only.
  4. Locked nodes are skipped with a warning (unless force: true).

The same op works in any /ops batch:

{ "op": "adaptFormat", "platform": "reels", "focal": { "hero": { "x": 0.62, "y": 0.4 } } }

focal (optional) sets the point of interest (0–1 on each axis) of image and video nodes in that format, so the crop keeps it in frame. Without it the reframe is deterministic and centred.

Smart focal (vision). "focal": "vision" is an opt-in, billed option: one vision turn looks at the top-level full-bleed images (ready PNG, JPEG or WebP assets of yours, up to 5 MB, at most 6) and returns a focal point for each, which every new format then uses. It runs once per call, for all platforms, with a 60 s limit; when it fails the adaptation still happens with centred crops and the warning vision focal unavailable.

It answers 200 with the /ops body plus { "formats": [{ "platform", "format_id", "width", "height" }], "focal": { "<node id>": { "x", "y" } } | null }. Errors: 400 unsupported_kind (only video and image creatives), 400 invalid_request.

Renders in 4:5. Renders and exact frames accept the feed-portrait format (1080×1350) next to 1920×1080, 1080×1920 and 1080×1080, so a creative adapted to a 4:5 feed renders directly.

Price. Adapting is free. Only focal: "vision" is token-metered like any kodwen vision call (usage row codai::adapt-focal, prompt caching on — typically well under $0.01 per call).

Lint and fixes

POST /v1/studio/creatives/{id}/lint runs static checks without rendering. Errors block a render; warnings are shown to you and to agents. Each issue is { "code", "severity", "message", "target", "value"?, "format"?, "fixable"? } — format names the format a format-specific issue applies to, fixable: true marks issues the free automatic fix can solve — and the response adds fixable, the number of such issues.

CodeSeverityFixableWhat it means
low_contrasterrornoText contrast below WCAG (4.5:1, or 3:1 for large text)
outside_safe_areawarningnoText or captions outside the safe area (the platform zone when the default format is bound to a platform)
empty_textwarningnoA text node without text
captions_no_wordserrornoA captions node without words
captions_no_clipwarningnoCaptions that reference a missing audio clip
missing_asseterrornoAn asset id that is not registered in the document
scene_zero_durationerror / warningnoA scene of 0 ms (error) or a node that is never visible (warning)
too_longerrornoTotal duration over 180 s
chart_no_svgwarningnoA chart without a pre-rendered SVG
unresolved_refwarningnoA reference to an unknown variable or token
empty_listwarningnoA list without item text
off_brand_colorwarningyesA literal colour outside the brand palette (neutral greys are never flagged)
outside_platform_safe_zonewarningyesText under a platform's interface in a format bound to that platform
text_too_smallwarningyesText below the legibility floor of a format (value = px)
missing_altwarningnoAn image in a deck or doc without alt text
missing_captionswarningnoA voice clip without a captions node — use Captions
reading_speedwarningnoA caption line faster than 20 characters per second (value = characters per second)
flashingerror / warningyes / noAn animation that flashes more than 3 times per second (error, fixable) or 4+ consecutive scenes shorter than 334 ms (warning)
motion_off_framewarningnoA text, caption or list whose content rests partly outside the frame, or ends a keyframed move cut by the frame edge (value = px outside)
motion_overlapwarningnoTwo texts of one scene on screen together for more than 0.5 s whose resting boxes overlap by more than 15 % of the smaller one (value = percent)
text_reading_timewarningnoA text or list of 3+ words readable for less than 0.5 s + 1 s per 4 words (value = ms shown)
motion_repeatwarningnoThe main text enters with the same preset in 4+ scenes in a row, or the background picture uses the same camera move in 2+ scenes in a row (value = run length)
narration_overrunwarningnoA voice clip that ends more than 50 ms after the end of the scene it starts in (value = ms of overrun)

The legibility floor is the platform minimum (see Platforms), otherwise 2.5 % of the format's short side.

Automatic fixes. POST /v1/studio/creatives/{id}/lint/fix turns fixable issues into one op batch applied like /ops:

curl https://ai.codai.ro/v1/studio/creatives/$ID/lint/fix \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "codes": ["off_brand_color", "text_too_small"] }'
FieldMeaning
codesOnly fix these codes (default: every fixable code)
agenttrue adds one paid agent pass for the remaining issues (default false)
base_seq / forceAs for /ops (default: the head); force also fixes locked nodes

The deterministic fixes are free: off-brand colours become a { "$token" } of the nearest brand colour, small text gets a larger size (or an adaptFormat in platform formats), text under a platform interface is re-adapted with adaptFormat, and flashing keyframe animations are removed. When nothing is fixable it answers 200 without writing a version (changed: []).

With agent: true, after the deterministic batch one bounded server agent turn receives the remaining issues and the outline and proposes ops (alt text, contrast, reading speed…), applied through the same edit path with one repair round. When the agent fails you still get the deterministic result and the warning agent fix unavailable.

It answers 200 with the /ops body plus { "fixed": [{ "code", "target" }], "remaining": [ … ], "agent": { "ops", "cost_micro_usd" } | null } — remaining is the lint result after the fix.

Price. Lint and the deterministic fixes are free. agent: true is token-metered like any kodwen text call (usage row codai::lint-fix, prompt caching on — typically well under $0.01).

Tweak panel

The editors on the hub, desktop and phone show a tweak panel for the document variables (vars): a colour picker, a slider, a toggle, a choice list, a text field or an image picker per variable. Variables keep their type and value in vars; meta.controls[<var key>] only describes how each one is edited.

{ "op": "setControl", "key": "accent", "control": { "kind": "select", "label": "Accent", "group": "Colours", "options": [{ "value": "#e4002b", "label": "Red" }, { "value": "#1d3557", "label": "Navy" }] } }
KindFits variables of typeExtra fields
texttext, numbermultiline
colorcolor—
slidernumbermin and max (required, min < max), step
toggleboolean—
selecttext, number, coloroptions (required, 1–24 { "value", "label"? })
imageimage—

Every control also takes label (up to 100 characters) and group (up to 60 characters, groups the panel). setControl with "control": null removes the control. Like any rejected op, a control that does not fit the variable type answers 400 with code invalid and an unknown variable 400 with code not_found.

GET /v1/studio/creatives/{id}/controls returns the panel model { "head_seq", "controls": [{ "key", "label", "control", "value", "type", "locked" }] }: one entry per variable, with its stored control when it is valid, otherwise one derived from the type (colour → color, boolean → toggle, image → image, number → slider around the current value, text → text). Changing a value is an ordinary setVar op on POST …/ops; editing a control is setControl. Reading and editing controls is free.

SDKs and agent tools (captions, platforms, lint, controls)

const cap = await codai.creatives.captions(c.id, { preset: 'bold-pill' });
const srt = await codai.creatives.captionsSrt(c.id, { node_id: cap.node_id });
const adapted = await codai.creatives.adapt(c.id, { platforms: ['tiktok', 'feed-portrait'], focal: 'vision' });
const fixed = await codai.creatives.lintFix(c.id, { agent: true });
const { controls } = await codai.creatives.controls(c.id);
cap = client.creatives.captions(c["id"], preset="bold-pill")
vtt = client.creatives.captions_vtt(c["id"])
adapted = client.creatives.adapt(c["id"], ["tiktok", "feed-portrait"], focal="vision")
fixed = client.creatives.lint_fix(c["id"], agent=True)
controls = client.creatives.controls(c["id"])["controls"]

Agents on desktop, phone and VS Code use creative_captions (creative_id, clip_id, preset, script, language), creative_adapt (creative_id, platforms, focal) and creative_lint_fix (creative_id, codes, agent); creative_lint lists the new codes and creative_edit accepts the adaptFormat and setControl ops.

Localisation

One creative can carry several languages. The source document stays as it is; each extra language is a set of overrides stored in the document under meta.localization, and every read that takes a locale (preview, render, still frames, captions, lint) applies them on the fly. When a language is ready to live on its own, fork it into a standalone creative.

The overrides model

{
  "meta": {
    "localization": {
      "source": "ro",
      "glossary": ["Kodwen", "Black Friday"],
      "locales": {
        "en": {
          "tone": "informal",
          "texts": { "title:spans.0": { "t": "Autumn sale", "h": "3fa2c9d1e0b4a7f6", "by": "agent" } },
          "audio": { "voice-1": "<speech asset id>" },
          "captions": { "captions": [{ "text": "Autumn", "startMs": 0, "endMs": 420 }] }
        }
      }
    }
  }
}
  • source is the language of the document itself (default: the document locale, otherwise en). Locales are BCP-47 tags (ro, en, de-DE, pt-BR), normalised on input (ro_ro → ro-RO).
  • texts maps a translation unit key to its translation t, the hash h of the source text it was made from and by (agent or user). When the source text changes later, the hash no longer matches and the translation is stale — it is still used until you refresh it.
  • audio swaps a voice clip's asset for that language and captions replaces a captions node's words (see Voice and captions).

Translation units. Every literal text in the document is one unit, addressed by a stable key:

Unit kindKeyWhere
text<node id>:spans.<i>A text span
list<node id>:items.<i>.spans.<j>A list item span
table, cell<node id>:columns.<c>, <node id>:rows.<r>.<c>, <node id>:captionTable headers, text cells and caption (sheet grids: headers and text cells; formulas, dates and numbers are kept)
chart<node id>:spec.title, spec.alt, spec.categories.<i>, spec.series.<i>.nameChart labels
alt, caption<node id>:alt, <node id>:captionImage alt text and caption
html, param<node id>:html.<i>, <node id>:params.<key>Text runs of an HTML block (tags untouched) and its text parameters
captions<node id>:wordsCaption words, joined by spaces
notesscene:<scene id>:notesSpeaker notes
titledoc:titleThe creative title
varvar:<key>A text variable
narrationclip:<clip id>The narration text of a voice clip (used only with voice: true)

Empty texts, pure numbers and { "$var" } spans are skipped (the variable is its own unit); a document has at most 2000 units. Text units carry a length budget (max_chars) computed from the box size and font size in the default format, so translations that would overflow are flagged.

Read the languages

GET /v1/studio/creatives/{id}/locales returns the status of every language for free:

{
  "head_seq": 7,
  "source": "ro",
  "glossary": ["Kodwen"],
  "total": 18,
  "locales": [{ "locale": "en", "tone": "informal", "translated": 17, "missing": 1, "stale": 2, "voice": 0 }]
}

With ?locale=en&units=1 it adds units (offset, limit up to 500, default 200) and units_total: one entry per unit with key, node_id, kind, the source text, max_chars, the current translation (or null), by, stale and locked. An unknown locale answers 404 Locale not found..

Translate with the model

POST /v1/studio/creatives/{id}/localize translates the creative into up to 10 languages in one call and saves everything as one new version (author agent, intent localize: en, de):

curl https://ai.codai.ro/v1/studio/creatives/$ID/localize \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "locales": ["en", "de"], "tone": "informal", "glossary": ["Kodwen", "Black Friday"] }'
FieldMeaning
locales1–10 distinct target languages, never the source language (required)
toneformal (polite address, e.g. "Sie", "dumneavoastră") or informal ("du", "tu"); stored per language
glossaryUp to 100 terms (each up to 100 characters) kept exactly as written; saved on the document
only_missingtrue (default) translates only units without a fresh translation; false translates everything again
voicetrue (video only) also re-records the narration and re-times the captions per language — see below
base_seq / forceAs for /ops (default: the head); force also translates locked nodes

The rules the model follows: placeholders, numbers, URLs and emoji are kept; glossary terms and the brand name and tagline of the creative's brand kit are copied verbatim; the tone is respected; each translation stays within the unit's max_chars (shorter is fine). The units travel in chunks (up to 300 units / 30 000 characters per model turn, three languages in parallel); unknown keys and empty answers are dropped and a missing answer gets one repair round.

It answers 200 with the /ops body plus { "locales": [{ "locale", "translated", "remaining", "voice_clips" }], "cost_micro_usd" }. A language that could not be translated adds the warning translation unavailable for <locale>; when every language failed the answer is 503 localize_unavailable. Errors: 400 invalid (a bad locale or the source language), 400 nothing_to_translate (every unit is already translated and fresh).

Price. Translation is token-metered like any kodwen text call (usage rows codai::localize, prompt caching on — a typical creative costs well under $0.01 per language). Reading languages, manual edits and forks are free.

Voice and captions

With voice: true, for every voice clip that is your own Studio narration (a ready speech asset that kept its source text), the narration is translated together with the texts, re-recorded with the same voice settings in the target language and swapped in for that language only (audio). When a captions node shows that clip, the new speech is transcribed and the translated captions are re-timed (captions). Narration is billed like any narration and transcription like Captions (usage row codai::captions). A clip that cannot be voiced adds the warning voice unavailable for <clip> (<locale>); the texts are still translated.

Edit translations by hand

Manual edits are ordinary ops on POST …/ops:

{ "op": "setLocaleText", "locale": "en", "texts": { "title:spans.0": "Autumn sale", "doc:title": null } }
  • setLocaleText sets up to 500 units of one language (null deletes a translation, which then counts as missing); the stored hash is that of the current source text, so the unit is fresh again. by defaults to user. An unknown key is not_found, a unit of a locked node locked (unless force), the source language invalid.
  • setLocale sets a language's tone, audio (clip id → asset id, the asset must be registered with setAsset) and captions (captions node id → words); "remove": true deletes the language.
  • setLocalization sets the document source language, the glossary and the forkOf link; "reset": true removes every language.

Checks

POST …/lint adds three warnings for translated creatives, each with locale:

  • missing_translation — one per language, value = the number of units without a translation.
  • stale_translation — a unit whose source text changed after it was translated (up to 50 per language; the message names the key).
  • text_overflow_locale — fixable: a translation longer than the text box allows in the default format. POST …/lint/fix gives that node autoFit: "shrink", so the text shrinks to fit in every language.

Send { "locale": "en" } to POST …/lint to lint the translated document itself (contrast, safe area… in English).

Preview, render and export a language

The locale parameter works on GET …/compiled (query), POST …/render (body; the render records the language), POST …/frame (body), GET …/captions.srt and GET …/captions.vtt (query) and POST …/lint (body). The source language (or no locale) gives the document as it is; an unknown locale answers 404 Locale not found..

curl https://ai.codai.ro/v1/studio/creatives/$ID/render \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "format": "9x16", "quality": "final", "locale": "en" }'

Renders of a language are billed like any render.

Fork a language

POST /v1/studio/creatives/{id}/locales/{locale}/fork with { "title"?, "seq"? } creates a standalone creative in that language from the version seq (default: the head): the translations are written into the document itself, the overrides are dropped, source becomes that language and forkOf records { creativeId, seq, locale }. The title defaults to "<title> (<locale>)". It answers 201 with the new creative plus { "locale", "fork_of": { "creative_id", "seq" } }. Free.

SDKs and agent tools (localisation)

const status = await codai.creatives.locales(c.id);
const units = await codai.creatives.locales(c.id, { locale: 'en', units: true });
const done = await codai.creatives.localize(c.id, { locales: ['en', 'de'], tone: 'informal', glossary: ['Kodwen'] });
const html = await codai.creatives.compiled(c.id, { locale: 'en' });
const job = await codai.creatives.render(c.id, { quality: 'draft', locale: 'en' });
const fork = await codai.creatives.forkLocale(c.id, 'en', { title: 'Launch teaser (EN)' });
status = client.creatives.locales(c["id"])
done = client.creatives.localize(c["id"], ["en", "de"], tone="informal", glossary=["Kodwen"])
issues = client.creatives.lint(c["id"], locale="en")
fork = client.creatives.fork_locale(c["id"], "en", title="Launch teaser (EN)")

Agents on desktop, phone and VS Code use creative_localize (creative_id, locales, tone, glossary, voice, only_missing) and creative_locale_fork (creative_id, locale, title); creative_lint takes locale and lists the translation codes, and creative_edit accepts the setLocaleText, setLocale and setLocalization ops. To fill one creative per row of a spreadsheet — optionally in a different language per row — see Batches.

Templates

Save any of your creatives as a private, reusable template: pick the text and image nodes that change from one use to the next, and each becomes a template variable. POST /v1/studio/creatives/{id}/template takes the creative at seq (default: the head):

curl https://ai.codai.ro/v1/studio/creatives/$CREATIVE_ID/template \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "title": "Promo 9:16", "vars": [
        { "node_id": "n_title", "prop": "text", "key": "headline", "label": "Headline" },
        { "node_id": "n_photo", "prop": "image", "key": "photo" } ] }'
  • prop: "text" on a text node: its spans collapse to one span that keeps the first span's style; the current text becomes the default.
  • prop: "image" on an image node with a literal asset: the current asset id becomes the default (the asset stays in the document).
  • key is a letter followed by up to 63 letters, digits or _, unique in the template; label defaults to the node name. 1–50 vars.

It answers 201 with the template: { "id", "object": "studio.template", "title", "kind", "source_creative_id", "source_seq", "vars_schema": { "headline": { "type": "text", "label", "default", "node_id", "prop" } }, "thumbnail_url", "created_at", "doc" }. The template keeps its own copy of the document; later edits of the creative do not change it. Errors: 400 node_not_found, 400 invalid_pick (wrong node type, already a variable, or an image without a literal asset), 400 duplicate_key, 404 (unknown creative or seq).

GET /v1/studio/templates?kind=video lists your templates (newest first, without doc), GET …/{id} reads one with its document, DELETE …/{id} deletes it (creatives made from it are not affected).

Create from a template — POST /v1/studio/creatives with template and the values in vars (by key; keys you leave out keep their default):

curl https://ai.codai.ro/v1/studio/creatives \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "video", "template": "<template id>", "vars": { "headline": "Autumn sale", "photo": "<image asset id>" } }'

Version 1 is the template document with the values applied; title defaults to the template title and brand_kit_id still works (applied after the vars). A text value takes up to 5000 characters, an image value must be the id of one of your image assets. Errors: 400 invalid_request (template together with doc, or vars without template), 400 kind_mismatch, 400 unknown_var, 400 invalid_var_value, 404 (unknown template; an image asset that is not yours is 404 asset_not_found).

const tpl = await codai.creatives.saveTemplate(c.id, {
  title: 'Promo 9:16',
  vars: [{ node_id: 'n_title', prop: 'text', key: 'headline', label: 'Headline' }],
});
const { data } = await codai.templates.list({ kind: 'video' });
const fromTpl = await codai.creatives.create({ kind: 'video', template: tpl.id, vars: { headline: 'Autumn sale' } });
await codai.templates.delete(tpl.id);
tpl = client.creatives.save_template(
    c["id"], vars=[{"node_id": "n_title", "prop": "text", "key": "headline", "label": "Headline"}], title="Promo 9:16"
)
templates = client.templates.list(kind="video")["data"]
from_tpl = client.creatives.create({"kind": "video", "template": tpl["id"], "vars": {"headline": "Autumn sale"}})
client.templates.delete(tpl["id"])

Price. Saving, listing, reading, deleting and using templates is free.

Export to FCPXML

Hand a video creative over to a desktop editor: GET /v1/studio/creatives/{id}/export.fcpxml queues a zip with an FCPXML 1.10 project and every file it points to:

  • project.fcpxml — one sequence at the document fps (24, 25 or 30) and size; one clip per scene in playback order, named after the scene, with a marker carrying the scene id; voice, music and sound effects as connected audio clips on their own lanes (voice -1, music -2, sfx -3), with their gain.
  • media/seg-NNNN.mp4 — the per-scene video segments, rendered at final quality, without audio.
  • media/audio-* — the voice, music and sound-effect files the timeline uses.
  • project.xml — the same timeline as Final Cut Pro 7 XML (xmeml v5) for Adobe Premiere Pro: one video track with the scene pieces and their markers, audio tracks per role (dialogue, music, effects) with their gain.
  • project.edl — the video cut list as a CMX 3600 EDL (non-drop timecode from 00:00:00:00, one event per scene piece) for any editor; audio stays in the XML / FCPXML.
  • captions.srt — the creative's captions as SubRip on the same timeline, only when it has captions.
  • credits.txt — stock media credits, only when stock media is used.

Unzip it and keep the folder together (media paths are relative). Open project.fcpxml in Final Cut Pro (File → Import → XML) or DaVinci Resolve (File → Import → Timeline); in Premiere Pro use File → Import and pick project.xml; any other editor imports project.edl, then add captions.srt as a captions track. CapCut desktop can import the media files. The StudioExport answer lists the project files in files.

curl "https://ai.codai.ro/v1/studio/creatives/$ID/export.fcpxml?format=16x9" \
  -H "Authorization: Bearer $CODAI_API_KEY"
# 202 { "id": "<export id>", "object": "studio.export", "status": "queued", ... }

curl https://ai.codai.ro/v1/studio/exports/$EXPORT_ID \
  -H "Authorization: Bearer $CODAI_API_KEY"

format defaults to the first document format of 1920×1080, 1080×1920 or 1080×1080 and seq to the head. The 202 body is a StudioExport: { "id", "object": "studio.export", "status", "creative_id", "seq", "format", "fps", "filename", "url", "expires_at", "bytes", "rendered_seconds", "cost_micro_usd", "warnings", "error", "created_at" }. Poll GET /v1/studio/exports/{id} (always 200 while the export is yours) until status is succeeded — url is then a signed download link valid for 1 hour (each poll signs a new one) — or failed with error.code and error.message. Audio assets that are missing or not ready are skipped and listed in warnings. Errors: 400 unsupported_kind (not a video creative), 400 unsupported_format, 400 too_long (over 180 s), 404 (unknown creative or seq), 429 too_many_renders (exports count with renders against the 3 in-flight jobs).

const queued = await codai.creatives.exportFcpxml(c.id, { format: '16x9' });
const done = await codai.exports.wait(queued.id); // polls every 3 s, up to 15 min
if (done.status === 'succeeded') console.log(done.filename, done.url, done.expires_at, done.cost_micro_usd);
else console.error(done.error?.code, done.error?.message);
queued = client.creatives.export_fcpxml(c["id"], format="16x9")
done = client.exports.wait(queued["id"])  # polls every 3 s, up to 15 min
if done["status"] == "succeeded":
    print(done["filename"], done["url"], done["expires_at"], done["cost_micro_usd"])

Price. The segments are the same ones final renders use: scenes already rendered (by an earlier export or a final render) are reused for free. Only newly rendered seconds are billed, at the final render price, once, when the export has succeeded (rendered_seconds, cost_micro_usd); an export made only of cached segments costs nothing. Polling is free.

Export to Lottie

Use a video creative on the web or in an app as a lightweight vector animation: GET /v1/studio/creatives/{id}/export.lottie exports it as Lottie, synchronously and for free — no render, no queue.

  • container=json (default) — one Bodymovin JSON with the images embedded as data URLs. Plays in lottie-web, the Lottie players for Android and iOS, and After Effects importers.
  • container=dotlottie — a dotLottie v2 archive (.lottie: manifest.json, a/<format>.json, i/<asset>.<ext>), smaller, for dotLottie players.
  • formats=all — every format of the document (dotLottie: one animation per format; JSON: a zip of JSON files). Without it, format picks one (default: the document's default format); sending both is a 400 invalid_request.
  • seq — the version (default the head).
# the file itself, straight to disk
curl -OJ "https://ai.codai.ro/v1/studio/creatives/$ID/export.lottie?format=16x9&download=1" \
  -H "Authorization: Bearer $CODAI_API_KEY"

# every format in one dotLottie archive, as an export object with a signed URL
curl "https://ai.codai.ro/v1/studio/creatives/$ID/export.lottie?container=dotlottie&formats=all" \
  -H "Authorization: Bearer $CODAI_API_KEY"
# 200 { "id": "<export id>", "object": "studio.export", "kind": "lottie", "container": "dotlottie",
#       "status": "succeeded", "format": "all", "url": "https://…", "cost_micro_usd": "0", "warnings": [...] }

Without download, the answer is a StudioExport (the same object as the FCPXML export) that is already succeeded: url is a signed download link valid for 1 hour, rendered_seconds is 0 and cost_micro_usd 0, and GET /v1/studio/exports/{id} reads it again. With download=1 the answer is the file itself (application/json or application/zip, content-disposition: attachment) and nothing is stored. File names: <slug>-<format>.lottie.json (JSON), <slug>-<format|all>.lottie (dotLottie), <slug>-lottie.zip (JSON, all formats).

Fidelity. Lottie is a vector format, so not everything a video can show fits in it. warnings lists what was approximated or left out:

Exportedshapes (rectangle, ellipse, line), images (cover / contain, focal point, crop, radius), text (system fonts), lists (as text), captions, groups, backgrounds (solid, linear gradient, image), every animation preset and keyframe animation, scene transitions, one marker per scene
Approximatedword-by-word stagger, highlight sweep, captions styling, mixed text styles inside one text node, group opacity animation
Not exportedvideo, chart, HTML block, table, cell grid and audio nodes; images beyond the 8 MB embed budget; the colour grade of an image (warning grade_not_exported)

Provenance. Every file says it was made with AI: the JSON meta holds g: "Kodwen Studio", tc: "AI-generated", ai_generated: true and custom: { ai_generated: true, generator: "codai-studio", doc_hash }, and every 2xx answer carries the x-codai-ai-generated: true and x-codai-generator: codai/codai-studio headers. Errors: 400 unsupported_kind (not a video creative), 400 invalid_request, 404 (unknown creative or seq), 413 too_large (the file would exceed 20 MB).

const e = await codai.creatives.exportLottie(c.id, { container: 'dotlottie', formats: 'all' });
console.log(e.filename, e.url, e.warnings);
const file = await codai.creatives.downloadLottie(c.id, { format: '16x9' }); // { bytes, filename, contentType }
e = client.creatives.export_lottie(c["id"], container="dotlottie", formats="all")
print(e["filename"], e["url"], e["warnings"])
f = client.creatives.download_lottie(c["id"], format="16x9")  # {"bytes", "filename", "content_type"}

Price. Free: no render, no usage row, no spend cap.

Documents (deck, doc, sheet)

Studio documents — decks (PPTX), docs (DOCX) and sheets (XLSX) — can be edited as creatives too. The creative holds the same content as the document spec, as typed nodes you can edit with ops, preview live and build back into a file.

Representation.

  • Deck — one scene per slide (s1, s2, …) with the slide layout (title, section, bullets, two_column, image, chart, table, quote, stats, timeline, closing) and the speaker notes on the scene. Nodes are laid out on the 16:9 (1920×1080) or 4:3 (1440×1080) format with ids such as s3-title, s3-bullets, s3-left-heading or s3-stat2-value. Bullets are a list node, tables a table node, charts a chart node, two columns two groups, stats and timeline items one group each.
  • Doc — one scene per page (p1, p2, …; a new page wherever the spec had a page_break). Each page holds one group with role: "flow" whose children are the blocks in order (b0, b1, …): headings, paragraphs, lists, tables, images, charts and quotes.
  • Sheet — one scene per sheet (sh1, …, named like the sheet) with a cell_grid node (columns, rows, freezeHeader, autofilter, totals, conditional) plus one chart node per chart with its anchor cell.

Every node carries a semantic role: title, subtitle, heading1–heading4, paragraph, quote, attribution, caption, contact, footer, flow, column-left, column-right, column-heading, stat, stat-value, stat-label, stat-delta, timeline-item, timeline-date, timeline-label and timeline-detail. The build reads only roles, node types and scene layouts — never ids — so you can rename, move and restyle nodes freely. Text spans may be code, carry a link (https) or a footnote (DOCX); images take alt and caption; paragraphs can be justify-aligned. Colours and fonts reference the theme tokens (color.bg, color.fg, color.accent, font.heading, font.body) and meta.document keeps the document settings (theme, language, aspect, footer, page size, orientation, header, page numbers, table of contents).

From a document. POST /v1/studio/documents/{id}/creative (no body) converts one of your documents. It is idempotent: the first call creates the creative (201, existing: false) and links it to the document (the document then reports creative_id); later calls return the same creative (200, existing: true). The body is the usual creative plus document_id and existing. Errors: 404 (unknown document), 409 document_spec_missing (no stored spec), 400 invalid_spec, 413 too_large. Free.

curl -X POST https://ai.codai.ro/v1/studio/documents/$DOC_ID/creative \
  -H "Authorization: Bearer $CODAI_API_KEY"
# 201 { "id": "<creative id>", "object": "studio.creative", "kind": "deck", "head_seq": 1, ..., "document_id": "…", "existing": false }

Edit and preview. Use outline, find, subtree and ops as for any creative. GET …/compiled now previews deck, doc and sheet creatives live: slides, pages or sheets stacked vertically in one HTML page (duration_ms: 0, kind in the response), with every scene and node addressable by id, so a click in the preview selects the node.

Build. POST /v1/studio/creatives/{id}/build with { "seq"?, "preview"?, "filename"? } converts the creative (head by default) back into a documents spec and builds the PPTX, DOCX or XLSX through the same path as POST /v1/studio/documents. Each build is a new version of the same document (based_on = the newest build, so version increments) and records creative_id and creative_seq. Nodes a document cannot hold (free shapes, HTML blocks, videos, extra texts) are skipped and listed in warnings; a scene added without a layout gets one inferred from its nodes. Errors: 400 not_supported_for_kind (video or image creatives), 400 invalid_spec (the converted spec fails validation), and every build error of POST /v1/studio/documents.

curl https://ai.codai.ro/v1/studio/creatives/$ID/build \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "preview": true }'
# 200 { "object": "studio.creative_build", "creative_id": "…", "seq": 4, "warnings": [], "document": { "version": 2, "file": { "url": "…" }, ... } }
const c = await codai.creatives.fromDocument(docId); // c.existing tells whether it was already linked
const { ids } = await codai.creatives.find(c.id, { type: 'list' }); // the bullet lists
await codai.creatives.edit(c.id, {
  baseSeq: c.head_seq,
  ops: [{ op: 'updateScene', target: 's2', patch: { notes: 'Mention the Q3 margin first.' } }],
});
const built = await codai.creatives.build(c.id, { preview: true });
console.log(built.document.version, built.document.file.url, built.warnings);
c = client.creatives.from_document(doc_id)
client.creatives.edit(
    c["id"],
    base_seq=c["head_seq"],
    ops=[{"op": "updateScene", "target": "s2", "patch": {"notes": "Mention the Q3 margin first."}}],
)
built = client.creatives.build(c["id"], preview=True)
print(built["document"]["version"], built["document"]["file"]["url"], built["warnings"])

Price. Converting, editing, previewing (compiled) and building are free. Only preview: true costs money: the document preview is billed like POST /v1/studio/documents/{id}/previews ($0.002 per page, min $0.01).

Productions (brief → finished output)

POST /v1/studio/productions turns one brief into a finished output, server-side. kind picks what is built (default video): one bounded plan turn (codai::production-storyboard), the images or music it needs under one wallet hold, an editable creative, then the output from the regular builders. Poll GET /v1/studio/productions/{id} until status is succeeded; non-video productions return kind, output_asset_id and outputs (Studio assets with a role, url signed for 1 h). starter, voice, narration, quality, music, aspect_ratio and duration_s apply to video only (duration_s, 15–60, is the target length in seconds: the storyboard and voice-over are planned for it and the scenes stretch to reach it; without it a video runs about 15–30 s); audio uses Lyria 3 Clip and is not available on EU data-residency keys. The budget (default $3, max $20) caps the whole production.

kindWhat the plan containsOutput (outputs[].role → format)C2PA
videoStoryboard on a starter (copy, one image, one narration line, a camera move and a transition per scene)MP4 render (render_id) at the requested quality, vision review + one repairYes
imageHeadline, subline, image prompt, format (1:1, 4:5 or 9:16)image → PNGYes
deckTitle + 4–8 slides (bullets, optional image), 16:9document → PPTXNo (document properties mark)
docTitle + 4–8 sections (paragraph, bullets, optional image), A4document → DOCXNo (document properties mark)
sheetTitle + columns + 5–20 rows, one worksheetdocument → XLSXNo (document properties mark)
carousel4–8 slides (headline, body, optional image), 4:5pages → ZIP of PNGs, cover → PNGCover yes; ZIP no (XMP in each PNG)
printPoster or flyer: headline, body, image prompt, A4 or A5pdf → PDFNo (PDF Info mark)
audioJingle or song: title, genre, prompt, optional lyrics, ≤ 60 saudio → MP3Yes

Video (kind: "video")

The storyboard turn writes the copy, one image prompt and one narration line per scene. It also chooses, per scene, a camera move for the background image (zoom-in, zoom-out, pan-left, pan-right or static) and the transition into the scene (any scene transition type). Every scene gets its image and its narration line, a music bed goes under the voice-over, the result is saved as an editable creative and one render is queued. When the render is ready, the vision review may fix text once; the repair render uses the same quality.

FieldMeaning
qualityfinal (default): full resolution at 30 fps. draft: 540p at 15 fps, half the render price
musictrue (default): adds an instrumental music bed (codai-music), generated from a music prompt the storyboard writes and placed under the voice-over at a lower level. false: no music

The music bed is skipped, with an entry in warnings, where music is not available (EU data-residency gateways; keys not allowed codai-music) or when its generation fails; the production still succeeds. It is billed at the usual codai-music price inside the production budget. The answer of a video production carries quality and music (true when a bed was actually added).

curl https://ai.codai.ro/v1/studio/productions \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Idempotency-Key: promo-001" \
  -H "Content-Type: application/json" \
  -d '{"brief":"A 20 s promo for our autumn sale: 30% off all coffee beans, order online","language":"en",
       "aspect_ratio":"9:16","quality":"final","music":true}'
# 202 { "id": "…", "object": "studio.production", "status": "running", "phase": "render",
#       "quality": "final", "music": true, "warnings": [], ... }

SDKs: productions.create({ brief, quality: 'draft', music: false }) (Python productions.create(brief, quality="draft", music=False)); agent tool creative_produce (quality, music).

Review before the final render (review)

review: true adds a stop before the final render. The production writes the storyboard, makes the images, the voice-over and the music, renders a draft preview and then waits in phase review (status stays running). Without review nothing changes: the production renders straight away, as before.

curl https://ai.codai.ro/v1/studio/productions \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Idempotency-Key: promo-002" \
  -H "Content-Type: application/json" \
  -d '{"brief":"A 20 s promo for our autumn sale: 30% off all coffee beans, order online","language":"en",
       "aspect_ratio":"9:16","review":true}'
# 202 { "id": "…", "status": "running", "phase": "assets",
#       "review": { "state": "preparing", "animatic_render_id": null, "stills": [], "since": null, "expires_at": null }, ... }

Poll GET /v1/studio/productions/{id} until phase is review. The review object then holds what there is to check:

FieldMeaning
statepreparing while the pictures and the preview are being made, waiting at the gate, then approved, cancelled or expired
animatic_render_idThe draft preview, a normal render: read it with GET /v1/studio/renders/{id} for the MP4 and the poster. null when the preview failed; the gate still opens with the stills
stillsOne entry per scene: scene, asset_id (the generated image, null when it failed) and narration (the voice-over line)
since, expires_atWhen the gate opened and when it expires (7 days later)

While the production waits you can edit its creative (creative_id) like any other; the edits are rendered when you approve. Then answer the gate:

curl https://ai.codai.ro/v1/studio/productions/<id>/review \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" \
  -d '{"decision":"approve"}'
# 200 { "id": "…", "status": "running", "phase": "render", "review": { "state": "approved", ... }, ... }
  • approve starts the final render at the requested quality, billed like every render. The review after the render runs as usual.
  • cancel ends the production with status: "failed" and error: "cancelled". The creative stays in your library.
  • A gate with no answer for 7 days ends the same way with error: "gate_expired".
  • 402 insufficient_funds on approve means the wallet cannot cover the final render. The production stays at the gate, so you can top up and approve again.
  • 409 not_in_review means the production is not waiting at the gate. Sending the same decision again after it was applied answers 200.

Costs: nothing is held or billed while a production waits at the gate. The images, the voice-over and the music are billed when they are made, as in every production, and the draft preview is billed as a draft render. render_id and first_render_id never point at the draft preview.

SDKs: productions.create({ brief, review: true }), then productions.review(id, { decision: 'approve' }) (Python productions.create(brief, review=True), then productions.review(id, decision="approve")). productions.wait() and productions.run() return when the production reaches the gate. Agent tool creative_produce: review starts a production with the gate, and production_id together with decision answers it.

Free composition (director)

director chooses who lays the video out. starter (the default) is the behaviour described above: the model fills one of the built-in video starters. With free the model writes the whole composition itself: the shot list, where each text sits on the grid, the type sizes and the entrance of every text, using the same motion design system as the starters. The result is an editable creative like any other. free cannot be combined with starter (a starter id): that is a 400. The answer carries director.

curl https://ai.codai.ro/v1/studio/productions \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" \
  -d '{"brief":"A 20 s promo for our autumn sale: 30% off all coffee beans, order online","language":"en",
       "director":"free"}'
# 202 { "id": "…", "status": "running", "director": "free", "starter": null, ... }

Critique rounds (critique_rounds)

Without this field the review after the render is one vision turn on still frames that may fix text once. critique_rounds: 1 or 2 replaces it with a critique loop of up to that many rounds: the render is sampled (still frames plus early frames that show the motion), a critic scores it against a short list of criteria written for the brief, and its changes (text, layout, timing, animation) are applied and rendered again. Each round can add one render and one model turn to the cost, inside the production budget.

curl https://ai.codai.ro/v1/studio/productions \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" \
  -d '{"brief":"A 20 s promo for our autumn sale: 30% off all coffee beans, order online","language":"en",
       "critique_rounds":2}'
# later, GET /v1/studio/productions/<id>:
# { "status": "succeeded", "phase": "ready",
#   "critique": { "max_rounds": 2, "rounds": [
#     { "round": 1, "verdict": "revise", "score": 62, "issues": ["…"], "ops": 5, "reverted": false },
#     { "round": 2, "verdict": "accept", "score": 81, "issues": [], "ops": 0, "reverted": false } ] }, ... }

score runs from 0 to 100 and ops counts the changes applied in that round. reverted: true means the changes of the previous round made the video worse: they were undone and the earlier render is delivered. qa keeps its usual shape (issues of the last round, fixes as the total number of changes applied). The scores are the critic's opinion of this one render; they do not promise how the video will perform.

All three options apply to kind: "video" only (400 with another kind) and can be combined. SDKs: productions.create({ brief, director: 'free', critique_rounds: 2 }) (Python productions.create(brief, director="free", critique_rounds=2)); agent tool creative_produce (director, critique_rounds).

Cut from uploads (kind: "cut")

kind: "cut" turns your own uploads into an edited video. asset_ids lists 1–10 of your ready video, audio and image assets (at least one video or audio, at most 60 minutes of media in total; images become b-roll and references). The phases are ingest → music → plan → broll → assemble → render: every source is ingested (transcript, shots, beats, faces), a music bed is added, one planner turn picks the spans (deterministic code picks the frames, never inside a word), fillers, long silences and weaker retakes are removed, b-roll comes from your footage, then stock, then generated images (your photos as references, with a Ken Burns move; Veo only with broll.generated: "veo"), captions and animated reframes are added for every format and one draft render of default_format is queued. The result is a new editable creative with the formats 9x16, 1x1 and 16x9 (or the ones you ask for).

curl https://ai.codai.ro/v1/studio/productions \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Idempotency-Key: cut-001" \
  -H "Content-Type: application/json" \
  -d '{"kind":"cut","brief":"A 45 s vlog teaser for Instagram, keep the joke about the coffee","language":"en",
       "asset_ids":["<video asset id>","<photo asset id>"],"target_duration_s":45,"pacing":"punchy",
       "music":{"source":"lyria"},"broll":{"own":true,"stock":true,"generated":"images"}}'
# 202 { "id": "…", "object": "studio.production", "kind": "cut", "status": "running", "phase": "ingest",
#       "ingest": { "total": 1, "done": 0, "failed": 0 }, "ingest_quote_micro_usd": "…", ... }

Poll GET /v1/studio/productions/{id} (a long source can take most of an hour; the sweeper finishes it even when nobody polls). Every automatic decision is logged with its reason: GET /v1/studio/productions/{id}/cut returns decisions (filler, silence, retake, hook, broll, zoom, reframe, music, script_gap; multicam: angle, split, sync), the retake clusters with every take and the chosen one, script_gaps (with a script) and stats. Change any of it for free with a revise — no model turn, nothing billed:

curl https://ai.codai.ro/v1/studio/productions/<id>/cut/revise \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" \
  -d '{"disabled":["filler:f3","+zoom:2"],"takes":{"c1":"s7"},"pacing":"calm","base_seq":2}'
# 200 { "creative_id": "…", "seq": 3, "branch": "main", "decisions": [...], "stats": {...}, "warnings": [] }

A decision id in disabled switches it off, +<id> back on (cumulative across revisions). The new version goes on the cut's branch while nobody edited the creative, else on a new branch cut-<n>, so your own edits are never overwritten. Pricing: the planner turn is token-metered (codai::production-cut-plan, prompt caching on), the Lyria Pro bed, b-roll images and the draft render are billed at their usual prices, all capped by budget_micro_usd (default $3, max $20); ingest of sources not ingested yet is billed on each asset and shown up front as ingest_quote_micro_usd. SDKs: productions.create({ kind: 'cut', … }), productions.cut(id), productions.reviseCut(id, body) (Python productions.create_cut, cut, revise_cut).

Prompt edits

Say what you want in words instead of picking decision ids: prompt on the same revise. Length, energy, captions, b-roll and framing requests are mapped for free without a model turn ("make it 60 s", "fă-l de 30 de secunde", "more energetic" → the Fast social style, "calmer" → Minimal, "no captions", "no b-roll", "square" / "vertical" / "landscape"). Requests about content — "only the pricing part", "start with the question about price", "drop the intro" — cost one model turn (quote $0.15, usually less; plan-funded, else from the wallet, 402 insufficient_funds when short) that answers with the sentences to keep. Nothing is written when the answer is unusable (502 revise_failed).

curl https://ai.codai.ro/v1/studio/productions/<id>/cut/revise \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" \
  -d '{"prompt":"only the part about pricing, about 45 s","base_seq":3}'
# 200 { "creative_id": "…", "seq": 4, "branch": "main", "decisions": [...], "stats": {...}, "warnings": [],
#       "prompt": { "path": "model", "understood": ["target 45 s", "story edit"],
#                   "explanation": "Kept the four sentences about pricing.", "unmet": null, "cost_micro_usd": 61000 } }

The same changes can be sent directly, always free: target_s (15–600; the least important sentences are dropped first — incomplete, weakest take, shortest, latest — never the opening or the conclusion; null goes back to the production target), plan_delta (keep_only, drop, hook, order with sentence ids from GET …/cut; null undoes every story edit), default_format and broll. Agent tool: creative_cut_revise with prompt.

Multi-camera recordings

A podcast or interview filmed with several cameras is one cut: list every camera in asset_ids and in multicam.angles (2–4). The master_audio camera (default the first angle) is the sound and the story — only it is transcribed and planned; the other cameras are picture only: an angle that has not been analysed yet is analysed without speech recognition, so it is cheaper (upload the extra cameras with ingest: false to get this; an angle already analysed is reused as it is). Each angle is synced to the master by sound, for free: first a cross-correlation of the loudness envelopes ingest already stores, then a sample-accurate GCC-PHAT pass on the audio with clock-drift correction (consumer cameras drift 10–100 ppm; a 29.97/30 fps mismatch is ~1000 ppm). The edit then follows simple rules on the master's speaker turns: cut to the speaker's close-up a little before their first word, never a shot shorter than the pacing minimum, the wide shot when people talk over each other, a split screen (left/right in 16:9, top/bottom in 9:16) for a quick back-and-forth, a short cut-away in long monologues. Speakers are matched to close-ups by which camera hears them loudest, the wide angle from the faces in frame.

curl https://ai.codai.ro/v1/studio/productions \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" \
  -d '{"kind":"cut","brief":"60 s clip of the pricing discussion","asset_ids":["<wide>","<cam-ana>","<cam-mihai>"],
       "multicam":{"angles":["<wide>","<cam-ana>","<cam-mihai>"],"master_audio":"<wide>"}}'

Every shot is a decision (angle:<n>, split:<n>; switching one off keeps the previous shot) and every sync a sync:<asset> entry with its method, drift and confidence (below 30 % the cut warns sync_low_confidence:<asset>); GET …/cut also lists the multicam cameras. Pin a shot for free with revise "angles": {"angle:3": "<cam-ana>"} ("wide", "split", or null to unpin). When the automatic sync or mapping is wrong, pass offsets_ms (asset → master ms minus angle ms), speaker_angles (S1 → asset) or wide_angle; "split": false disables split screen. SDKs: multicam on cut create, angles on reviseCut / revise_cut; agent tools creative_produce (multicam) and creative_cut_revise (angles).

Scored variants

POST /v1/studio/productions/<id>/cut/variants proposes alternative edits of a finished cut and scores them. One model turn proposes n variants (default 3, max 5), each a different angle — strongest_hook (open on the strongest standalone claim or question), story (setup, point, conclusion), punchy (the shortest version that still lands), topic (one topic only) — and a second turn scores every variant and your current cut 0–99 on four axes: Hook (do the first seconds make a viewer stay), Flow (one clear thought, no mid-sentence starts, a conclusion), Value (useful for the audience of the brief) and Fit (length, speaking rate and pace for the format). Each axis blends 60 % of the judge with 40 % measured features; the total is 0.35 Hook + 0.25 Flow + 0.25 Value + 0.15 Fit and the list comes back best first, each score with one reason per axis, the weakest axis and a fix_hint. Loudness and controversy are never rewarded. Scores are predictions, not guarantees.

curl https://ai.codai.ro/v1/studio/productions/<id>/cut/variants \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" -d '{"n":3}'
# 200 { "object": "studio.production.cut_variants", "base_seq": 4, "stale": false, "judged": true,
#       "variants": [ { "id": "v2", "angle": "strongest_hook", "title": "Price question first", "output_ms": 42100,
#                       "plan_delta": { "keep_only": ["s7", "s8", "s12"], "hook": "s7" },
#                       "score": { "total": 81, "hook": 90, "flow": 75, "value": 80, "fit": 60, "source": "blend",
#                                  "weakest_axis": "fit", "fix_hint": "make it 35 s", "reasons": { ... } } }, ... ],
#       "current": { "output_ms": 61000, "score": { "total": 58, ... } }, "cost_micro_usd": 41000, "warnings": [] }

Both turns are token-metered (codai::production-cut-variants, codai::production-cut-score, prompt caching on), each quoted and held on its own (plan allowance first, then the wallet): a short wallet before the first turn is a 402 and nothing runs; before the judge the variants keep the free measured score (source: "heuristic", warning judge_insufficient_funds). "judge": false skips the judge. Every variant is a sentence-level plan_delta assembled for free, and the result is stored on the production: GET …/cut/variants reads it again free (404 no_variants before the first call; stale: true once the cut changed). POST …/cut/select {variant, base_seq?} writes a variant as a new version, free, on the same branch rules as a revise; undo with plan_delta: null on revise. To raise the weakest axis send its fix_hint as prompt to …/cut/revise. SDKs: productions.cutVariants(id, { n }), retrieveCutVariants(id), selectCutVariant(id, { variant }) (Python cut_variants, retrieve_cut_variants, select_cut_variant); agent tools creative_cut_variants and creative_cut_select.

Hook A/B

POST /v1/studio/productions/<id>/cut/hooks suggests 2–3 other sentences of the recording that could open the same cut: the body stays as it is, only the cold open changes. The ranking is free and measured — a question, a number or a claim up front, short and close to the brief — and the hook the cut uses now is always in the list with current: true. "judge": true adds one paid model turn (codai::production-cut-hooks, quoted 60,000 µUSD, plan allowance first, then the wallet) that re-ranks them and writes a one-line why; a short wallet keeps the free ranking (warning judge_insufficient_funds). The result is stored: GET …/cut/hooks reads it again for free (404 no_hooks before the first call, stale: true once the cut changed). Scores are predictions, not guarantees.

curl https://ai.codai.ro/v1/studio/productions/<id>/cut/hooks \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" -d '{"n":3,"judge":false}'
# 200 { "object": "studio.production.cut_hooks", "base_seq": 4, "stale": false, "judged": false,
#       "hooks": [ { "id": "h1", "sentence": "s7", "text": "Why pay twice for the same invoice?", "duration_ms": 2100,
#                    "score": 86, "reasons": ["question", "brief"], "why": null, "current": false }, ... ],
#       "cost_micro_usd": 0, "warnings": [] }

Apply a hook with POST …/cut/revise {"hook": "s7"} (free; "hook": null removes the cold open), or render every hook for an A/B test with Render all below.

Length presets

GET /v1/studio/productions/<id>/cut/lengths re-assembles the cut for free at 15, 30, 60 and 90 seconds and max (180 s, the YouTube Shorts / Instagram Reels ceiling). The least important sentences go first; the opening, the hook and the conclusion stay. Each preset has its real output_ms, the sentences kept, a measured score 0–99, fits_material (the material is shorter than the preset), trimmed and over_caps — the platforms whose length cap it exceeds: Shorts and Reels 3:00, X 2:20 (without Premium), TikTok 10:00 (API uploads), LinkedIn 15:00. The same list is computed for the current cut.

curl https://ai.codai.ro/v1/studio/productions/<id>/cut/lengths -H "Authorization: Bearer $CODAI_API_KEY"
# 200 { "object": "studio.production.cut_lengths", "seq": 4,
#       "current": { "output_ms": 201000, "preset": null, "over_caps": ["shorts", "reels", "x"] },
#       "presets": [ { "id": "30", "target_s": 30, "output_ms": 29400, "sentences": 7, "score": 71,
#                      "fits_material": false, "over_caps": [], "trimmed": true }, ... ] }

Apply one with POST …/cut/revise {"target_s": 30} (free; max = 180).

Attention guide (heuristic)

GET /v1/studio/productions/<id>/cut/attention (free) returns a per-second curve 0–1 of the current cut built only from measured signals — speech rate, voice energy and visual change (cuts, b-roll, zooms) — with the signals themselves, the mean and every dip of 2 s or more. Each dip has its likely reason (silence, low_energy, slow_speech, static_visual) and, when there is one, a one-click fix whose revise body you send as-is to …/cut/revise (cut the pause, add b-roll or a zoom, faster pacing, drop the sentence). It is a heuristic, not a retention prediction (heuristic: true): there is no peer-reviewed evidence that such proxies predict audience retention, so label it that way in any UI.

curl https://ai.codai.ro/v1/studio/productions/<id>/cut/attention -H "Authorization: Bearer $CODAI_API_KEY"
# 200 { "object": "studio.production.cut_attention", "heuristic": true, "step_s": 1, "points": [0.82, 0.77, ...],
#       "signals": { "speech": [...], "energy": [...], "visual": [...] }, "mean": 0.64,
#       "dips": [ { "start_s": 18, "end_s": 21.5, "reason": "silence", "sentence": null,
#                   "fix": { "action": "cut_pause", "revise": { "disabled": ["+silence:12"] } } } ] }

Topic chips

POST /v1/studio/productions/<id>/cut/topics runs one paid model turn (codai::production-cut-topics, quoted 60,000 µUSD, plan-funded) that splits the recording into labelled topics — label under 40 characters, a kind (intro, problem, solution, demo, pricing, story, tips, qa, outro, other), the sentences, start and end — and stores them. {"refresh": false} returns the stored topics for free; GET …/cut/topics always reads them for free (404 no_topics before the first call; 502 topics_failed when the model returns nothing usable).

curl https://ai.codai.ro/v1/studio/productions/<id>/cut/topics \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" -d '{}'
# 200 { "object": "studio.production.cut_topics",
#       "topics": [ { "id": "t1", "label": "Why invoices get rejected", "kind": "problem", "sentences": ["s1", "s2", "s3"],
#                     "start_ms": 0, "end_ms": 24100 }, { "id": "t2", "label": "Pricing", "kind": "pricing", ... } ],
#       "cost_micro_usd": 3800, "warnings": [] }

A chip is a free revise: POST …/cut/revise {"topics": ["t1", "t3"]} keeps only those topics (the hook stays when it lies inside them); {"topics": []} brings every topic back. The same topics become the podcast chapters.

Render all

POST /v1/studio/productions/<id>/cut/renders {"kind": "hooks" | "lengths", "ids"?, "quality"?} turns every variant into its own creative and starts one render each, billed at the usual render prices (draft $0.005/s, final $0.01/s). hooks uses the stored hook sentence ids (default all), lengths the preset ids (default 15, 30 and 60). At most 3 renders run at once, so a batch that would exceed that is refused up front (429 too_many_renders), and the wallet must cover the whole batch before anything is written (402). Idempotency-Key replays the first answer.

curl https://ai.codai.ro/v1/studio/productions/<id>/cut/renders \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" -d '{"kind":"lengths","ids":["15","30","60"],"quality":"draft"}'
# 200 { "object": "studio.production.cut_renders", "kind": "lengths", "quality": "draft", "quote_micro_usd": 525000,
#       "renders": [ { "id": "15", "label": "15 s", "creative_id": "…", "render_id": "…", "output_ms": 15200 }, ... ] }

Instagram Trial Reels is the only native way to A/B test video variants; YouTube Test & Compare covers titles and thumbnails only, not the video itself.

Podcast package

POST /v1/studio/productions/<id>/cut/podcast packages a finished render of the cut as a podcast episode, in the background like an FCPXML export: the answer is a 202 Export (kind: "podcast") plus spotify_upload_url and chapters; poll GET /v1/studio/exports/<export_id> until succeeded and download url (<slug>-podcast.zip). The zip holds:

  • episode.mp3 — loudness -16 LUFS, true peak -1 dBTP, ID3v2 tags, the cover and CHAP/CTOC chapters (Apple Podcasts reads them); mp3 {"channels": 1 | 2, "bitrate_k": 128 | 192}, default stereo 192 kbit/s;
  • cover.jpg — 3000×3000, a frame of the cut with the title (Apple accepts 1400–3000 px square RGB);
  • notes.md — title, description with MM:SS chapters, show notes and keywords;
  • chapters.txt when there are chapters, and episode.mp4 (the render; include_video: false leaves it out).

Chapters come from the stored topics (one topics turn runs first when there are none and notes is on) and follow the platform rules: at least 3, the first at 00:00, at least 30 s apart, titles under 40 characters — otherwise there are no chapters (warning chapters_too_few). Spotify has no upload API: upload episode.mp4 (video podcast, H.264 16:9 with AAC) or episode.mp3 by hand at Spotify for Creators and paste the description from notes.md — Spotify builds chapters only from the timestamps in the description.

curl https://ai.codai.ro/v1/studio/productions/<id>/cut/podcast \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" -d '{"notes":true,"mp3":{"channels":1,"bitrate_k":128}}'
# 202 { "id": "…", "object": "studio.export", "kind": "podcast", "status": "queued", "filename": "invoice-tips-podcast.zip",
#       "files": ["episode.mp3", "cover.jpg", "notes.md", "chapters.txt", "episode.mp4"],
#       "spotify_upload_url": "https://creators.spotify.com/pod/login", "chapters": 4, ... }

Costs: the package job is 20,000 µUSD flat (held, billed when it starts); notes: true (default) adds one notes turn (codai::production-cut-notes, quoted 30,000 µUSD) — false writes plain notes for free. 409 production_not_ready, no_render or render_not_ready when there is nothing finished to package.

Voice cleanup

Cut productions clean the speech for free: voice_cleanup on the create request is auto (default), on or off. auto measures the noise floor between words per source and grades the pass: -60 dBFS or quieter = nothing (already broadcast-clean), -60 to -45 dBFS = a mild pass (strength 0.4), louder than -45 dBFS = a full pass (strength 1.0); on always cleans (at least the mild pass), off never. The cleanup (DeepFilterNet3 denoise, de-ess, compression, loudness) runs in the optional clean phase between b-roll and assemble, and every cleaned source is a voice:<asset> decision you can switch off like any other.

POST …/cut/revise {"voice_cleanup": false} switches the cleaned voice off; true switches it back on — on a cut made without cleaned sources it starts a free background job and the answer carries voice_cleanup_job. Poll it with GET /v1/studio/productions/<job_id> (kind: "cut_clean", phase clean → ready, sources with floor_db, strength, status and clean_asset_id); when it succeeds the cut gets one more version with the cleaned voice.

SDKs and agent tools (wave 3)

TypeScript: productions.cutHooks(id, { n, judge }), retrieveCutHooks(id), cutLengths(id), cutAttention(id), cutTopics(id, { refresh }), retrieveCutTopics(id), cutRenders(id, { kind, ids, quality }), cutPodcast(id, { notes, mp3 }) (then exports.wait) and retrieveCleanJob(id); reviseCut takes hook, topics, drop and voice_cleanup. Python: cut_hooks, retrieve_cut_hooks, cut_lengths, cut_attention, cut_topics, retrieve_cut_topics, cut_renders, cut_podcast; revise_cut(hook=…, topics=…, drop=…, voice_cleanup=…) and create_cut(voice_cleanup=…). Agent tools: creative_cut_hooks, creative_cut_lengths, creative_cut_attention, creative_cut_topics, creative_cut_render_all, creative_cut_podcast (the VS Code extension saves the zip into .codai/studio/), plus hook / topics / drop / voice_cleanup on creative_cut_revise and voice_cleanup on creative_produce.

Edit styles

An edit style is a named set of cut settings: pacing (calm, balanced, punchy), captions (false or a preset with max_words_per_line), zoom (off, light, normal, heavy), broll (density none / light / normal / dense, stock true or false, generated none or images) and reframe (slow, default, fast). Six are built in — podcast-clip, talking-head, tutorial, fast-social, cinematic, minimal — and GET /v1/studio/edit-styles?lang=en|ro returns them with localised names next to your own styles (add gallery=1 to get the first gallery page in the same call). Managing styles is free.

  • Own styles: POST /v1/studio/edit-styles {name, description?, knobs} saves a private style (up to 50, else 409 too_many_styles); PATCH /v1/studio/edit-styles/{id} edits it and DELETE removes it. Any edit makes a shared style private again.
  • Gallery: POST /v1/studio/edit-styles/{id}/submit shares one of your styles. Only the name, description and settings are shared, with your username, and Kodwen reviews it first: the status is pending ("Waiting for review") until a moderator approves it (10 submissions a day). GET /v1/studio/edit-styles/gallery?limit=&cursor= lists approved styles, newest first, with next_cursor; POST /v1/studio/edit-styles/{id}/copy puts a private copy in your styles (copied_from points to the source).
  • Reporting and moderation: styles in the gallery are shared by other users and checked by Kodwen before they appear. Report a style that breaks the rules with POST /v1/studio/edit-styles/{id}/report {reason, details?} (spam, harassment, hate, sexual, violence, illegal, ip, other; one report per user, 5 reports hide it until review). Moderators use GET /v1/studio/edit-styles/moderation?status= and POST /v1/studio/edit-styles/{id}/moderate {action: approve|reject|remove} (super_admin only).

Restyle a finished cut with a built-in id or a numeric style id. It applies at once and free: the knobs are written as a new version on the same branch rules as a revise.

curl https://ai.codai.ro/v1/studio/productions/<id>/cut/restyle \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" \
  -d '{"style":"fast-social","base_seq":3}'
# 200 { "creative_id": "…", "seq": 4, "branch": "main", "decisions": [...], "stats": {...}, "warnings": [],
#       "style": "builtin:fast-social", "broll_slots_needed": 6,
#       "broll_job": { "id": "<job id>", "kind": "cut_restyle", "status": "running", "phase": "broll",
#                      "slots_total": 3, "slots_done": 0, "quote_micro_usd": "…", ... } }

Extra b-roll is fetched in the background; generated images are charged at cost. When the style wants more b-roll than the cut has, broll_job fills the missing slots from your footage, then stock, then generated images (only when the style allows them). Own and stock inserts are free; the generated stills are covered by one wallet hold (quote_micro_usd) and only what was spent is charged (spent_micro_usd). Poll GET /v1/studio/productions/<job id> until status is succeeded (it has written one more version, seq) or failed; a newer restyle cancels a running job and releases its hold. Pass "fetch_broll": false to apply the knobs only; when the hold fails for lack of funds the knobs are still applied and warnings contains broll_insufficient_funds. GET …/cut then shows the style in overrides.style with overrides.pacing_params, overrides.reframe and overrides.broll_limit. SDKs: editStyles.list / create / update / delete / submit / gallery / copy / report, productions.restyleCut(id, { style }), productions.retrieveRestyleJob(jobId) (Python edit_styles.*, productions.restyle_cut(id, style), productions.retrieve(job_id)); agent tools creative_edit_styles and creative_cut_restyle.

Stock b-roll

GET /v1/studio/stock/search?q=…&kind=video|photo searches royalty-free stock (Pexels first, Pixabay as the fallback; Pixabay items labelled AI-generated are filtered out) and POST /v1/studio/stock/import {provider, id, kind} copies one item into your assets with its licence recorded (meta.stock). Both are free (600 searches and 200 imports per user per day); 503 stock_unavailable when no provider is configured, 429 stock_rate_limited when they are rate limited. The cut uses the same path for its stock b-roll.

Attribution rules: show every item's credit (for example "Video by Jane Doe on Pexels", linked to page_url) wherever you display it, and keep the imported asset's credit visible where you publish the work — Kodwen writes it to the render credits, share pages and exports. The licences allow use inside a new work, commercial included, but not redistributing the raw files on their own, a competing stock service, bulk copying or model training; preview_url and files[].url are for temporary display only. People, brands and places in the footage may need your own clearance for ads. SDKs: stock.search({ q }), stock.import({ provider, id, kind }) (Python stock.search, stock.import_).

Publish to YouTube

Direct publishing is being prepared: until Kodwen enables it, GET /v1/studio/connections answers enabled: false and connecting, drafting and publishing answer 404 not_found.

Connect a YouTube channel once, then upload a finished video render with fields you approve. Publishing is free; the upload counts against YouTube's own limits (Kodwen keeps at most 100 uploads a day per channel).

  • Connect: POST /v1/studio/connections/youtube/start returns a Google authorize_url with a state and a PKCE code_verifier; after the person approves, send the code from the redirect to POST /v1/studio/connections/youtube/exchange {code, state, code_verifier} within 10 minutes. The hub does this for you under Studio → Connections. Kodwen asks only for the upload scope and stores the tokens encrypted with a key in Cloud KMS. GET /v1/studio/connections lists your channels (enabled tells whether publishing is on); DELETE /v1/studio/connections/{id} revokes the access at Google, deletes the tokens and cancels uploads that have not started — videos already on YouTube stay there.
  • Draft: POST /v1/studio/publish/draft {render_id, title, description?, hashtags?, language?} fills in title, description, tags and the AI disclosure from the render, without uploading. Privacy and "made for kids" are never pre-filled: the person chooses them.
  • Upload: POST /v1/studio/publish {render_id, connection_id, title, description?, tags?, privacy, made_for_kids, contains_synthetic_media, publish_at?, category_id?, language?, client_request_id?} answers 202 with a publication. Fields are checked like YouTube does: title up to 100 characters, description up to 5000 bytes, no < or >, tags up to 500 characters in total, publish_at only with privacy: "private". Renders made with generative AI must keep contains_synthetic_media: true (only renders that just edit your own footage may set it to false). Send client_request_id to make retries safe.
  • Status: GET /v1/studio/publish/{id} returns phase (queued, uploading, processing, done, failed, cancelled), progress, the video link and a readable error; POST /v1/studio/publish/{id}/cancel works until the upload starts.

SDKs: publishing.listConnections / startConnection / exchangeConnection / deleteConnection / draft / create / retrieve / cancel (Python publishing.list_connections, …); agent tools creative_publish_draft (prepares a draft only — the person confirms it in the app) and creative_publish_status.

Timelines become creatives

POST /v1/studio/timelines keeps working as before and now also saves the same timeline as an editable creative: its 202 body has creative_id (or null if the copy could not be made — the render is never affected).

Share, review and remix

A creative can be shared through a public read-only view link (anyone with an API key can remix it into their own account, assets copied) or a review link where people without an account leave comments anchored to a node and a moment, and approvers approve or request changes; an approval pins the version. All of it is free — see Share, review and remix.

Hub (internal)

The hub calls the same routes under /internal/studio/creatives…, /internal/studio/brand-kits…, /internal/studio/templates…, /internal/studio/exports… and /internal/studio/edit-styles… (plus POST /internal/studio/productions/:id/cut/restyle) with a service token and a user_id; bodies and errors are identical. Brand kits are mirrored in full: POST /internal/studio/brand-kits, PATCH /internal/studio/brand-kits/:id, GET /internal/studio/brand-kits/:id/dtcg, POST /internal/studio/brand-kits/extract (with url or logo_asset_id) and POST /internal/studio/creatives/:id/restyle. Captions, platforms, fixes and the tweak panel are mirrored too: POST /internal/studio/creatives/:id/captions, GET /internal/studio/creatives/:id/captions.srt and .vtt, POST /internal/studio/creatives/:id/adapt, POST /internal/studio/creatives/:id/lint/fix and GET /internal/studio/creatives/:id/controls. Localisation is mirrored as well: GET /internal/studio/creatives/:id/locales, POST /internal/studio/creatives/:id/localize and POST /internal/studio/creatives/:id/locales/:locale/fork; batches live under /internal/studio/batches… (see Batches); share links, reviews and remix under /internal/studio/creatives/:id/shares…, /internal/studio/creatives/:id/review… and /internal/studio/shares/:token/remix (see Share, review and remix).

SDKs

const c = await codai.creatives.create({ kind: 'video', title: 'Launch teaser' });
const r = await codai.creatives.edit(c.id, {
  baseSeq: c.head_seq,
  ops: [{ op: 'setMeta', title: 'Launch teaser v2' }],
});
const job = await codai.creatives.render(c.id, { quality: 'draft' });

let frames = await codai.creatives.frame(c.id, { t_ms: [0, 1500] });
while (frames.status === 'running') {
  await new Promise((r) => setTimeout(r, 2000));
  frames = await codai.creatives.frameJob(c.id, frames.frame_job_id);
}
const edit = await codai.creatives.comment(c.id, { t_ms: 1500, comment: 'Make the title red', base_seq: 2 });
const ab = await codai.creatives.variants(c.id, { base_seq: edit.head_seq, count: 3, focus: ['hook', 'cta'] });
await codai.creatives.pickVariant(c.id, ab.variants[0].creative_id, { base_seq: edit.head_seq });

const kit = await codai.brandKits.extract({ url: 'https://acme.example/' });
const branded = await codai.creatives.create({ kind: 'image', brand_kit_id: kit.id });
c = client.creatives.create({"kind": "video", "title": "Launch teaser"})
client.creatives.edit(c["id"], base_seq=c["head_seq"], ops=[{"op": "setMeta", "title": "v2"}])
job = client.creatives.render(c["id"], {"quality": "draft"})
frames = client.creatives.frame(c["id"], t_ms=[0, 1500], exact=True)
if frames["status"] == "running":
    frames = client.creatives.frame_job(c["id"], frames["frame_job_id"])  # poll until "succeeded"
edit = client.creatives.comment(c["id"], t_ms=1500, comment="Make the title red", base_seq=2)
ab = client.creatives.variants(c["id"], base_seq=edit["head_seq"], count=3, focus=["hook", "cta"])
client.creatives.pick_variant(c["id"], ab["variants"][0]["creative_id"], base_seq=edit["head_seq"])

On this page