---
name: threadwright-api
description: "Make AI-character media on Threadwright through its REST API: characters with voices and reference images, voice takes, images, video clips, finished vertical reels and photo posts (projects), plus assist tools that improve prompts and write scripts. Work runs as credit-priced jobs (submit, poll, download the output assets). Use when asked to create, edit or automate media on Threadwright with an access token (smp_...)."
metadata:
  api-version: "0.0.0"
  openapi: "https://app.threadwright.ai/skill/openapi.json"
---

# Threadwright API

Threadwright makes AI-character media: characters with a voice and locked reference
images, then voice, image, video and finishing tools, and projects that turn them into
vertical reels and photo posts. Work runs as asynchronous **jobs** paid in credits. You
submit a job, poll it until it finishes, then download its output **assets**. You can do
everything the website does. This guide is `GET /skill`. Every route and schema is in the
OpenAPI document: https://app.threadwright.ai/skill/openapi.json (also `GET /api/v1/openapi.json`).

## Two kinds of tools

- **Do tools** run with your own inputs and make media or records: `voice.*`, `image.*`,
  `video.*`, `finish.*`, `edit.*`, `character.create` and `project.*`.
- **Assist tools** return a result and make nothing you keep: `assist.improve_prompt`
  (`target` names the field), `assist.suggest_text`, `assist.write_reel_script` (sets each
  clip's `clip_model`), `assist.write_post_plan`, `assist.recommend_model` and the free
  idea helpers (see Idea Studio).
- **Auto options**: `finish.develop` and `image.details` accept `auto: "ai"`. The tool
  chooses the look or details itself and returns only the finished image.

## Base URL and authentication

- Base URL: `https://app.threadwright.ai`. JSON bodies, snake_case keys, ids with a type prefix (`job_`,
  `asset_`, `chr_`, `prj_`, `tok_`).
- Send `Authorization: Bearer smp_...` on every request. The account owner creates a token
  in the web app at https://app.threadwright.ai/settings/tokens; the secret is shown once. Keep it in an
  environment variable (`SOMEP_TOKEN` below), never in code, URLs or logs.
- Send a `User-Agent` header (any name): the edge answers 403 "error code: 1010" to some
  library defaults (seen with Python urllib).
- Token scopes: `read`, `jobs`, `assets`, `characters`, `projects`, `wallet:read`. A missing
  scope answers 403 `forbidden`. `read` reads everything; writes need the matching scope.
- Check the token: `curl -H "Authorization: Bearer $SOMEP_TOKEN" https://app.threadwright.ai/api/v1/me`; `GET /api/v1/health` needs none.
- Bodies are wrapped: one record under its type name (`{"character": {...}}`, `{"asset": {...}}`),
  a list under the plural with `next_cursor` (`{characters, next_cursor}`). Jobs differ: a job
  is sent bare and `GET /api/v1/jobs` lists them under `data`. Every route: see Responses.

## Job lifecycle

1. Pick a tool: `GET /api/v1/offerings` lists the tools on your plan with prices;
   `GET /api/v1/offerings/{id}` gives the input JSON Schema and an example request.
2. Optionally price it: `POST /api/v1/offerings/{id}/estimate` with `{"input": {...}}`.
3. Submit with `POST /api/v1/jobs` and a fresh `Idempotency-Key` (a UUID); reuse it on a retry:

```bash
curl -X POST https://app.threadwright.ai/api/v1/jobs \
  -H "Authorization: Bearer $SOMEP_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"offering": "<tool id>", "input": {...}}'
```

   202 returns the job (bare, not wrapped); a replay of the same key and body returns it again with 200 and
   `Idempotent-Replayed: true`; the same key with a different body is 409.
4. Poll `GET /api/v1/jobs/{id}` until `status` is `succeeded`, `failed` or `cancelled`.
   `queued` and `running` mean in progress; `waiting` means it waits for a provider or a
   free slot. Poll every 5 s at most, every 15 to 30 s for video.
5. On `succeeded`, `outputs` follows the tool's output schema; media outputs are asset ids
   (`asset_...`). `GET /api/v1/assets/{id}` returns `{asset, download_url}` (the URL is valid
   about 10 minutes);
   `GET /api/v1/assets/{id}/content` redirects to the bytes. On `failed`, `error.code` says why.
6. `POST /api/v1/jobs/{id}/cancel` stops a `queued` or `waiting` job and releases its credits.

A job has `id`, `offering`, `status`, `tier`, `queue`, `estimate_credits`, `actual_credits`,
`outputs`, `error` and timestamps.

Job failure codes (`error.code`): `offering_not_found`, `offering_version_mismatch`, `invalid_input`, `invalid_output`, `output_missing`, `semaphore_timeout`, `provider_timeout`, `provider_failed`, `execution_failed`, `settle_failed`, `queue_exhausted`, `enqueue_failed`, `job_cancelled`;
also tool-specific ones such as `character_not_found`, `niche_required` and
`assist_failed` (no usable answer: nothing is charged, try again).

## Credits and estimates

- 1 credit = $0.01. A submit holds `estimate_credits`; success captures `actual_credits`
  (what the job really costs) and releases the rest. A failed or cancelled job is not charged
  (hold released). A finished job you dislike is charged.
  Billing promise: https://threadwright.ai/#billing-promise
- Balance: `GET /api/v1/wallet` returns `available`, `held`, `by_kind` and `next_expiry`;
  `GET /api/v1/wallet/history` lists charges. Short balance: 402 `insufficient_credits`.
- Plans differ in tools, limits and queue: free-plan jobs share a slower queue.
- A 15-second reel (2 talking shots, 1 B-roll) is about 262 credits: Starter 5, Creator 15, Pro 41 reels a month.

## Uploading inputs

1. `POST /api/v1/assets/uploads` with `{"mime", "bytes", "filename"?, "sha256"?}`
   returns `{asset_id, upload}`.
2. `upload.method` `single`: PUT the bytes to `upload.url` with exactly `upload.headers`
   (Content-Type and Content-Length are signed). `multipart` (over 100 MiB): PUT each
   `parts[n]` slice to its `url`, keep each response's `ETag`. URLs expire after 15 minutes.
3. `POST /api/v1/assets/uploads/{id}/complete` (body `{}`, or `{"upload_id",
   "parts": [{"part_number", "etag"}]}` for multipart) returns `{asset}`. Images and
   videos get a free automatic safety check: `asset.moderation` `checking` (not usable yet;
   poll `GET /api/v1/assets/{id}` every few seconds), then `clear` (`asset.status` `ready`: use
   the `asset_id` in tool
   inputs) or `blocked` (breaks the Acceptable Use Policy; never usable or downloadable).

Accepted types: `image/png`, `image/jpeg`, `image/webp`, `video/mp4`, `video/quicktime`, `video/webm`, `audio/webm`, `audio/wav`, `audio/mpeg`, `audio/mp4`, `application/json`, `application/x-subrip`, `text/x-ssa`.
Limits: Free 200 MiB per file, 2 GiB total; Starter 500 MiB per file, 20 GiB total; Creator 1000 MiB per file, 50 GiB total; Pro 2000 MiB per file, 200 GiB total. Read assets with `GET /api/v1/assets` (filters below) and
`GET /api/v1/assets/{id}`; delete with `DELETE /api/v1/assets/{id}`.

## Characters

A character is the saved look and voice every project and project tool applies. Needs the
`characters` scope to write; `read` reads.

- `GET /api/v1/characters`: list yours.
- `POST /api/v1/characters`: create. Only `name` is required. Add `wish` (one line, 3 to 500
  characters) and it is filled in for free: `description`, `niche`, a voice and finishing
  defaults; your own fields win. Then run `character.create` `faces`, `angles`, `finalize`.
- `GET /api/v1/characters/{id}/voice-suggestion`: a fitting voice (`{voice}`), for one with none.
- `GET /api/v1/characters/{id}`: one character.
- `PATCH /api/v1/characters/{id}`: change any fields; `null` clears a nullable field.
  `look`, `captions`, `routing` and `refs` replace the stored value as a whole.
- `DELETE /api/v1/characters/{id}`: delete it (its images stay in your assets).

Main fields: `name`, `description` (the look in words, put in front of prompts), `niche`
(the page concept in your words, up to 500 characters; the idea helpers read it),
`negative_hints`, `style_suffix`, `voice_id` (see Voices), `voice_settings`, `look`
(the `finish.develop` settings), `captions`, `title_card`, `audio_name` and `refs`.
`refs` holds asset ids of your ready images by set: `identity` (1 to 6: front, profile,
full body), and optional `detail`, `context` (up to 10 each) and `hero` (one id). Other
fields are in the OpenAPI schemas.

Make reference images with `character.create`, one step per job: `faces`, `angles`,
optional `contexts`, then free `finalize` writes `refs` (and `niche`). Pass `character_id`.

**Uploaded faces need a declaration.** When `refs` adds an image you uploaded, or one made
directly from an upload, send `upload_declaration` in the same request (on `POST` or
`PATCH /api/v1/characters`, or in `character.create` `finalize`):

```json
{"upload_declaration": {"version": "2026-10-16"}}
```

By sending it you confirm: "This is an original character, not a real person, and I have the rights to use it." Only do so when it is true and
the account owner agrees. Without it the request fails 422 `declaration_required`; an
old `version` fails with the current one in `details.current_version`.

## Character and audio libraries

Free. `read` plus `characters` (characters) or `assets` (audio) browses; that scope also uses.

- `GET /api/v1/library/characters`: browse; `GET /api/v1/library/characters/{id}`: one (`lib_...`)
  with `images` URLs; `GET /api/v1/library/characters/{id}/images/{slot}`: an image (send your credential).
- `POST /api/v1/library/characters/{id}/use`: clone into your characters, `{character_id, character}`; images are copied into your assets (413 when full).
- `GET /api/v1/library/audio`: browse; `GET /api/v1/library/audio/{id}/preview` streams (public).
- `POST /api/v1/library/audio/{id}/use`: copy a track (`libaud_...`), `{asset_id}`; capped per day; `download=1` on a copy is 403.

## Voices

`GET /api/v1/voices` lists the preset voices (`voice_id`, `name`, `description`,
`preview_url`). Only these ids are accepted as a character's `voice_id` and in `voice.take`.

## Projects

A project is one reel or photo post: a `spec` (what to make) and its state. Needs `projects`
scope to write; `read` reads. Free: one guided web reel in draft quality
(`GET /api/v1/projects/free-plan`), finished in 1080p on a paid plan; no API access.

1. `POST /api/v1/projects` with `{kind: "reel" | "post", title?, character_id?, spec?}`; no
   `title`: named from the script. A reel `spec` has `duration_seconds` (required
   to build or price), `shots` (`id` optional) and `edit`.
   A shot is `talking` (`audio`, `first_frame_prompt`,
   `motion_prompt`) or `vo_broll` (voice-over on B-roll `segments`, each with
   `first_frame_prompt` and `motion_prompt`). A post `spec` has `aspect`, `variants` and
   `images` (`id`, `prompt`). `assist.write_reel_script` writes the
   reel's `shots`, shared scene `references` and `sound` library tags;
   `assist.write_post_plan` `{project_id, idea, count?}` plans a post's images and references.
2. `GET /api/v1/projects/{id}/units` lists the spec's units (reel:
   `shot01.take`, `shot01.frame`, `shot01.realism`, `shot01.grade`, `shot01.clip`; post:
   `img1.image`, `img1.realism`, `img1.grade`), each with its tool, `depends_on`,
   `waiting_on`, jobs, `candidates`, `selected` and `estimate_credits` (what one row run
   holds; clips at `clip_seconds`, which `run-all/estimate` uses too; only B-roll without a
   `clip_model` is held higher by Make everything: the dearer of its route and fallback).
3. `POST /api/v1/projects/{id}/units/{unit}/run` (optional `Idempotency-Key`, body
   `{"overrides"?: {...}}`) runs the unit's tool with the spec, the selected upstream
   assets and the character applied (`{job_id, status, replayed}`); 409
   `missing_upstream` while an upstream is unselected. A run never selects its output:
   `select` one (Generate all and Make everything select the first result for you).
4. `POST /api/v1/projects/{id}/units/{unit}/select` with `{asset_id}` picks the output you
   keep: a candidate or your own upload of its kind (image; video for a
   clip; audio for a take); see `warnings`, `notes`. An uploaded take starts a paid
   transcription (retry `POST /api/v1/projects/{id}/units/{unit}/transcribe`).
   `POST /api/v1/projects/{id}/units/{unit}/edit` `{source_asset_id|candidate, instruction}`.
   A frame also takes one of your character's reference images (`select` its asset id), so a clip can start from that exact photo.
   `realism` and `grade` are optional steps after each image (skipped: the earlier pick stays);
   an unselected realism or grade result is not used; clips read the Grade pick. Opening Realism = `POST /api/v1/projects/{id}/units/{unit}/analyse`:
   items are suggested automatically (free; `PUT /api/v1/projects/{id}/units/{unit}/items` `{items, paste_back}` edits them;
   `paste_back: false` keeps the person exactly as is, `true` pastes the original back, `null` lets it choose);
   unasked, the run looks itself (`image.details` price).
   Draft clips (`clip_drafts`): `POST /api/v1/clips/{id}/finish`
   `{mode: "upscale" | "regenerate"}`, priced by `POST /api/v1/clips/{id}/finish/estimate`;
   the finished clip is the job's `outputs.video`.
5. `POST /api/v1/projects/{id}/build` (reels) with `{step}`: `preview`, `draft`, `final`
   or `covers` (`project.reel`; `cover_at` seconds optional: three moments of the latest cut; `video_asset_id` picks the cut). A post is done when every image has its Grade pick;
   `project.post` `{project_id, step: "finals", image_ids}` grades them.
   `POST /api/v1/projects/{id}/run-all/estimate` prices Make everything;
   `POST /api/v1/projects/{id}/run-all` `{estimate_credits}` makes what is missing
   plus the final cut, 3 covers (`run`; `POST /api/v1/projects/{id}/run-all/stop`), the suggested bed if `edit.ambience` is unset.
   Generate all: both take `{mode: "step", step}` (`voice|frames|realism|grade|clips|images|variants`); never-run units only.
   Finish in 1080p: `POST /api/v1/projects/{id}/finish-all/estimate`, `POST /api/v1/projects/{id}/finish-all`
   `{estimate_credits}`. `POST /api/v1/projects/{id}/cover` `{asset_id}` (final-cut cover or `covers` frame);
   `POST /api/v1/projects/{id}/caption` writes a caption (free, not saved).
   Sound: `sound_suggestion` on the project (`ambience_tags`, `music_tags`, library ids `ambience_id`, `music_id`;
   `apply_music`: Make everything also adds the music). Set `spec.edit` `music` and `ambience` (`{asset_id, level?}`: a copy from
   `POST /api/v1/library/audio/{id}/use`), `fps`, `acoustics` (`outdoor|indoor_small|indoor_medium|indoor_large`), `mic_distance` (`close|medium|far`).
6. `GET /api/v1/projects/{id}`; `PATCH /api/v1/projects/{id}` (`title`, `spec`, `character_id`,
   `post_caption` `{text, hashtags}` saved in `state`; `If-Match: <updated_at>`: stale 412); `DELETE /api/v1/projects/{id}`.

References (`first_frame_refs` of a shot or segment, `refs` of a post image) are a list of
`RefEntry` values, in order: a character ref set name (`"identity"`, `"detail"`,
`"context"`, `"hero"`), `{"unit": "shot01.frame"}` (that unit's selected output;
409 `missing_upstream` until chosen) or `{"asset_id": "asset_..."}` (an image of yours).
`[]` makes a plain text-to-image. The default is `["identity"]`.

Scene references (`spec.references`, at most 6, `{id, title, kind, prompt?}`, kind
`location`, `outfit` or `object`): frames link one with `{"reference": "ref1"}` and wait
(409 `missing_upstream`) until its unit `ref1.reference` has a selection. A designer's
reference has no `prompt`; its first run is free once per project (`free_run`), later
runs cost credits. Your own references (with `prompt`) always cost.

## Idea Studio

From a niche to a scripted reel. The idea helpers are free up to a daily number of runs per
plan, then about 1 credit each (`free_daily` on the estimate shows the runs left). Writing
needs the `projects` scope; `read` reads.

1. `POST /api/v1/ideas` `{character_id, title?, state?}` saves a session; keep your working
   state in `state` (any JSON object up to 64 KB) with `PATCH /api/v1/ideas/{id}`.
   `GET /api/v1/ideas`, `GET /api/v1/ideas/{id}` and `DELETE /api/v1/ideas/{id}` as usual.
2. `assist.suggest_formats` `{character_id, niche?}` returns 1 to 3 `formats`
   (`format_id`, `title`, `subtitle`, `reason`). Without a niche the job fails `niche_required`.
   With your own idea instead: `assist.shape_idea` `{character_id, idea, niche?}` returns 1 to 3
   `formats` (same fields) each with 2 `takes` (`title`, `hook`, `summary`, `changes`), up to
   2 `questions` and a `note?`; answer them with `answers` and `previous` to improve the takes.
3. `assist.suggest_ideas` `{character_id, format_id, avoid?, angle?, seed?}` returns exactly 5
   `ideas` (`title`, `hook`, `summary`); `avoid` (up to 15 earlier titles) asks for new ones,
   `angle` (the format's `subtitle` and `reason`) and `seed` (your own idea) steer them.
4. `assist.develop_idea` `{character_id, format_id, idea, duration_seconds?}` returns `idea`:
   `title`, `hook`, `summary` and 3 to 8 `scenes` (`summary`, `line?`), and `duration_seconds`
   (yours, else the length the idea needs). `idea` is a suggested idea or `{text}`; `angle` and
   `seed` as in step 3. Revise
   with `previous` (the last `idea`) and `feedback`. Save the result as `state.developed`.
5. `POST /api/v1/ideas/{id}/project` `{duration_seconds?}` (default `state.duration_seconds`) creates the reel project (201);
   409 `idea_not_developed` without `state.developed`, `already_converted` once done.
6. `assist.write_reel_script` `{project_id, format_id, description}` with the developed
   idea as JSON in `description`; save `outputs.shots` with `PATCH /api/v1/projects/{id}`.

Formats: `knowledge_gap` (The knowledge gap), `belief_buster` (The belief buster), `confession` (The confession), `nobody_talks_about` (Nobody talks about this), `numbered_lessons` (Numbered lessons), `before_after` (Before and after), `story_cold_open` (The cold open story), `pov` (POV), `this_or_that` (This or that), `stop_doing_this` (Stop doing this), `wait_for_it` (Wait for it).

## Listing and filters

Every list returns `{<plural>, next_cursor}` (jobs: `data`; see Responses), newest first. Page with `limit` and `cursor`:
pass `next_cursor` back as `cursor` until it is null. Filters combine with paging.

- `created_after` (inclusive) and `created_before` (exclusive): ISO 8601 UTC times such as
  `2026-10-01T00:00:00Z`. A bad value is 400 `invalid_request`.
- `q`: case-insensitive contains-search on the name or title, at most 100 characters.

Query parameters per route:

- `GET /api/v1/wallet/history`: `cursor`, `limit`, `type`.
  - `type` (grant, capture, refund, adjust, expire): Only entries of this type.
- `GET /api/v1/characters`: `cursor`, `limit`, `q`, `created_after`, `created_before`.
- `GET /api/v1/library/characters`: `gender`, `age_band`, `niche`, `q`, `cursor`, `limit`.
  - `gender` (female, male, non_binary): Only this gender.
  - `age_band` (18_24, 25_34, 35_44, 45_54, 55_plus): Only this age band.
  - `niche`: Case-insensitive contains-search on the niche (the page concept).
- `GET /api/v1/library/audio`: `kind`, `tag`, `q`, `cursor`, `limit`.
  - `kind` (ambience, music): Only this kind.
  - `tag`: Only this tag.
- `GET /api/v1/assets`: `kind`, `project`, `mime`, `source`, `character`, `created_after`, `created_before`, `cursor`, `limit`.
  - `kind` (image, video, audio, file): Only this kind.
  - `project`: Only this project (`prj_...`).
  - `mime`: Exact media type (`image/png`) or a type prefix ending in a slash (`image/`); case-insensitive.
  - `source` (upload, job): Only uploaded assets, or only assets a job made.
  - `character`: Only assets of this character (`chr_...`): those in its reference sets (`refs`), plus those made by your jobs whose input has `character_id` naming it.
- `GET /api/v1/assets/{id}`: `download`.
  - `download` (0, 1): 1: as an attachment.
- `GET /api/v1/jobs`: `cursor`, `limit`, `status`, `tool`, `project`, `created_after`, `created_before`.
  - `status` (queued, running, waiting, succeeded, failed, cancelled): Only this status.
  - `tool`: Only jobs of this tool (offering id, e.g. `image.generate`).
  - `project`: Only jobs whose input has this `project_id` (`prj_...`).
- `GET /api/v1/projects`: `cursor`, `limit`, `kind`, `q`, `created_after`, `created_before`.
  - `kind` (reel, post): Only this kind.
- `GET /api/v1/ideas`: `cursor`, `limit`, `character`, `q`, `created_after`, `created_before`.
  - `character`: Only sessions for this character (`chr_...`).

## Responses

The top-level keys of each route's success body, generated from the OpenAPI document. Read
the record from the named key: `GET /api/v1/assets/{id}` is `{asset, download_url, ...}`, so
its state is `asset.status` and `asset.moderation`. Jobs are the one record sent bare.

- `GET /api/v1/health` 200: `{ok, version, db}`
- `GET /api/v1/me` 200: `{account, via, scopes}`
- `GET /api/v1/wallet` 200: `{available, held, by_kind, next_expiry, period}`
- `GET /api/v1/wallet/history` 200: `{entries, next_cursor}`
- `GET /api/v1/characters` 200: `{characters, next_cursor}`
- `POST /api/v1/characters` 201: `{character}`
- `GET /api/v1/characters/{id}` 200: `{character}`
- `PATCH /api/v1/characters/{id}` 200: `{character}`
- `DELETE /api/v1/characters/{id}` 204, no body
- `GET /api/v1/library/characters` 200: `{characters, next_cursor}`
- `GET /api/v1/library/characters/{id}` 200: `{character}`
- `GET /api/v1/library/characters/{id}/images/{slot}` 200: the bytes
- `POST /api/v1/library/characters/{id}/use` 201: `{character_id, character}`
- `GET /api/v1/library/audio` 200: `{audio, next_cursor}`
- `GET /api/v1/library/audio/{id}/preview` 200: the bytes
- `POST /api/v1/library/audio/{id}/use` 201: `{asset_id, library_id, asset}`
- `GET /api/v1/characters/{id}/voice-suggestion` 200: `{voice}`
- `GET /api/v1/voices` 200: `{voices}`
- `GET /api/v1/assets` 200: `{assets, next_cursor}`
- `POST /api/v1/assets/uploads` 201: `{asset_id, upload}`
- `POST /api/v1/assets/uploads/{id}/complete` 200: `{asset}`
- `GET /api/v1/assets/{id}` 200: `{asset, download_url, download_expires_at, preview}`
- `DELETE /api/v1/assets/{id}` 204, no body
- `GET /api/v1/assets/{id}/content` 302: a redirect to the bytes
- `GET /api/v1/jobs` 200: `{data, next_cursor}`
- `POST /api/v1/jobs` 200 or 202: the job itself (not wrapped)
- `GET /api/v1/jobs/{id}` 200: the job itself (not wrapped)
- `POST /api/v1/jobs/{id}/cancel` 200: the job itself (not wrapped)
- `GET /api/v1/offerings` 200: `{plan, signed_in, offerings, next_cursor}`
- `GET /api/v1/offerings/{id}` 200: `{id, version, category, title, summary, providers, ...}`
- `POST /api/v1/offerings/{id}/estimate` 200: `{offering, tier, estimate_credits, free_daily}`
- `GET /api/v1/projects` 200: `{projects, next_cursor}`
- `POST /api/v1/projects` 201: `{project}`
- `GET /api/v1/projects/{id}` 200: `{project}`
- `PATCH /api/v1/projects/{id}` 200: `{project}`
- `DELETE /api/v1/projects/{id}` 204, no body
- `GET /api/v1/projects/{id}/units` 200: `{units, estimate}`
- `POST /api/v1/projects/{id}/units/{unit}/run` 200 or 202: `{unit, tool, job_id, status, replayed}`
- `POST /api/v1/projects/{id}/units/{unit}/edit` 200 or 202: `{unit, tool, job_id, status, replayed}`
- `POST /api/v1/projects/{id}/build` 200 or 202: `{tool, job_id, status, replayed, draft_clips}`
- `POST /api/v1/projects/{id}/run-all/estimate` 200: `{estimate_credits, balance_credits, enough, units, finishing_credits, build_credits, ...}`
- `POST /api/v1/projects/{id}/run-all` 200 or 202: `{job_id, status, replayed, run}`
- `POST /api/v1/projects/{id}/finish-all/estimate` 200: `{estimate_credits, balance_credits, enough, units, finishing_credits, build_credits, ...}`
- `POST /api/v1/projects/{id}/finish-all` 200 or 202: `{job_id, status, replayed, run}`
- `POST /api/v1/projects/{id}/run-all/stop` 200: `{run}`
- `POST /api/v1/projects/{id}/cover` 200: `{project}`
- `POST /api/v1/projects/{id}/caption` 200: `{caption, hashtags}`
- `POST /api/v1/projects/{id}/units/{unit}/analyse` 200: `{unit}`
- `PUT /api/v1/projects/{id}/units/{unit}/items` 200: `{unit}`
- `POST /api/v1/projects/{id}/units/{unit}/transcribe` 200 or 202: `{unit, tool, job_id, status, replayed}`
- `POST /api/v1/projects/{id}/units/{unit}/select` 200: `{unit, warnings}`
- `POST /api/v1/clips/{id}/finish/estimate` 200: `{asset_id, draft, resolution, options}`
- `POST /api/v1/clips/{id}/finish` 200 or 202: `{asset_id, mode, tool, job_id, status, replayed, ...}`
- `GET /api/v1/ideas` 200: `{ideas, next_cursor}`
- `POST /api/v1/ideas` 201: `{idea}`
- `GET /api/v1/ideas/{id}` 200: `{idea}`
- `PATCH /api/v1/ideas/{id}` 200: `{idea}`
- `DELETE /api/v1/ideas/{id}` 204, no body
- `POST /api/v1/ideas/{id}/project` 201: `{project}`

## Account

- `GET /api/v1/me`: the account, its plan and the token's scopes.
- `PATCH /api/v1/me`: set the display name (1 to 60 characters); a browser session only.
- Deleting the account is browser-only (Settings in the web app).

## Chains

Short recipes; poll each job before using its `outputs`.

**A finished photo.** Improve the prompt, make the image, add realism, grade it, add text.
1. `assist.improve_prompt` `{target: "image.generate.prompt", prompt: "<rough idea>"}`
   gives `outputs.prompt`.
2. `image.generate` with that `prompt` gives `outputs.images[].asset_id`; pick one.
3. `image.details` `{source_asset_id, auto: "ai"}` gives `outputs.images[].asset_id`: the image
   with lived-in details; pick one.
4. `finish.develop` `{source_asset_id, auto: "ai"}` gives `outputs.asset_id`: the graded image.
5. `assist.suggest_text` `{image_asset_id}` gives three ideas; `finish.text` with your pick
   as `text` puts it on the image.

**A voice line to a talking clip.** `voice.take` `{text, voice_id}` returns `outputs.takes[]`
(`audio`, `duration_ms`); keep one. `video.talking` `{frame_asset_id, audio_asset_id,
audio_seconds}` (`duration_ms` / 1000, rounded up) returns `outputs.video`, the lip-synced clip.

**A reel from an idea.** Create the character, then `POST /api/v1/projects` (`kind: "reel"`,
`character_id`, `spec: {duration_seconds: 20}`). `assist.write_reel_script` `{project_id, description}`;
save `outputs.shots` with `PATCH /api/v1/projects/{id}`. Then run-all (estimate first), or run each unit.
To change a script, send it back: `assist.write_reel_script` `{project_id, description: <what to
change>, script: {shots, references}}` changes only that and returns `duration_seconds` too.

## Tool catalogue

Generated from the live registry. Credits below are what the example holds on each plan;
estimate your own input with `POST /api/v1/offerings/{id}/estimate`. Full JSON Schemas:
`GET /api/v1/offerings/{id}` (`input_schema`, `output_schema`). Do tools first, then assist
tools.

### Do tools

### `voice.take`: Voice take

Speaks a line with a preset voice and returns the audio plus character-level timestamps for captions. Sweep stability to get several takes to choose from.

- Plans: Free, Starter, Creator, Pro.
- Price: Per billed character, times the number of takes.
- Input:
  - `project_id` (string, optional): Tag the outputs with this project (`prj_...`) of yours.
  - `text` (string, required): What the voice says (1 to 2500 characters; whitespace is normalised).
  - `voice_id` (string, required): A `voice_id` from `GET /api/v1/voices`.
  - `model_id` (string, optional): Speech model id; leave out for the default.
  - `settings` (object, optional): Voice settings, 0 to 1.
  - `stability_sweep` (number[], optional): One take per stability value, labelled `sNN` (`s80` for 0.8).
  - `seed` (integer, optional): Seed shared by every take.
- Outputs: `takes` (object[])
- Example (One take), holds 1 credits on every plan that offers it:
  `{"offering":"voice.take","input":{"text":"Hello there, this is a test line.","voice_id":"21m00Tcm4TlvDq8ikWAM"}}`

### `voice.ambience`: Ambience bed

Generates a looping room or outdoor ambience bed from a text prompt, up to 30 seconds.

- Plans: Free, Starter, Creator, Pro.
- Price: Per minute of sound, prorated by the second.
- Input:
  - `project_id` (string, optional): Tag the outputs with this project (`prj_...`) of yours.
  - `prompt` (string, required): The sound of the place, in words.
  - `seconds` (number, default 12): Length in seconds (0.5 to 30).
  - `prompt_influence` (number, default 0.3): How closely to follow the prompt.
  - `loop` (boolean, default true): Make the bed loop seamlessly.
- Outputs: `audio` (string), `seconds` (number)
- Example (Rainy street), holds 5 credits on every plan that offers it:
  `{"offering":"voice.ambience","input":{"prompt":"light rain on a quiet city street at night"}}`

### `voice.music`: Music bed

Composes an instrumental music bed from a text prompt, 3 seconds to 5 minutes. Falls back to a looping sound effect (up to 30 s) when music is unavailable.

- Plans: Starter, Creator, Pro.
- Price: Per minute of music (the estimate rounds up to whole minutes, settlement uses the length generated).
- Input:
  - `project_id` (string, optional): Tag the outputs with this project (`prj_...`) of yours.
  - `prompt` (string, required): The mood, genre and instruments.
  - `seconds` (number, required): Length in seconds (3 to 300).
  - `instrumental` (boolean, default true): No vocals.
- Outputs: `audio` (string), `seconds` (number), `source` ("music" | "sound_effect")
- Example (Calm bed), holds 35 credits on every plan that offers it:
  `{"offering":"voice.music","input":{"prompt":"calm warm piano, slow, minimal","seconds":30}}`

### `voice.transcribe`: Transcribe speech

Writes down what is said in your own audio or video and times every word, so captions, text placement and lip-sync timing work on recordings you uploaded. Done once per asset; asking again is free.

- Plans: Free, Starter, Creator, Pro.
- Price: Per minute of audio; an asset already transcribed costs nothing.
- Input:
  - `project_id` (string, optional): Tag the outputs with this project (`prj_...`) of yours.
  - `asset_id` (string, required): Your own audio or video asset (an upload or a clip), up to 600 seconds.
  - `seconds` (number, required): Length of the asset in seconds (its `duration_ms` / 1000, rounded up); the credits held are priced from it, and the job fails before spending anything if the asset is longer.
  - `language_code` (string, optional): Language spoken (ISO 639, `en`); leave out to detect it.
- Outputs: `asset_id` (string), `alignment` (string), `text` (string), `language_code` (string | null), `word_count` (integer), `seconds` (number), `reused` (boolean)
- Example (A 30 second recording), holds 1 credits on every plan that offers it:
  `{"offering":"voice.transcribe","input":{"asset_id":"asset_00000000-0000-4000-8000-000000000001","seconds":30}}`

### `image.generate`: Generate an image

Text to image. Makes up to four variants of a prompt, each its own request with its own seed. Use it for scenes and B-roll; use image.edit when a character must stay recognisable.

- Plans: Free, Starter, Creator, Pro.
- Price: Per image; one image per variant. With `enhance` on (the default) the prompt is rewritten for the model first, adding 5 credits when the rewrite was used.
- Input:
  - `project_id` (string, optional): Tag the outputs with this project (`prj_...`) of yours.
  - `prompt` (string, required): What the image shows.
  - `aspect_ratio` ("9:16" | "4:5" | "1:1" | "16:9" | "3:4", default "9:16"): Shape of the image: 9:16 (reel), 4:5 (feed), 1:1, 16:9 or 3:4.
  - `variants` (integer, default 1): Separate images to make, 1 to 4.
  - `negative_hints` (string, optional): Appended to the prompt after a blank line (what to avoid, likeness hints).
  - `seed` (integer, optional): Seed of the first variant; later variants add a fixed stride. Random-ish when absent.
  - `enhance` (boolean, optional): Enhance prompt before generation (default true): the prompt is rewritten for the chosen model before it runs, adding 5 credits to the run (charged only when the rewrite was used). The rewritten prompt is never returned. false sends your prompt as written.
- Outputs: `images` (object[])
- Example (One vertical frame), holds 11 credits on every plan that offers it:
  `{"offering":"image.generate","input":{"prompt":"a quiet street at dusk, wet pavement, phone photo","aspect_ratio":"9:16"}}`

### `image.edit`: Edit with references

Makes new images that keep the look of up to ten reference images (a character, a place, a product). Use it for character frames; every output records the references as parents.

- Plans: Free, Starter, Creator, Pro.
- Price: Per image; one image per variant. With `enhance` on (the default) the prompt is rewritten for the model first, adding 5 credits when the rewrite was used.
- Input:
  - `project_id` (string, optional): Tag the outputs with this project (`prj_...`) of yours.
  - `prompt` (string, required): The scene to make, naming what each reference is.
  - `reference_asset_ids` (string[], required): 1 to 10 image assets the result must stay faithful to.
  - `aspect_ratio` ("9:16" | "4:5" | "1:1" | "16:9" | "3:4", default "9:16"): Shape of the image: 9:16 (reel), 4:5 (feed), 1:1, 16:9 or 3:4.
  - `variants` (integer, default 1): Separate images to make, 1 to 4.
  - `negative_hints` (string, optional): Appended to the prompt after a blank line (what to avoid, likeness hints).
  - `seed` (integer, optional): Seed of the first variant; later variants add a fixed stride. Random-ish when absent.
  - `enhance` (boolean, optional): Enhance prompt before generation (default true): the prompt is rewritten for the chosen model before it runs, adding 5 credits to the run (charged only when the rewrite was used). The rewritten prompt is never returned. false sends your prompt as written.
- Outputs: `images` (object[])
- Example (Character in a new scene), holds 11 credits on every plan that offers it:
  `{"offering":"image.edit","input":{"prompt":"The woman in the reference sits on a bus, lit by the window light.","reference_asset_ids":["asset_00000000-0000-4000-8000-000000000001"],"aspect_ratio":"9:16"}}`

### `image.details`: Realism pass

Adds small, scene-specific signs of life to an image while keeping framing, light and the person unchanged. Use it on a finished frame that looks too clean or too empty.

- Plans: Free, Starter, Creator, Pro.
- Price: Per edit; one image per variant. paste_back adds the subject mask (free when cached) and a small container charge per variant.
- Input:
  - `project_id` (string, optional): Tag the outputs with this project (`prj_...`) of yours.
  - `source_asset_id` (string, required): The image asset to add details to.
  - `auto` ("ai", optional): Set to `ai` to let Threadwright look at the image and choose the details, the scale and (unless you send `paste_back`) paste_back itself; only the edited images are returned. Leave `scale` out; send `items` to use your own details.
  - `items` (string[], optional): Scene-specific things to add, for example `a mug on the shelf`. Required unless `auto` is set.
  - `scale` ("small" | "scene", default "small"): `small` adds clutter; `scene` also adds distant passers-by to an empty place.
  - `variants` (integer, default 1): Separate edits to make, 1 or 2.
  - `paste_back` (boolean, default false): Paste the original subject back over each edit, so the person is pixel-identical to the source. Runs `image.mask` on the source (free when it was masked before) and needs that tool on your plan. With `auto`, `true` or `false` is your choice; leave it out to let Threadwright choose.
  - `analysis_job_id` (string, optional): Set by a project's Realism step: the image analysis it already made of this source (`job_...`), reused so the run makes and charges no new one. Needs `auto`.
  - `seed` (integer, optional): Seed of the first variant; later variants add a fixed stride. Random-ish when absent.
- Outputs: `images` (object[]), `mask_asset_id` (string | null), `paste_back` (boolean), `mask_credits` (integer), `container_run` (object | null)
- Example (Kitchen clutter), holds 6 credits on every plan that offers it:
  `{"offering":"image.details","input":{"source_asset_id":"asset_00000000-0000-4000-8000-000000000001","items":["a half-drunk mug by the sink","a tea towel over the oven handle"]}}`

### `image.mask`: Subject mask

A black and white mask of the main subject of an image (white is the subject). Asking again for the same image returns the earlier mask at no charge.

- Plans: Starter, Creator, Pro.
- Price: Per mask; a repeat for the same image is free.
- Input:
  - `project_id` (string, optional): Tag the outputs with this project (`prj_...`) of yours.
  - `source_asset_id` (string, required): The image asset to cut the subject out of.
- Outputs: `mask` (object), `cached` (boolean)
- Example (Mask a portrait), holds 1 credits on every plan that offers it:
  `{"offering":"image.mask","input":{"source_asset_id":"asset_00000000-0000-4000-8000-000000000001"}}`

### `image.revise`: Edit with words

Describe the change in words; the image is edited, the rest kept. In projects, use the unit edit route.

- Plans: Free, Starter, Creator, Pro.
- Price: One edited image at the edit's price plus 5 credits for working out your words; a failed edit is not charged.
- Input:
  - `project_id` (string, optional): Tag the outputs with this project (`prj_...`) of yours.
  - `source_asset_id` (string, required): The image to edit.
  - `instruction` (string, required): The change, in your own words.
  - `reference_asset_ids` (string[], optional): Character images; only the face and hair are taken from them.
  - `scene_references` (object[], optional): Places, outfits or objects in the image that stay exactly as they are.
  - `aspect_ratio` ("9:16" | "4:5" | "1:1" | "16:9" | "3:4", optional): Default: the source's shape.
  - `seed` (integer, optional): Seed of the first variant; later variants add a fixed stride. Random-ish when absent.
- Outputs: `images` (object[]), `source_asset_id` (string)
- Example (Change one thing), holds 11 credits on every plan that offers it:
  `{"offering":"image.revise","input":{"source_asset_id":"asset_00000000-0000-4000-8000-000000000001","instruction":"make her jacket red"}}`

### `image.reference`: Scene reference

Makes a project's scene reference (a place, an outfit or a key object) that linked frames stay faithful to. Run it as the project's reference unit.

- Plans: Free, Starter, Creator, Pro.
- Price: Per image at the routed image model's price; the first run of a script designer's reference is free.
- Input:
  - `project_id` (string, required): A project (`prj_...`) of yours.
  - `reference_id` (string, required): The scene reference of the project (`ref1`).
  - `reference_asset_ids` (string[], optional): Character images for an outfit reference; the face stays the same.
  - `aspect_ratio` ("9:16" | "4:5" | "1:1" | "16:9" | "3:4", default "9:16"): Shape of the image: 9:16 (reel), 4:5 (feed), 1:1, 16:9 or 3:4.
  - `variants` (integer, default 1): Separate images to make, 1 to 4.
  - `seed` (integer, optional): Seed of the first variant; later variants add a fixed stride. Random-ish when absent.
- Outputs: `images` (object[]), `reference_id` (string)
- Example (A location plate), holds 6 credits on every plan that offers it:
  `{"offering":"image.reference","input":{"project_id":"prj_00000000-0000-4000-8000-000000000001","reference_id":"ref1","aspect_ratio":"9:16"}}`

### `video.talking`: Talking clip

A talking clip from a frame and a voice take: the character speaks the audio, lip-synced. The default model, H3 768P, suits every shot, still or moving, and makes a final clip at 768P; the others are options. For voice takes of up to 15 seconds.

- Plans: Starter, Creator, Pro.
- Price: Per second of audio plus any held tail (H3 768P and H3 Max by resolution; a plate model adds its lip-sync pass, OmniHuman its upscale; some models bill whole seconds or by frame size), at least the model's minimum; padding, a tail or a frame-rate conversion adds a small container charge. With `enhance` on (the default) the prompt is rewritten for the model first, adding 5 credits when the rewrite was used.
- Models (`model`, credits per second of clip), recommended: `h3_fal` 8.6/s. Options: `h3_max` 7.2/s (still); `kling_avatar` 16.5/s (moving); `omnihuman` 17.9/s (moving); `ltx` 24.3/s (moving); `kling_v3_standard` 19.2/s (moving); `kling_v3_pro` 23.2/s (moving); `veo31_lite` 11.5/s (moving, until 2026-11-17); `veo31_fast` 21.5/s (moving, until 2026-11-17); `veo31` 35.8/s (moving, until 2026-11-17); `h3_hf` 18.6/s; `seedance2_hf` 56.7/s (moving)
- Input:
  - `project_id` (string, optional): Tag the outputs with this project (`prj_...`) of yours.
  - `model` ("h3_fal" | "h3_max" | "kling_avatar" | "omnihuman" | "ltx" | "kling_v3_standard" | "kling_v3_pro" | "veo31_lite" | "veo31_fast" | "veo31" | "h3_hf" | "seedance2_hf", default "h3_fal"): The talking model (`GET /api/v1/offerings/video.talking` lists them with prices): `h3_fal` (default) for every talking shot, still or moving, a final clip at 768P; the others are options (`h3_hf` the same model at 2K; `h3_max` at 480P is a draft to finish later).
  - `frame_asset_id` (string, required): The first frame: an image asset (`image.generate`).
  - `audio_asset_id` (string, required): The voice take: an audio asset of up to 15 seconds (`voice.take` or an upload); some models take less. A take under the model's minimum is padded with silence.
  - `audio_seconds` (number, required): Length of the audio in seconds (the asset's `duration_ms` / 1000, rounded up); the credits held are priced from it, and the job fails before spending anything if the audio is longer.
  - `tail_seconds` (number, default 0): Silence held after the last word, in seconds; counts toward the billed length.
  - `resolution` ("480P" | "768P" | "1080P" | "2K", optional): Output resolution of a model priced by it, one of its own sizes: `h3_fal` 480P, 768P, 2K (default 768P, final); `h3_max` 480P, 768P, 1080P, 2K (default 480P, a draft to finish later). Left out: the model's default. The plan sets the highest. Other models ignore it.
  - `prompt` (string, optional): How the character moves (or what happens around a still one); say it plainly (`she walks toward the camera`). H3 Max ignores it.
  - `enhance` (boolean, optional): Enhance prompt before generation (default true): the prompt is rewritten for the chosen model before it runs, adding 5 credits to the run (charged only when the rewrite was used). The rewritten prompt is never returned. false sends your prompt as written. H3 Max takes no prompt: nothing is rewritten or added.
- Outputs: `video` (string), `seconds` (number), `model` (string), `width` (number | null), `height` (number | null), `container_run` (object | null)
- Example (Ten-second clip at 768P (H3 Max)), holds 115 credits on every plan that offers it:
  `{"offering":"video.talking","input":{"model":"h3_max","frame_asset_id":"asset_00000000-0000-4000-8000-000000000001","audio_asset_id":"asset_00000000-0000-4000-8000-000000000002","audio_seconds":10,"resolution":"768P"}}`

### `video.broll`: B-roll clip

A short silent clip (Seedance 2 to 10 seconds, Kling 5 or 10) that moves a frame as the prompt says; optionally ends on a second frame. Use it for cutaways between talking shots. Seedance 1 Pro Fast by default; if it refuses the frame the clip is made on Kling 2.6 Pro instead.

- Plans: Starter, Creator, Pro.
- Price: The clip length (Seedance by video tokens, Kling per 5 s block). A chosen model holds its own price; a refused clip made on the fallback is charged at the fallback's price for the shortest length it makes that covers the clip (the hold is raised first). Without a model the hold covers the dearer of the two. With `enhance` on (the default) the prompt is rewritten for the model first, adding 5 credits when the rewrite was used.
- Models (`model`, credits per second of clip), recommended: `seedance_fast` 4.1/s (falls back to `kling_26`). Options: `kling_26` 10/s; `h3_hf` 18.6/s; `seedance2_hf` 56.7/s
- Input:
  - `project_id` (string, optional): Tag the outputs with this project (`prj_...`) of yours.
  - `model` ("seedance_fast" | "kling_26" | "h3_hf" | "seedance2_hf", optional): The B-roll model (`GET /api/v1/offerings/video.broll` lists them with prices): `seedance_fast` (falls back to `kling_26` when it refuses the frame), `kling_26`, or the options `h3_hf` and `seedance2_hf`. Sent: the hold is that model's price, raised if the fallback runs. Left out: `seedance_fast`, holding the dearer of `seedance_fast` and `kling_26`.
  - `frame_asset_id` (string, required): The first frame: an image asset.
  - `motion_prompt` (string, required): What moves, and how the camera moves.
  - `seconds` (integer, default 5): Clip length in whole seconds, one the model makes: `seedance_fast` 2 to 10, `kling_26` 5 or 10, `h3_hf` 5 to 15, `seedance2_hf` 4 to 15. A `seedance_fast` clip made on `kling_26` (a refusal or an end frame) is made and billed at the shortest Kling length that covers it.
  - `end_frame_asset_id` (string, optional): An image asset the clip must arrive at (the end frame). Kling, `h3_hf` and `seedance2_hf` take one: with `seedance_fast` the clip is made on `kling_26`.
  - `enhance` (boolean, optional): Enhance prompt before generation (default true): the prompt is rewritten for the chosen model before it runs, adding 5 credits to the run (charged only when the rewrite was used). The rewritten prompt is never returned. false sends your prompt as written.
- Outputs: `video` (string), `seconds` (number), `model` (string), `fallback_from` (string), `notice` (string), `width` (number | null), `height` (number | null)
- Example (Five-second push-in), holds 56 credits on every plan that offers it:
  `{"offering":"video.broll","input":{"frame_asset_id":"asset_00000000-0000-4000-8000-000000000001","motion_prompt":"slow push-in, the curtain moves in the breeze","seconds":5}}`

### `video.lipsync`: Lip-sync a clip

Re-syncs the mouth in an existing video to a voice take. When the lengths differ the video is trimmed, never the voice. Works best on a clip where the person faces the camera and talks calmly with the mouth clearly visible; a grin, laughing, or hair or hands over the mouth give weak sync.

- Plans: Starter, Creator, Pro.
- Price: Per second of audio.
- Input:
  - `project_id` (string, optional): Tag the outputs with this project (`prj_...`) of yours.
  - `video_asset_id` (string, required): The clip to re-sync: a video asset.
  - `audio_asset_id` (string, required): The voice take: an audio asset.
  - `audio_seconds` (number, required): Length of the audio in seconds (the asset's `duration_ms` / 1000, rounded up); the credits held are priced from it, and the job fails before spending anything if the audio is longer.
  - `model` ("sync_v2" | "latentsync", default "sync_v2"): `sync_v2` (default, best quality) or `latentsync` (cheaper).
- Outputs: `video` (string), `seconds` (number)
- Example (Re-sync a ten-second clip), holds 72 credits on every plan that offers it:
  `{"offering":"video.lipsync","input":{"video_asset_id":"asset_00000000-0000-4000-8000-000000000004","audio_asset_id":"asset_00000000-0000-4000-8000-000000000002","audio_seconds":10}}`

### `finish.develop`: Color grading

Grades an image like a phone photo: removes colour cast, tames chroma, flattens and lifts the blacks. Output is a PNG.

- Plans: Free, Starter, Creator, Pro.
- Price: A set price per graded image (catalog `run_credits`).
- Input:
  - `project_id` (string, optional): Tag the outputs with this project (`prj_...`) of yours.
  - `source_asset_id` (string, required): The image to grade (PNG, JPEG or WebP).
  - `look` (object, optional): Overrides of the default grade; leave out for the default look.
  - `auto` ("ai", optional): Set to `ai` to let Threadwright look at the image and choose the grade itself; only the graded image is returned. Leave `look` out.
- Outputs: `asset_id` (string), `source_asset_id` (string), `before` (object), `after` (object), `look` (object), `container_run` (object)
- Example (Default look), holds 10 credits on every plan that offers it:
  `{"offering":"finish.develop","input":{"source_asset_id":"asset_00000000-0000-4000-8000-000000000001"}}`

### `finish.text`: Text on image or video

Adds Instagram-style text to an image or video, inside the story, reel or feed safe zone and clear of the character's face and body.

- Plans: Free, Starter, Creator, Pro.
- Price: Priced by the size of the job (its length, and per variant where there are variants); estimate it first.
- Input:
  - `project_id` (string, optional): Tag the outputs with this project (`prj_...`) of yours.
  - `source_asset_id` (string, required): The image or video to put text on.
  - `text` (string, required): The text; line breaks are kept.
  - `background` ("white" | "black" | "none" | string, default "white"): Background of the text block: white or black label, none for bare text, or a #rrggbb colour.
  - `text_color` (string, optional): Text colour (#rrggbb); default black or white, whichever suits the background.
  - `font` ("DM Sans" | "Inter" | "Montserrat" | "Playfair Display" | "Bebas Neue" | "Caveat", optional): One of the bundled fonts; default DM Sans.
  - `variants` (1 | 2, default 1): 2 renders a second option (another placement when position is auto, else another background); both are charged.
  - `format` ("auto" | "story" | "reel" | "feed", default "auto"): Safe zone to stay inside; auto picks from the size.
  - `position` ("auto" | "top" | "upper" | "middle" | "lower" | "bottom", default "auto"): Where the block sits; auto looks for the quietest place.
  - `size` (number, optional): Font size in px; default scales with the canvas.
  - `weight` (integer, default 600): Font weight.
  - `start` (number, optional): Video only: when the text appears (s).
  - `end` (number, optional): Video only: when it disappears (s).
  - `mask_asset_id` (string, optional): A subject mask from image.mask (white = character); makes the placement avoid it.
  - `use_mask` (boolean, default true): false skips the mask entirely (and the image.mask charge).
- Outputs: `asset_id` (string), `source_asset_id` (string), `mask_asset_id` (string | null), `mask_credits` (integer), `placement` (object), `variants` (object[]), `container_run` (object)
- Example (Story caption on a photo), holds Free 1, Starter 2, Creator 2, Pro 2 credits:
  `{"offering":"finish.text","input":{"source_asset_id":"asset_00000000-0000-4000-8000-000000000001","text":"found this in the dryer","format":"story"}}`

### `finish.captions`: Caption files

Turns a voice take's word timings into subtitle files (or, for your own audio or video, transcribes it first): `.srt` (plain subtitles to upload to YouTube or TikTok) and `.ass` (styled captions for burning into the video). Assembly already burns captions in when your character has a caption style; use this when you want the files themselves.

- Plans: Free, Starter, Creator, Pro.
- Price: Free when you give alignments. A shot given as your own audio or video is transcribed first: per minute of speech, nothing if it was transcribed before.
- Input:
  - `project_id` (string, optional): Tag the outputs with this project (`prj_...`) of yours.
  - `shots` (object[], required): One entry per take, in play order; the pauses place each take on the timeline.
  - `style` (object, optional): Caption look; leave out for the default.
  - `width` (integer, default 1080): Canvas width the ASS file is made for.
  - `height` (integer, default 1920): Canvas height.
  - `language_code` (string, optional): Language spoken when a shot is transcribed (ISO 639); leave out to detect it.
- Outputs: `ass_asset_id` (string), `srt_asset_id` (string), `event_count` (integer), `transcribed_seconds` (number), `container_run` (object)
- Example (Two takes with a half-second pause), holds 0 credits on every plan that offers it:
  `{"offering":"finish.captions","input":{"shots":[{"alignment_asset_id":"asset_00000000-0000-4000-8000-000000000001","pause_after":0.5},{"alignment_asset_id":"asset_00000000-0000-4000-8000-000000000002"}]}}`

### `edit.assemble`: Assemble a cut

Joins voice takes, video clips and stills into a finished vertical video with room sound, ambience, music and captions. Draft is quick and marked; final is the real cut. Optional cover frames.

- Plans: Starter, Creator, Pro.
- Price: A set price per cut, draft or final (catalog `run_credits`), WAV conversions included, plus the caption burn-in price (catalog `caption_burn_in_credits`) when the cut burns in on-video captions: a caption look and at least one take with word timings.
- Input:
  - `project_id` (string, optional): Tag the outputs with this project (`prj_...`) of yours.
  - `spec` (object, required)
  - `draft` (boolean, default true): true: fast low-bitrate draft with a DRAFT mark; false: the final cut.
  - `cover_at` (number[], optional): Seconds in the finished cut to take cover frames (and 3:4 grid crops) from.
- Outputs: `video_asset_id` (string), `duration_ms` (integer | null), `width` (integer | null), `height` (integer | null), `draft` (boolean), `covers` (object[]), `warnings` (string[]), `log` (string[]), `container_run` (object)
- Example (Draft of one talking shot), holds 12 credits on every plan that offers it:
  `{"offering":"edit.assemble","input":{"spec":{"episode_no":1,"shots":[{"id":"s1","take_asset_id":"asset_00000000-0000-4000-8000-000000000001","alignment_asset_id":"asset_00000000-0000-4000-8000-000000000002","duration":5.2,"pause_after":0.4}],"units":[{"id":"s1","duration":5.6,"label":"talking","clip_asset_id":"asset_00000000-0000-4000-8000-000000000003"}],"edit":{"acoustics":"indoor_small"}}}}`

### `edit.audio_preview`: Audio preview

Renders only the sound of a timeline (voice, pauses, room, ambience, music, effects) as an mp3, quick and cheap, before assembling the video.

- Plans: Starter, Creator, Pro.
- Price: Priced by the size of the job (its length, and per variant where there are variants); estimate it first.
- Input:
  - `spec` (object, required)
  - `project_id` (string, optional): Tag the outputs with this project (`prj_...`) of yours.
- Outputs: `audio_asset_id` (string), `duration_ms` (integer | null), `warnings` (string[]), `container_run` (object)
- Example (One talking shot), holds 2 credits on every plan that offers it:
  `{"offering":"edit.audio_preview","input":{"spec":{"episode_no":1,"shots":[{"id":"s1","take_asset_id":"asset_00000000-0000-4000-8000-000000000001","alignment_asset_id":"asset_00000000-0000-4000-8000-000000000002","duration":5.2,"pause_after":0.4}],"units":[{"id":"s1","duration":5.6,"label":"talking"}]}}}`

### `project.reel`: Build a reel

Turns a reel project's selected takes, frames and clips into an audio preview, a quick draft, the final cut, or cover frames. Select an output for every unit first; the draft holds a frame where a clip is missing, the final needs every clip.

- Plans: Starter, Creator, Pro.
- Price: Priced by the size of the job (its length, and per variant where there are variants); estimate it first.
- Input:
  - `project_id` (string, required): A project (`prj_...`) of yours.
  - `step` ("draft" | "final" | "preview" | "covers", required): `preview`: the soundtrack as mp3; `draft`: quick marked cut; `final`: the real cut; `covers`: frame grabs from a finished cut.
  - `cover_at` (number[], optional): Seconds of the cut to grab covers (and 3:4 grid crops) from.
  - `video_asset_id` (string, optional): covers: the cut to grab from; default: the project's latest final, else draft.
  - `timeline_seconds` (number, optional): Length of the cut in seconds; required for preview, draft and final. `POST /projects/:id/build` fills it from the project's `duration_seconds`. Only sizes the credit hold of a preview.
  - `captions` (boolean, optional): draft and final: whether the cut burns in on-video captions. `false` burns none. Leave out (or `true`) and it burns them where the project has a caption look and a take with word timings. `POST /projects/:id/build` sets it from the project. The caption burn-in is held unless this is `false`, and charged only when captions were burned in.
- Outputs: `step` ("draft" | "final" | "preview" | "covers"), `video_asset_id` (string | null), `audio_asset_id` (string | null), `duration_ms` (integer | null), `covers` (object[]), `warnings` (string[]), `captions_burned` (boolean), `container_run` (object)
- Example (Audio preview), holds 1 credits on every plan that offers it:
  `{"offering":"project.reel","input":{"project_id":"prj_00000000-0000-4000-8000-000000000001","step":"preview","timeline_seconds":25}}`

### `project.post`: Finish a photo post

Grades the picked images of a photo-post project like phone photos (finish.develop) in post order and selects each result as the image's Grade pick. Pick an image for every image first (a Realism pick is used when there is one).

- Plans: Starter, Creator, Pro.
- Price: Priced by the size of the job (its length, and per variant where there are variants); estimate it first.
- Input:
  - `project_id` (string, required): A project (`prj_...`) of yours.
  - `step` ("finals", required): `finals`: develop the selected images and select the results.
  - `image_ids` (string[], required): Ids of the project's images to develop (each needs a selected image).
- Outputs: `step` ("finals"), `finals` (object[]), `container_run` (object)
- Example (Develop three images), holds 30 credits on every plan that offers it:
  `{"offering":"project.post","input":{"project_id":"prj_00000000-0000-4000-8000-000000000001","step":"finals","image_ids":["d1_morning","d1_midday","d1_night"]}}`

### `character.create`: Create a character

Guided creator for an original character: faces from a description (or upload yours), front, profile and full-body angles from that face (uploaded angles are not generated), optional places, then finalize writes the references. One step per job; review between steps. Real people are rejected; uploads need the upload declaration.

- Plans: Free, Starter, Creator, Pro.
- Price: The sum of the image calls the step runs (image.generate for faces, image.edit for angles and contexts); uploaded angles are not generated or charged, and finalize is free.
- Input:
  - `step` ("faces" | "angles" | "contexts" | "finalize", required): `faces`, then `angles` from a picked face, then `contexts`, then `finalize`.
  - `character_id` (string, optional): A character (`chr_...`) of yours; its description and hints are used.
  - `description` (string, optional): The character's look in words. Defaults to the character's description.
  - `negative_hints` (string, optional): Defaults to the character's.
  - `style_suffix` (string, optional): Defaults to the character's.
  - `n` (integer, optional): faces: how many candidate faces, 2 to 6 (default 4).
  - `face_asset_id` (string, optional): angles: the picked (or uploaded) face; not needed when all angles are uploaded.
  - `uploaded` (object, optional): angles: the angles you already have (your own image asset ids); only the others are generated, and with all three the step is free.
  - `front_asset_id` (string, optional): contexts: the locked front image.
  - `per_shot` (integer, optional): angles and contexts: images per angle or location, 1 or 2 (default 1).
  - `locations` (object[], optional): contexts: 0 to 3 places to show the character in (none is free).
  - `refs` (object, optional): finalize: the references to lock; `context` is optional.
  - `niche` (string | null, optional): finalize: the page concept you work in, in your own words; stored on the character (`null` clears it, leaving it out keeps it).
  - `upload_declaration` (object, optional): finalize: required when `refs` adds an uploaded image (or one made directly from an upload): the character is original, not a real person, and yours to use.
  - `seed` (integer, optional): Seed of the first variant; later variants add a fixed stride. Random-ish when absent.
- Outputs: `step` ("faces" | "angles" | "contexts" | "finalize"), `images` (object[]), `character` (object | null)
- Example (Four candidate faces), holds 23 credits on every plan that offers it:
  `{"offering":"character.create","input":{"step":"faces","description":"An original fictional woman aged 28, oval face, high cheekbones, dark almond eyes, straight eyebrows, warm olive skin, long dark wavy hair.","n":4}}`

### Assist tools

Send your own content, read the improved or analyzed result from `outputs`, then pass it
to a do tool yourself.

### `assist.improve_prompt`: Improve prompt

Turns a rough idea into text ready for one field of a tool (an image prompt, a motion prompt, a voice script, a character description). Returns only the improved text (and a recommended `model` for clip motion prompts).

- Plans: Free, Starter, Creator, Pro.
- Price: Priced per call; `POST /api/v1/offerings/{id}/estimate` shows the hold. A declined or unusable answer is not charged.
- Input:
  - `target` ("image.generate.prompt" | "image.edit.prompt" | "video.talking.prompt" | "video.broll.motion_prompt" | "voice.take.text" | "voice.ambience.prompt" | "voice.music.prompt" | "character.create.description" | "project.shot.first_frame_prompt" | "project.shot.motion_prompt" | "project.shot.audio", required): The tool field the text is for, `<tool>.<field>`.
  - `prompt` (string, required): Your rough text or idea, in your own words.
  - `character_id` (string, optional): A character (`chr_...`) of yours the text is for.
  - `image_asset_id` (string, optional): An image of yours to take into account (image and video targets only).
- Outputs: `prompt` (string), `model` (string)
- Example (Improve an image prompt), holds 1 credits on every plan that offers it:
  `{"offering":"assist.improve_prompt","input":{"target":"image.generate.prompt","prompt":"girl at a cafe in paris, morning, cozy"}}`

### `assist.suggest_text`: Suggest text

Looks at one of your images and suggests three short texts to put on it, the way people caption a story or a reel cover. Use one as `text` in `finish.text`.

- Plans: Free, Starter, Creator, Pro.
- Price: Priced per call; `POST /api/v1/offerings/{id}/estimate` shows the hold. A declined or unusable answer is not charged.
- Input:
  - `image_asset_id` (string, required): The image the text goes on.
  - `hint` (string, optional): A topic, a mood or a draft to improve; leave out for open ideas.
- Outputs: `ideas` (string[])
- Example (Ideas for a photo), holds 1 credits on every plan that offers it:
  `{"offering":"assist.suggest_text","input":{"image_asset_id":"asset_00000000-0000-4000-8000-000000000001"}}`

### `assist.write_reel_script`: Write a reel script

Writes every shot of a reel (spoken lines, first-frame and motion prompts) from a short description, or edits the current script (`script`) as the description asks. The shots are valid as a reel project's `spec.shots`.

- Plans: Free, Starter, Creator, Pro.
- Price: Priced per call; `POST /api/v1/offerings/{id}/estimate` shows the hold. A declined or unusable answer is not charged.
- Input:
  - `project_id` (string, optional): A reel project of yours; it gives the character, the length and the ref sets.
  - `description` (string, required): What the video is about, in your own words.
  - `duration_seconds` (number, optional): Length of the reel (overrides the project's). Without it and without a length on the project, the catalog default is used.
  - `character_id` (string, optional): The character who stars; required without `project_id`.
  - `format_id` ("knowledge_gap" | "belief_buster" | "confession" | "nobody_talks_about" | "numbered_lessons" | "before_after" | "story_cold_open" | "pov" | "this_or_that" | "stop_doing_this" | "wait_for_it", optional): A format from `assist.suggest_formats`: the hook lands in the first 3 seconds and the script follows the format's order.
  - `script` (object, optional): The current script to edit (the project's `spec.shots` and `spec.references`, saved or not). With it, `description` says what should change: only that changes, every other shot comes back as it is, and the output carries the new `duration_seconds`.
- Outputs: `shots` (object[]), `references` (object[]), `sound` (object), `duration_seconds` (number)
- Example (Script for a project), holds 2 credits on every plan that offers it:
  `{"offering":"assist.write_reel_script","input":{"project_id":"prj_00000000-0000-4000-8000-000000000001","description":"She gets an office job and copies the humans."}}`

### `assist.write_post_plan`: Plan a photo post

Plans a photo post (one image or a carousel) from a one-line idea: the aspect, every image's prompt, its refs and realism details, and the scene references the images share. The result is valid as a post project's `spec`.

- Plans: Free, Starter, Creator, Pro.
- Price: Free up to a number of runs per day (UTC) by plan: Free 10, Starter 10, Creator 10, Pro 10. Past it, about 1 credit a run. The estimate shows the next run's price and `free_daily` (`limit`, `used`, `remaining`, `resets_at`).
- Input:
  - `project_id` (string, optional): A photo post project of yours; it gives the character, and the scene references are kept for it.
  - `idea` (string, required): What the post is about, in one line or two, in your own words.
  - `count` (integer, optional): How many images (1 is a single photo, more is a carousel); default: the plan decides.
  - `aspect` ("9:16" | "4:5" | "1:1" | "16:9" | "3:4", optional): The post's aspect; default: the plan decides.
  - `character_id` (string, optional): The character who stars; required without `project_id`.
- Outputs: `aspect` ("9:16" | "4:5" | "1:1" | "16:9" | "3:4"), `images` (object[]), `references` (object[])
- Example (Plan for a project), holds 0 credits on every plan that offers it:
  `{"offering":"assist.write_post_plan","input":{"project_id":"prj_00000000-0000-4000-8000-000000000001","idea":"A slow Sunday morning at home, as a carousel.","count":4}}`

### `assist.suggest_formats`: Suggest video formats

Suggests 1 to 3 proven short-video formats that suit a character's niche, each with a one-line take for that niche and the reason behind the pick. Uses the character's `niche` unless you send one; with neither the job fails `niche_required`. Free up to a daily number of runs, then credit-priced.

- Plans: Free, Starter, Creator, Pro.
- Price: Free up to a number of runs per day (UTC) by plan: Free 20, Starter 40, Creator 100, Pro 100. Past it, about 1 credit a run. The estimate shows the next run's price and `free_daily` (`limit`, `used`, `remaining`, `resets_at`).
- Input:
  - `character_id` (string, required): The character (`chr_...`) of yours the page is for.
  - `niche` (string, optional): The page concept, in your own words; overrides the character's `niche`.
- Outputs: `formats` (object[])
- Example (Formats for a page), holds 0 credits on every plan that offers it:
  `{"offering":"assist.suggest_formats","input":{"character_id":"chr_00000000-0000-4000-8000-000000000001","niche":"Calm home cooking for busy students"}}`

### `assist.shape_idea`: Shape my idea

Turns your own idea into 1 to 3 proven short-video formats that tell it best: each with a one-line take, the reason it fits and exactly 2 takes (a faithful one and a bolder one), plus up to 2 questions. Send `answers` with the earlier result as `previous` to improve the takes. `niche` is optional. Free up to a daily number of runs, then credit-priced.

- Plans: Free, Starter, Creator, Pro.
- Price: Free up to a number of runs per day (UTC) by plan: Free 20, Starter 40, Creator 100, Pro 100. Past it, about 1 credit a run. The estimate shows the next run's price and `free_daily` (`limit`, `used`, `remaining`, `resets_at`).
- Input:
  - `character_id` (string, required): The character (`chr_...`) of yours who stars.
  - `idea` (string, required): Your own idea for a video, in your own words: a few words up to a rough script.
  - `niche` (string, optional): The page concept, in your own words; overrides the character's `niche`.
  - `answers` (object[], optional): Your answers to the `questions` of an earlier run; sent with `previous`.
  - `previous` (object, optional): The earlier result (`outputs`) to improve with `answers`.
- Outputs: `formats` (object[]), `questions` (string[]), `note` (string)
- Example (Shape your own idea), holds 0 credits on every plan that offers it:
  `{"offering":"assist.shape_idea","input":{"character_id":"chr_00000000-0000-4000-8000-000000000001","idea":"She finds out her grandmother's soup recipe has one secret ingredient."}}`

### `assist.suggest_ideas`: Suggest video ideas

Writes 5 video ideas in one format for a character's niche: a title, the opening hook and a short summary each. `avoid` (earlier titles) asks for new ones; `angle` (the format's reason) and `seed` (your own idea) steer them. Free up to a daily number of runs, then credit-priced.

- Plans: Free, Starter, Creator, Pro.
- Price: Free up to a number of runs per day (UTC) by plan: Free 20, Starter 40, Creator 100, Pro 100. Past it, about 1 credit a run. The estimate shows the next run's price and `free_daily` (`limit`, `used`, `remaining`, `resets_at`).
- Input:
  - `character_id` (string, required): The character (`chr_...`) of yours who stars.
  - `format_id` ("knowledge_gap" | "belief_buster" | "confession" | "nobody_talks_about" | "numbered_lessons" | "before_after" | "story_cold_open" | "pov" | "this_or_that" | "stop_doing_this" | "wait_for_it", required): A format id, as `assist.suggest_formats` returns it.
  - `niche` (string, optional): The page concept, in your own words; overrides the character's `niche`.
  - `avoid` (string[], optional): Titles of ideas you already have (at most 15); the new ones differ from them.
  - `angle` (string, optional): Why the format was picked: the format's `subtitle` and `reason` from `assist.suggest_formats`, joined. The ideas build on it.
  - `seed` (string, optional): Your own idea, in your own words, when the ideas are built from it.
- Outputs: `ideas` (object[])
- Example (Ideas in a format), holds 0 credits on every plan that offers it:
  `{"offering":"assist.suggest_ideas","input":{"character_id":"chr_00000000-0000-4000-8000-000000000001","format_id":"belief_buster"}}`

### `assist.develop_idea`: Develop a video idea

Develops one idea into a plan: title, hook, summary and 3 to 8 scenes, for your `duration_seconds` or the length the idea needs. `previous` and `feedback` revise it; `angle` and `seed` carry the format's reason and your idea. Fits `assist.write_reel_script`'s `description`. Free up to a daily number of runs, then credit-priced.

- Plans: Free, Starter, Creator, Pro.
- Price: Free up to a number of runs per day (UTC) by plan: Free 20, Starter 40, Creator 100, Pro 100. Past it, about 1 credit a run. The estimate shows the next run's price and `free_daily` (`limit`, `used`, `remaining`, `resets_at`).
- Input:
  - `character_id` (string, required): The character (`chr_...`) of yours who stars.
  - `format_id` ("knowledge_gap" | "belief_buster" | "confession" | "nobody_talks_about" | "numbered_lessons" | "before_after" | "story_cold_open" | "pov" | "this_or_that" | "stop_doing_this" | "wait_for_it", required): A format id, as `assist.suggest_formats` returns it.
  - `idea` (object | object, required): A suggested idea (`title`, `hook?`, `summary`) or your own (`text`).
  - `duration_seconds` (number, optional): Length of the reel in seconds. Send it when you have chosen one: the plan fits it. Left out, the plan picks the length the idea needs (the default unless it needs more) and returns it as `duration_seconds`.
  - `angle` (string, optional): Why the format was picked: the format's `subtitle` and `reason` from `assist.suggest_formats`, joined. The ideas build on it.
  - `seed` (string, optional): Your own idea, in your own words, when the ideas are built from it.
  - `previous` (object, optional): The developed idea to revise (`outputs.idea` of an earlier run).
  - `feedback` (string, optional): What to change in `previous`; everything else is kept.
- Outputs: `idea` (object), `duration_seconds` (number)
- Example (Develop your own idea), holds 0 credits on every plan that offers it:
  `{"offering":"assist.develop_idea","input":{"character_id":"chr_00000000-0000-4000-8000-000000000001","format_id":"story_cold_open","idea":{"text":"She gets lost at a flea market and finds an old camera."},"duration_seconds":20}}`

### `assist.recommend_model`: Recommend a model

Picks the clip model that suits a shot: send what you know of it, or a project clip `unit`. Returns `model` for the tool's `model` or a shot's `clip_model`. Clear cases are free.

- Plans: Free, Starter, Creator, Pro.
- Price: Free up to a number of runs per day (UTC) by plan: Free 30, Starter 80, Creator 200, Pro 200. Past it, about 1 credit a run. The estimate shows the next run's price and `free_daily` (`limit`, `used`, `remaining`, `resets_at`).
- Input:
  - `tool` ("video.talking" | "video.broll", required): The clip tool the model is for.
  - `motion_prompt` (string, optional): The clip's motion prompt.
  - `line` (string, optional): Talking: what the character says.
  - `seconds` (number, optional): The clip's length, if known.
  - `motion` ("still" | "moving", optional): Talking: the character holds `still` or is `moving`.
  - `quality` ("draft" | "standard" | "high", optional): How much quality matters.
  - `character_id` (string, optional): A character (`chr_...`) of yours the text is for.
  - `project_id` (string, optional): A reel project of yours; its `unit` shot is read.
  - `unit` (string, optional): With `project_id`: a clip unit (`shot01.clip`).
- Outputs: `model` (string)
- Example (A talking shot), holds 0 credits on every plan that offers it:
  `{"offering":"assist.recommend_model","input":{"tool":"video.talking","motion_prompt":"She walks along the beach towards the camera, talking."}}`

## Browser-only routes

A token gets 403 `session_required` here: the web app does sign-in, billing and deletion.

- `POST /api/v1/client-errors`: Report a browser error (web app)
- `POST /auth/email/start`: Email a sign-in link (browser sign-up and sign-in)
- `GET /auth/google`: Start Google sign-in (browser redirect)
- `POST /auth/sign-out`: End the browser session
- `PATCH /api/v1/me`: Set the display name (1 to 60 characters, trimmed)
- `DELETE /api/v1/me`: Delete the account: cancels the subscription, revokes all access and deletes content; financial records are kept for the retention period
- `POST /api/v1/me/terms`: Accept the current Terms of Service and Privacy Policy
- `GET /api/v1/tokens`: List live access tokens
- `POST /api/v1/tokens`: Create an access token (the secret is shown once)
- `DELETE /api/v1/tokens/{id}`: Revoke an access token
- `POST /api/v1/billing/checkout`: Start a Stripe Checkout session for a tier subscription or a credit bundle
- `POST /api/v1/billing/free-cta`: Log a free reel offer view or click
- `POST /api/v1/billing/portal`: Open the Stripe Customer Portal for the signed-in account
- `GET /api/v1/billing/subscription`: The account's current plan and Stripe subscription state
- `GET /api/v1/projects/free-plan`: The Free plan's one guided reel: its project, redos left and the recipe

## Errors

Every error is `{"error": {"code": "snake_case", "message": "...", "details": {}}}`
with a matching HTTP status. Act on `code`, not on `message`.

| Status | Codes | What to do |
|---|---|---|
| 400 | `invalid_request`, `invalid_json`, `invalid_input` | Fix the body or query; `details.issues` names the fields. |
| 400 | `invalid_units`, `unknown_tier` | Change the input or the plan name. |
| 400 | `invalid_cursor`, `invalid_idempotency_key` | Restart paging; use 1 to 255 visible ASCII characters. |
| 401 | `unauthorized` | Token missing, wrong, revoked or expired: create a new one. |
| 402 | `insufficient_credits` | `details.required` credits are needed: top up or pick a cheaper input. |
| 403 | `forbidden` | The token lacks a scope (`details.missing_scopes`). |
| 403 | `tool_not_in_plan`, `resolution_not_in_plan` | Your plan does not offer this; see `GET /api/v1/offerings`. |
| 403 | `session_required`, `csrf_failed` | Browser-only route: tokens cannot use it. |
| 403 | `asset_blocked` | The upload broke the Acceptable Use Policy; it cannot be used. |
| 404 | `unknown_offering`, `*_not_found` | Check the id; ids are only visible to their account. |
| 409 | `idempotency_conflict` | The key was used for a different body: use a new key. |
| 409 | `job_not_cancellable`, `asset_not_ready`, `upload_incomplete` | Wait or re-check the state, then retry. |
| 409 | `missing_upstream`, `character_incomplete`, `wrong_kind` | A project unit needs an earlier unit selected, or the character lacks references (no voice is fine: one is picked). |
| 410, 413, 415, 422 | `upload_expired`, `upload_too_large`, `storage_quota_exceeded`, `unsupported_media_type`, `upload_mismatch`, `content_mismatch` | Start a new upload that fits the plan and the declared type. |
| 422 | `invalid_spec`, `invalid_character`, `invalid_voice`, `invalid_refs`, `invalid_look`, `invalid_captions`, `invalid_routing` | A field does not fit; `message` names it. |
| 422 | `declaration_required` | Send `upload_declaration` with uploaded reference images (see Characters). |
| 422 | `not_a_candidate`, `wrong_asset_kind` | Select an output of the unit's own jobs, or a ready upload of the right kind. |
| 404, 409 | `idea_not_found`, `idea_not_developed`, `already_converted`, `character_required` | Idea sessions: save `state.developed` and a character first; a session converts once. |
| 429 | `rate_limited` | Wait for `Retry-After` seconds, then retry. |
| 503 | `enqueue_failed` | The job failed before it was queued (credits released): submit again with a new `Idempotency-Key`. |
| 500 | `internal_error` | Retry later with the same `Idempotency-Key`. |

## Rate limits

Requests are rate limited per account (per IP when anonymous): a 429 `rate_limited` carries
`Retry-After`. Jobs beyond capacity are not refused; they wait in `queued` or `waiting`.

## Do and do not

- Do send an `Idempotency-Key` with every submit, and the same one when you retry it.
- Do store asset ids, not download URLs: URLs expire within minutes.
- Do estimate before large or repeated jobs, and check the wallet.
- Do not put the token in URLs, query strings, files you share or logs.
- Do not retry 4xx answers unchanged (except 429); fix the request first.
