# Creatives (/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](./renders.mdx) 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](./sharing.mdx) |
| `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 [#the-document]

```json
{
  "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](./assets.mdx) 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.

```bash
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 [#editing-with-ops]

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

```bash
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" } }
    ]
  }'
```

```json
{ "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](#localisation)). Adapting to a platform is one `adaptFormat` (see [Platforms](#platforms)); describing how a variable is edited in the tweak panel is one `setControl` (see [Tweak panel](#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](#restyle-a-creative)); swapping an image everywhere is one `replaceAsset`.

### Concurrent edits [#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:

```json
{
  "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 [#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 [#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 [#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 [#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`:

```bash
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}`](./renders.mdx), 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](#documents-deck-doc-sheet)).

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](./renders.mdx#ai-label-eu-ai-act-art-504). 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 [#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):

```bash
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**:

```json
{
  "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 [#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`:

```bash
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 [#ab-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.

```bash
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 [#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](https://www.designtokens.org/) design tokens (format 2025.10), so you can
import them from and export them to design tools.

### The DTCG format [#the-dtcg-format]

```json
{
  "$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](#documents-deck-doc-sheet) creatives follow the kit too.

### Create, import, edit, export [#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.

```bash
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 [#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.

```bash
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 [#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 [#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:

```bash
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:

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:

```json
{ "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 [#sdks-and-agent-tools]

```ts
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 });
```

```python
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 [#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](#platforms)).

```bash
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 [#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:

```bash
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:

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:

```json
{ "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 [#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](#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](#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`:

```bash
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 [#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.

```json
{ "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](#rejected-ops), 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) [#sdks-and-agent-tools-captions-platforms-lint-controls]

```ts
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);
```

```python
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 [#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 [#the-overrides-model]

```json
{
  "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](#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 [#read-the-languages]

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

```json
{
  "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 [#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`):

```bash
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 [#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](#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 [#edit-translations-by-hand]

Manual edits are ordinary ops on `POST …/ops`:

```json
{ "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 [#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 [#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.`.

```bash
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 [#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) [#sdks-and-agent-tools-localisation]

```ts
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)' });
```

```python
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](./batches.mdx).

## Templates [#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):

```bash
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):

```bash
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`).

```ts
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);
```

```python
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 [#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.

```bash
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).

```ts
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);
```

```python
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 [#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](https://github.com/airbnb/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).

```bash
# 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).

```ts
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 }
```

```python
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) [#documents-deck-doc-sheet]

Studio [documents](./documents.mdx) — 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 `group`s, 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.

```bash
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`.

```bash
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": "…" }, ... } }
```

```ts
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);
```

```python
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 [#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 [#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](./sharing.mdx).

## Hub (internal) [#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](./batches.mdx)); 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](./sharing.mdx)).

## SDKs [#sdks]

```ts
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 });
```

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