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.
| Method | Path | Result |
|---|---|---|
POST | /v1/studio/creatives | New 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}/outline | Compact text outline, formats and variables |
POST | /v1/studio/creatives/{id}/find | Node ids matching a query |
POST | /v1/studio/creatives/{id}/subtree | Full JSON of nodes or a scene |
POST | /v1/studio/creatives/{id}/ops | Apply an op batch → new version |
GET | /v1/studio/creatives/{id}/versions | Version history |
POST | /v1/studio/creatives/{id}/revert | New head with an older version's document |
GET | /v1/studio/creatives/{id}/branches | Named branches (main first) |
POST | /v1/studio/creatives/{id}/branches | New branch from a version, 201 |
DELETE | /v1/studio/creatives/{id}/branches/{name} | Delete a branch and its own versions |
POST | /v1/studio/creatives/{id}/filmstrip | Thumbnail per clip (shared compositor, frame quota) |
GET | /v1/studio/jobs | Running and recent Studio jobs (queue) |
POST | /v1/studio/creatives/{id}/fork | New creative from a version (201) |
POST | /v1/studio/creatives/{id}/lint | Contrast, 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}/render | MP4 render (202, codai-render; 200 when the identical render already exists) |
POST | /v1/studio/creatives/{id}/frame | PNG 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}/comments | Turn a comment pinned on a frame into an edit (token-metered) |
POST | /v1/studio/creatives/{id}/variants | Generate A/B variants as forks + draft renders (token-metered) |
GET | /v1/studio/creatives/{id}/variants | The latest variant group with statuses and render state |
POST | /v1/studio/creatives/{id}/variants/{variantId}/pick | Apply the winning variant to the original |
DELETE | /v1/studio/creatives/{id} | Delete |
POST | /v1/studio/brand-kits | Create a brand kit from tokens or a DTCG file (201) |
POST | /v1/studio/brand-kits/extract | Brand 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}/dtcg | Download a brand kit as a DTCG .tokens.json file |
DELETE | /v1/studio/brand-kits/{id} | Delete a brand kit |
POST | /v1/studio/creatives/{id}/restyle | Re-apply a brand kit to a creative → new version |
POST | /v1/studio/creatives/{id}/captions | Auto-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}/adapt | Adapt to platform presets (sizes + UI safe zones) → new version |
POST | /v1/studio/creatives/{id}/lint/fix | Fix lint issues automatically (free; optional paid agent pass) |
GET | /v1/studio/creatives/{id}/controls | Tweak 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}/localize | Translate into up to 10 languages → new version (token-metered) |
POST | /v1/studio/creatives/{id}/locales/{locale}/fork | Standalone creative in one language (201) |
POST | /v1/studio/creatives/{id}/template | Save 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}/creative | Edit a deck, doc or sheet document as a creative (201, or 200 when already linked) |
POST | /v1/studio/creatives/{id}/build | Build a deck, doc or sheet creative into a new PPTX / DOCX / XLSX version |
POST | /v1/studio/creatives/{id}/shares | Create a view or review link (201, token shown once) — see Share, review and remix |
GET | /v1/studio/creatives/{id}/shares | Active share links |
DELETE | /v1/studio/creatives/{id}/shares/{shareId} | Revoke a share link (204) |
GET | /v1/studio/creatives/{id}/review | Review status and approved version |
POST | /v1/studio/creatives/{id}/review | Request a review or go back to draft |
GET | /v1/studio/creatives/{id}/review/threads?status= | Review comment threads |
POST | /v1/studio/creatives/{id}/review/threads | New anchored comment thread (201) |
POST | /v1/studio/creatives/{id}/review/threads/{threadId}/replies | Reply to a thread (201) |
POST | /v1/studio/creatives/{id}/review/threads/{threadId}/resolve | Resolve a thread |
POST | /v1/studio/creatives/{id}/review/threads/{threadId}/reopen | Reopen a thread |
DELETE | /v1/studio/creatives/{id}/review/comments/{commentId} | Delete a review comment (204) |
POST | /v1/studio/shares/{token}/remix | Copy 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
orderKeyorder; video scenes last 0.5–30 s and a video lasts at most 180 s. - Nodes are
text,shape,image,video,chart,captions,groupandhtml_block, each with anid, alayoutand optionaltiming,anim(presets such asfade-in,slide-up,ken-burns,zoom-in,typewriter, or keyframes — see Motion) and per-formatoverrides. - Media refer to your assets by id.
brand.tokensandvarsare 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" }]
}| Preset | What it does | Default duration |
|---|---|---|
fade-in, fade-out | Fades in; fades out at the end of the node | 400 ms |
slide-up, slide-down, slide-left, slide-right | Slides in over params.distance (default 48 px) with a fade | 500 ms |
scale-pop | Pops in from 0.6 with a small overshoot | 450 ms |
typewriter | Reveals the text character by character | 1200 ms |
word-stagger | Words appear one after another (params.wordDurMs, default 350) | 600 ms |
count-up | Counts from params.from (default 0) to params.to (default: the number in the text) | 1200 ms |
wipe | Reveals the node with a horizontal wipe | 600 ms |
highlight-sweep | Sweeps the highlight colour across the text | 600 ms |
ken-burns | Slow linear zoom towards the focal point (params.scale, default 1.08) | Whole node |
zoom-in, zoom-out | Camera push / pull on full-bleed media about the focal point (params.scale, default 1.14) | Whole node |
pan-left, pan-right | Camera truck across slightly enlarged media (params.scale, default 1.12) | Whole node |
rise | Entrance: moves up (params.distance, default 64 px) and scales from 0.96 with a fade | 700 ms |
scale-out | Exit: shrinks to 0.92 and fades, placed at the end of the node like fade-out | 400 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 }'| Field | Meaning |
|---|---|
t_ms | 1–6 times in milliseconds, each below the creative's total duration (required) |
format | A formats[].id (default the document's default format) |
exact | false (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 |
version | Document 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 }'| Field | Meaning |
|---|---|
t_ms | Playhead time of the comment (required) |
comment | What to change, 1–2000 characters (required) |
base_seq | The head_seq you last read (required; a stale base is rebased like /ops) |
node_id / scene_id / clip_id | The 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) |
apply | true (default) writes a version; false returns the proposed ops and their diff only |
chips | Up 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 }'| Field | Meaning |
|---|---|
base_seq | The version to fork from (required) |
count | 1–5 variants (default 3) |
focus | Any of hook (the opening), title, cta, visual (colours, layout, imagery, motion), copy (default hook, title, cta) |
brief | Optional direction, up to 1000 characters |
render | true (default, video only) queues a draft render of each variant |
format | Format 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
$valuemay also be a"#abc"/"#aabbcc"string,{ "hex" }or{ "colorSpace": "srgb", "components" };$typemay sit on the token or on its group; a font$valuemay be a single string. Stored kits always use the canonical shape above. - A font
$valuelists 1–4 family names (an empty list means unknown);radiusis 0–200 px and optional. - The canonical JSON is at most 16 KB. An invalid token file answers
400 invalid_tokenswith the problems inerror.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.
Extract from a web page or a logo
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/creativeswith"brand_kit_id": "<kit id>": version 1 already hasbrand = { kitId, tokens }and the logo registered inassets(no extra version). An unknown kit is404. - To an existing creative —
POST /v1/studio/creatives/{id}/restyle(below). - By hand — send
[{ "op": "setBrand", "kitId": "<kit id>", "tokens": { … } }](plus asetAssetfor the logo) toPOST …/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 }'| Field | Meaning |
|---|---|
brand_kit_id / tokens | Exactly one: one of your kits, or a flat token map |
base_seq | The version you read (default: the head); a stale base is rebased like /ops |
snap | true maps every other literal colour to the nearest kit colour (default false) |
fonts | false leaves fonts alone (default true) |
force | true 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:
- Token-ify. The new brand is the old
brand.tokenswith the kit's tokens on top. Every literal#rrggbbin 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. - Snap. With
snap: trueevery 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. - 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
headingFontfor headings (rolestitle,subtitle,heading1–heading4,column-heading,stat-value, or text of size 48 and up outside documents) andbodyFontotherwise. - Locked nodes (or nodes inside a locked group) are skipped with the warning
restyle skipped locked node <id>, unlessforce: 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" }'| Field | Meaning |
|---|---|
clip_id | The audio clip to caption (default: the first voice clip) |
preset | bold-pill (default), clean-outline, karaoke-pop or minimal |
node_id | Id of the captions node (default captions, then captions-2…); an existing captions node with that id is replaced |
script | The exact narration text, up to 20 000 characters (default: the text the speech asset was generated from) |
language | Transcription language (default: the document locale) |
retranscribe | true ignores the cached words and transcribes again |
base_seq / force | As 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:
| Preset | Style | Text size | Words per line | Colours |
|---|---|---|---|---|
bold-pill | Pill behind the text | 5.5 % | 4 | White |
clean-outline | Outlined text | 5 % | 6 | White |
karaoke-pop | Word-by-word highlight | 6 % | 3 | White, highlight accent token (or #ffd60a) |
minimal | Outlined text | 4 % | 8 | White |
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:
| Platform | Label | Size | Safe zone (top / right / bottom / left) | Min. text |
|---|---|---|---|---|
tiktok | TikTok | 1080×1920 | 8 % / 13 % / 20 % / 5 % | 36 px |
reels | Instagram Reels | 1080×1920 | 7 % / 10 % / 18 % / 5 % | 36 px |
shorts | YouTube Shorts | 1080×1920 | 7 % / 12 % / 17 % / 5 % | 36 px |
feed-square | Feed 1:1 | 1080×1080 | 5 % on every side | 32 px |
feed-portrait | Feed 4:5 | 1080×1350 | 5 % on every side | 32 px |
youtube | YouTube 16:9 | 1920×1080 | 5 % on every side | 28 px |
The zones are deliberately conservative approximations of the platforms' published guides, which change often.
| Field | Meaning |
|---|---|
platforms | 1–6 distinct platform ids (required) |
focal | none (default) or vision — see below |
base_seq / force | As 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:
- 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 inmeta.platforms[<format id>]. - 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. - Text below the platform's minimum gets a larger size in that format only.
- 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.
| Code | Severity | Fixable | What it means |
|---|---|---|---|
low_contrast | error | no | Text contrast below WCAG (4.5:1, or 3:1 for large text) |
outside_safe_area | warning | no | Text or captions outside the safe area (the platform zone when the default format is bound to a platform) |
empty_text | warning | no | A text node without text |
captions_no_words | error | no | A captions node without words |
captions_no_clip | warning | no | Captions that reference a missing audio clip |
missing_asset | error | no | An asset id that is not registered in the document |
scene_zero_duration | error / warning | no | A scene of 0 ms (error) or a node that is never visible (warning) |
too_long | error | no | Total duration over 180 s |
chart_no_svg | warning | no | A chart without a pre-rendered SVG |
unresolved_ref | warning | no | A reference to an unknown variable or token |
empty_list | warning | no | A list without item text |
off_brand_color | warning | yes | A literal colour outside the brand palette (neutral greys are never flagged) |
outside_platform_safe_zone | warning | yes | Text under a platform's interface in a format bound to that platform |
text_too_small | warning | yes | Text below the legibility floor of a format (value = px) |
missing_alt | warning | no | An image in a deck or doc without alt text |
missing_captions | warning | no | A voice clip without a captions node — use Captions |
reading_speed | warning | no | A caption line faster than 20 characters per second (value = characters per second) |
flashing | error / warning | yes / no | An animation that flashes more than 3 times per second (error, fixable) or 4+ consecutive scenes shorter than 334 ms (warning) |
motion_off_frame | warning | no | A 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_overlap | warning | no | Two 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_time | warning | no | A text or list of 3+ words readable for less than 0.5 s + 1 s per 4 words (value = ms shown) |
motion_repeat | warning | no | The 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_overrun | warning | no | A 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"] }'| Field | Meaning |
|---|---|
codes | Only fix these codes (default: every fixable code) |
agent | true adds one paid agent pass for the remaining issues (default false) |
base_seq / force | As 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" }] } }| Kind | Fits variables of type | Extra fields |
|---|---|---|
text | text, number | multiline |
color | color | — |
slider | number | min and max (required, min < max), step |
toggle | boolean | — |
select | text, number, color | options (required, 1–24 { "value", "label"? }) |
image | image | — |
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 }] }
}
}
}
}
}sourceis the language of the document itself (default: the documentlocale, otherwiseen). Locales are BCP-47 tags (ro,en,de-DE,pt-BR), normalised on input (ro_ro→ro-RO).textsmaps a translation unit key to its translationt, the hashhof the source text it was made from andby(agentoruser). When the source text changes later, the hash no longer matches and the translation is stale — it is still used until you refresh it.audioswaps a voice clip's asset for that language andcaptionsreplaces 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 kind | Key | Where |
|---|---|---|
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>:caption | Table 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>.name | Chart labels |
alt, caption | <node id>:alt, <node id>:caption | Image 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>:words | Caption words, joined by spaces |
notes | scene:<scene id>:notes | Speaker notes |
title | doc:title | The creative title |
var | var:<key> | A text variable |
narration | clip:<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"] }'| Field | Meaning |
|---|---|
locales | 1–10 distinct target languages, never the source language (required) |
tone | formal (polite address, e.g. "Sie", "dumneavoastră") or informal ("du", "tu"); stored per language |
glossary | Up to 100 terms (each up to 100 characters) kept exactly as written; saved on the document |
only_missing | true (default) translates only units without a fresh translation; false translates everything again |
voice | true (video only) also re-records the narration and re-times the captions per language — see below |
base_seq / force | As 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 } }setLocaleTextsets up to 500 units of one language (nulldeletes a translation, which then counts as missing); the stored hash is that of the current source text, so the unit is fresh again.bydefaults touser. An unknown key isnot_found, a unit of a locked nodelocked(unlessforce), the source languageinvalid.setLocalesets a language'stone,audio(clip id → asset id, the asset must be registered withsetAsset) andcaptions(captions node id → words);"remove": truedeletes the language.setLocalizationsets the documentsourcelanguage, theglossaryand theforkOflink;"reset": trueremoves 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/fixgives that nodeautoFit: "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 atextnode: its spans collapse to one span that keeps the first span's style; the current text becomes the default.prop: "image"on animagenode with a literal asset: the current asset id becomes the default (the asset stays in the document).keyis a letter followed by up to 63 letters, digits or_, unique in the template;labeldefaults 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 from00: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,formatpicks one (default: the document's default format); sending both is a400 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:
| Exported | shapes (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 |
| Approximated | word-by-word stagger, highlight sweep, captions styling, mixed text styles inside one text node, group opacity animation |
| Not exported | video, 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 slidelayout(title,section,bullets,two_column,image,chart,table,quote,stats,timeline,closing) and the speakernoteson the scene. Nodes are laid out on the 16:9 (1920×1080) or 4:3 (1440×1080) format with ids such ass3-title,s3-bullets,s3-left-headingors3-stat2-value. Bullets are alistnode, tables atablenode, charts achartnode, two columns twogroups, stats and timeline items onegroupeach. - Doc — one scene per page (
p1,p2, …; a new page wherever the spec had apage_break). Each page holds onegroupwithrole: "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 acell_gridnode (columns,rows,freezeHeader,autofilter,totals,conditional) plus onechartnode per chart with itsanchorcell.
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.
kind | What the plan contains | Output (outputs[].role → format) | C2PA |
|---|---|---|---|
video | Storyboard 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 repair | Yes |
image | Headline, subline, image prompt, format (1:1, 4:5 or 9:16) | image → PNG | Yes |
deck | Title + 4–8 slides (bullets, optional image), 16:9 | document → PPTX | No (document properties mark) |
doc | Title + 4–8 sections (paragraph, bullets, optional image), A4 | document → DOCX | No (document properties mark) |
sheet | Title + columns + 5–20 rows, one worksheet | document → XLSX | No (document properties mark) |
carousel | 4–8 slides (headline, body, optional image), 4:5 | pages → ZIP of PNGs, cover → PNG | Cover yes; ZIP no (XMP in each PNG) |
print | Poster or flyer: headline, body, image prompt, A4 or A5 | pdf → PDF | No (PDF Info mark) |
audio | Jingle or song: title, genre, prompt, optional lyrics, ≤ 60 s | audio → MP3 | Yes |
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.
| Field | Meaning |
|---|---|
quality | final (default): full resolution at 30 fps. draft: 540p at 15 fps, half the render price |
music | true (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:
| Field | Meaning |
|---|---|
state | preparing while the pictures and the preview are being made, waiting at the gate, then approved, cancelled or expired |
animatic_render_id | The 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 |
stills | One entry per scene: scene, asset_id (the generated image, null when it failed) and narration (the voice-over line) |
since, expires_at | When 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", ... }, ... }approvestarts the final render at the requestedquality, billed like every render. The review after the render runs as usual.cancelends the production withstatus: "failed"anderror: "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_fundsonapprovemeans the wallet cannot cover the final render. The production stays at the gate, so you can top up and approve again.409 not_in_reviewmeans the production is not waiting at the gate. Sending the same decision again after it was applied answers200.
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 withMM:SSchapters, show notes and keywords;chapters.txtwhen there are chapters, andepisode.mp4(the render;include_video: falseleaves 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 409too_many_styles);PATCH /v1/studio/edit-styles/{id}edits it andDELETEremoves it. Any edit makes a shared style private again. - Gallery:
POST /v1/studio/edit-styles/{id}/submitshares one of your styles. Only the name, description and settings are shared, with your username, and Kodwen reviews it first: the status ispending("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, withnext_cursor;POST /v1/studio/edit-styles/{id}/copyputs a private copy in your styles (copied_frompoints 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 useGET /v1/studio/edit-styles/moderation?status=andPOST /v1/studio/edit-styles/{id}/moderate {action: approve|reject|remove}(super_adminonly).
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/connectionsanswersenabled: falseand connecting, drafting and publishing answer 404not_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/startreturns a Googleauthorize_urlwith astateand a PKCEcode_verifier; after the person approves, send thecodefrom the redirect toPOST /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/connectionslists your channels (enabledtells 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_atonly withprivacy: "private". Renders made with generative AI must keepcontains_synthetic_media: true(only renders that just edit your own footage may set it to false). Sendclient_request_idto make retries safe. - Status:
GET /v1/studio/publish/{id}returnsphase(queued,uploading,processing,done,failed,cancelled),progress, the video link and a readableerror;POST /v1/studio/publish/{id}/cancelworks 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"])Documents
Kodwen Studio documents — build decks (PPTX), documents (DOCX) and spreadsheets (XLSX) from a JSON spec, preview them as PDF and PNG, and turn decks into narrated MP4s.
Audio creatives
Songs and podcasts as editable Kodwen Studio creatives — a multitrack timeline with sections, tracks, clips, MIDI, effects, a master bus and timed lyrics, edited with small ops.