Creatives
Editable creative documents in codai Studio — create a video, image or deck as JSON, edit it with ops, keep every version and render it to MP4.
A creative is one editable document per Studio artefact (video, image, deck, doc or sheet), 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 |
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,typewriter, or keyframes) 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" }'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.
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.
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 | The selected node or scene (default: the scene playing at t_ms) |
apply | true (default) writes a version; false returns the proposed ops only |
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.
Price. Token-metered like any codai 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 codai 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 codai 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 codai 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) |
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 codai 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": ["codai", "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": ["codai"],
"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": ["codai", "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 codai 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: ['codai'] });
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=["codai"])
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.
Unzip it and open project.fcpxml in Final Cut Pro (File → Import → XML) or DaVinci Resolve (File → Import →
Timeline); the media paths are relative, so keep the folder together. Premiere Pro opens it through its FCPXML
import or after a conversion (for example with a FCPXML-to-XML converter); CapCut desktop can import the media 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 |
Provenance. Every file says it was made with AI: the JSON meta holds g: "codai 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).
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… and /internal/studio/exports… 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
codai 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.
Batches
Fill a template or a creative from a CSV file or a sheet — one creative per row, with optional per-row language and render, status export and a ZIP of the videos.