codai docs
Gateway

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.

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

The document

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

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

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

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 }'
FieldMeaning
t_ms1–6 times in milliseconds, each below the creative's total duration (required)
formatA formats[].id (default the document's default format)
exactfalse (default): a fast screenshot of the compiled HTML. true: frames cut from a draft render of the scenes covering those times — pixel-identical to the MP4, and it fills the scene cache for later renders
versionDocument version (seq); default the head

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

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

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

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

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

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

Comments on a frame

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

curl https://ai.codai.ro/v1/studio/creatives/$ID/comments \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "node_id": "title", "t_ms": 1200, "comment": "Make the headline bigger and white", "base_seq": 3 }'
FieldMeaning
t_msPlayhead time of the comment (required)
commentWhat to change, 1–2000 characters (required)
base_seqThe head_seq you last read (required; a stale base is rebased like /ops)
node_id / scene_idThe selected node or scene (default: the scene playing at t_ms)
applytrue (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 }'
FieldMeaning
base_seqThe version to fork from (required)
count1–5 variants (default 3)
focusAny of hook (the opening), title, cta, visual (colours, layout, imagery, motion), copy (default hook, title, cta)
briefOptional direction, up to 1000 characters
rendertrue (default, video only) queues a draft render of each variant
formatFormat id of the draft renders (default: the document default)

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

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

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

Price. Generating is token-metered like any 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 $value may also be a "#abc" / "#aabbcc" string, { "hex" } or { "colorSpace": "srgb", "components" }; $type may sit on the token or on its group; a font $value may be a single string. Stored kits always use the canonical shape above.
  • A font $value lists 1–4 family names (an empty list means unknown); radius is 0–200 px and optional.
  • The canonical JSON is at most 16 KB. An invalid token file answers 400 invalid_tokens with the problems in error.details.issues.

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

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

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

Create, import, edit, export

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

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

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

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

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

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

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

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

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

Apply a kit

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

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

Restyle a creative

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

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

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

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

The same op works in any /ops batch:

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

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

SDKs and agent tools

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

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

Price. Extracting is token-metered like any 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" }'
FieldMeaning
clip_idThe audio clip to caption (default: the first voice clip)
presetbold-pill (default), clean-outline, karaoke-pop or minimal
node_idId of the captions node (default captions, then captions-2…); an existing captions node with that id is replaced
scriptThe exact narration text, up to 20 000 characters (default: the text the speech asset was generated from)
languageTranscription language (default: the document locale)
retranscribetrue ignores the cached words and transcribes again
base_seq / forceAs for /ops (default: the head)

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

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

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

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

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

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

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

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

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

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

Platforms

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

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

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

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

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

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

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

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

The same op works in any /ops batch:

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

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

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

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

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

Price. Adapting is free. Only focal: "vision" is token-metered like any 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.

CodeSeverityFixableWhat it means
low_contrasterrornoText contrast below WCAG (4.5:1, or 3:1 for large text)
outside_safe_areawarningnoText or captions outside the safe area (the platform zone when the default format is bound to a platform)
empty_textwarningnoA text node without text
captions_no_wordserrornoA captions node without words
captions_no_clipwarningnoCaptions that reference a missing audio clip
missing_asseterrornoAn asset id that is not registered in the document
scene_zero_durationerror / warningnoA scene of 0 ms (error) or a node that is never visible (warning)
too_longerrornoTotal duration over 180 s
chart_no_svgwarningnoA chart without a pre-rendered SVG
unresolved_refwarningnoA reference to an unknown variable or token
empty_listwarningnoA list without item text
off_brand_colorwarningyesA literal colour outside the brand palette (neutral greys are never flagged)
outside_platform_safe_zonewarningyesText under a platform's interface in a format bound to that platform
text_too_smallwarningyesText below the legibility floor of a format (value = px)
missing_altwarningnoAn image in a deck or doc without alt text
missing_captionswarningnoA voice clip without a captions node — use Captions
reading_speedwarningnoA caption line faster than 20 characters per second (value = characters per second)
flashingerror / warningyes / noAn animation that flashes more than 3 times per second (error, fixable) or 4+ consecutive scenes shorter than 334 ms (warning)

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

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

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

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

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

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

Price. Lint and the deterministic fixes are free. agent: true is token-metered like any 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" }] } }
KindFits variables of typeExtra fields
texttext, numbermultiline
colorcolor—
slidernumbermin and max (required, min < max), step
toggleboolean—
selecttext, number, coloroptions (required, 1–24 { "value", "label"? })
imageimage—

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

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

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

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

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

Localisation

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

The overrides model

{
  "meta": {
    "localization": {
      "source": "ro",
      "glossary": ["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 }] }
        }
      }
    }
  }
}
  • source is the language of the document itself (default: the document locale, otherwise en). Locales are BCP-47 tags (ro, en, de-DE, pt-BR), normalised on input (ro_ro → ro-RO).
  • texts maps a translation unit key to its translation t, the hash h of the source text it was made from and by (agent or user). When the source text changes later, the hash no longer matches and the translation is stale — it is still used until you refresh it.
  • audio swaps a voice clip's asset for that language and captions replaces a captions node's words (see Voice and captions).

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

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

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

Read the languages

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

{
  "head_seq": 7,
  "source": "ro",
  "glossary": ["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"] }'
FieldMeaning
locales1–10 distinct target languages, never the source language (required)
toneformal (polite address, e.g. "Sie", "dumneavoastră") or informal ("du", "tu"); stored per language
glossaryUp to 100 terms (each up to 100 characters) kept exactly as written; saved on the document
only_missingtrue (default) translates only units without a fresh translation; false translates everything again
voicetrue (video only) also re-records the narration and re-times the captions per language — see below
base_seq / forceAs for /ops (default: the head); force also translates locked nodes

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

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

Price. Translation is token-metered like any 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 } }
  • setLocaleText sets up to 500 units of one language (null deletes a translation, which then counts as missing); the stored hash is that of the current source text, so the unit is fresh again. by defaults to user. An unknown key is not_found, a unit of a locked node locked (unless force), the source language invalid.
  • setLocale sets a language's tone, audio (clip id → asset id, the asset must be registered with setAsset) and captions (captions node id → words); "remove": true deletes the language.
  • setLocalization sets the document source language, the glossary and the forkOf link; "reset": true removes every language.

Checks

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

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

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

Preview, render and export a language

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

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

Renders of a language are billed like any render.

Fork a language

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

SDKs and agent tools (localisation)

const status = await codai.creatives.locales(c.id);
const units = await codai.creatives.locales(c.id, { locale: 'en', units: true });
const done = await codai.creatives.localize(c.id, { locales: ['en', 'de'], tone: 'informal', glossary: ['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 a text node: its spans collapse to one span that keeps the first span's style; the current text becomes the default.
  • prop: "image" on an image node with a literal asset: the current asset id becomes the default (the asset stays in the document).
  • key is a letter followed by up to 63 letters, digits or _, unique in the template; label defaults to the node name. 1–50 vars.

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

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

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

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

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

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

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

Export to FCPXML

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

  • project.fcpxml — one sequence at the document fps (24, 25 or 30) and size; one clip per scene in playback order, named after the scene, with a marker carrying the scene id; voice, music and sound effects as connected audio clips on their own lanes (voice -1, music -2, sfx -3), with their gain.
  • media/seg-NNNN.mp4 — the per-scene video segments, rendered at final quality, without audio.
  • media/audio-* — the voice, music and sound-effect files the timeline uses.

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, format picks one (default: the document's default format); sending both is a 400 invalid_request.
  • seq — the version (default the head).
# the file itself, straight to disk
curl -OJ "https://ai.codai.ro/v1/studio/creatives/$ID/export.lottie?format=16x9&download=1" \
  -H "Authorization: Bearer $CODAI_API_KEY"

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

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

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

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

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

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

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

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

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

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

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

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

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"])

On this page