# Characters

> Create, update, archive and restore characters over REST, generate portrait candidates, expressions, angles and motion clips, and approve the anchor portrait.

Source: https://nodaro.ai/docs/developers/api/characters

The **Characters API** scripts everything Character Studio does. You create a character, generate portrait candidates, approve one as the anchor portrait, and add expressions, angles, poses, lighting variants and motion clips. Image and video nodes then reuse the character, so the same person looks the same in every shot.

The routes work on every edition. They take a bearer token: a personal API token (`ndr_…`), an OAuth app token (`ndr_app_…`), or your session token on Community edition. Every route is scoped to the caller: you only see and change your own characters. See [Authentication](https://nodaro.ai/docs/developers/api/authentication).

## Endpoints

| Method | Path | What it does |
| --- | --- | --- |
| `GET` | `/v1/characters` | List your characters, one page at a time. |
| `GET` | `/v1/characters/:id` | Get one character with its jobs in progress. |
| `POST` | `/v1/characters` | Create a character, or update one when the body has an `id`. |
| `POST` | `/v1/characters/:id/duplicate` | Copy a character to a new one named with a `(copy)` suffix. |
| `DELETE` | `/v1/characters/:id` | Archive a character. It can be restored. |
| `POST` | `/v1/characters/:id/restore` | Restore an archived character. |
| `GET` | `/v1/characters/:id/usage` | Count and list the workflows that use the character. |
| `POST` | `/v1/generate-character` | Generate 1 to 10 portrait candidates. |
| `POST` | `/v1/generate-character-asset` | Generate one expression, pose, angle or lighting variant. |
| `POST` | `/v1/generate-character-motion` | Animate the character into a motion clip. |
| `POST` | `/v1/characters/:id/approve-portrait` | Approve a candidate as the portrait and write the character's description. |
| `POST` | `/v1/characters/:id/llm-caption` | Write the description again from the current portrait. |

Training a dedicated model on a character is a Nodaro Cloud feature with its own routes. See [Character training](https://nodaro.ai/docs/developers/api/character-training).

## What a character holds

A character is one saved identity. The fields below come back from `GET /v1/characters/:id` in camelCase.

| Field | What it holds |
| --- | --- |
| `id`, `name` | The identifier and the display name. Names are unique per account, ignoring case. |
| `description`, `gender`, `style`, `baseOutfit` | Identity notes that shape every generated image of the character. |
| `seedPrompt` | A short prompt that frames the portrait, up to 4,000 characters. |
| `sourceImageUrl` | The anchor portrait. It is set when you approve a candidate. |
| `canonicalDescription` | A visual description of about 80 to 120 words, written by Nodaro when the portrait is approved. Prompts that reference the character include it. |
| `expressions`, `poses`, `angles`, `bodyAngles`, `lightingVariations`, `motions` | The asset buckets. Each entry is `{ name, url }`. |
| `referencePhotos` | Up to 20 real photos, each tagged with its framing. |
| `realLifeRefsByVariant`, `referenceVideosByVariant` | Extra reference photos or clips for one variant, for example the `smile` expression. |
| `person`, `wardrobe` | Structured appearance and wardrobe choices, as set on the Pickers page of Character Studio. |
| `voice`, `personality` | The character's voice and personality. |
| `identityLock` | How strictly generated assets keep the face: `off`, `soft` or `strict`. The default is `strict`. |
| `deletedAt` | Set when the character is archived. |

### The asset buckets

Each bucket holds variants of the anchor portrait. You can name a variant anything; these are the preset names.

| Bucket | What it shows | Preset variants |
| --- | --- | --- |
| `expressions` | Head and shoulders, a different emotion | neutral, smile, angry, surprised, sad, talking, laughing, disgusted, fearful, smirk, crying |
| `angles` | Head and shoulders from another camera angle | front, 3/4 left, left profile, right profile, 3/4 right |
| `bodyAngles` | Full body from another angle, arms relaxed | front, 3/4 left, left profile, right profile, 3/4 right, back |
| `poses` | Full body in another posture | standing, walking, sitting, running, crouching, pointing, fighting stance, jumping, turning |
| `lightingVariations` | The same pose under other light | daylight, night, dramatic |
| `motions` | Video clips of the character moving | Any label, for example walking or head turn |

## List characters

`GET /v1/characters` returns one page of your characters, newest first. Keep requesting pages until `nextCursor` is `null`: a single response is never "all characters" for an account above the page size.

| Query parameter | What it does |
| --- | --- |
| `limit` | Rows per page. The default is 100 and the maximum is 500. |
| `cursor` | The `nextCursor` of the previous page. |
| `projectId` | Only characters of one project. |
| `archived` | `true` lists archived characters instead. |

**curl**

```bash
CURSOR=""
while :; do
PAGE=$(curl -s "https://app.nodaro.ai/v1/characters?limit=100${CURSOR:+&cursor=$CURSOR}" \
    -H "Authorization: Bearer $NODARO_API_KEY")
echo "$PAGE" | jq -r '.characters[] | "\(.id) \(.name)"'
CURSOR=$(echo "$PAGE" | jq -r '.nextCursor // empty')
[ -z "$CURSOR" ] && break
done
```

**TypeScript SDK**

```ts

const client = createClient({
baseUrl: 'https://app.nodaro.ai',
auth: new StaticTokenAuth(process.env.NODARO_API_KEY!),
})

const all = []
let cursor: string | undefined
do {
const page = await client.characters.list({ limit: 100, cursor })
all.push(...page.characters)
cursor = page.nextCursor ?? undefined
} while (cursor)
```

**CLI**

```bash
nodaro characters list --limit 100 --json
nodaro characters list --archived
```

```json
{
"characters": [
{
"id": "3f6c2a9e-8d41-4b7a-9c35-1e2f7a6b0d94",
"name": "Kira",
"description": "young protagonist with auburn hair",
"sourceImageUrl": "https://cdn.nodaro.ai/characters/kira-portrait.png",
"expressions": [{ "name": "smile", "url": "https://cdn.nodaro.ai/characters/kira-smile.png" }]
}
],
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA5LTIwVDEwOjAwOjAxWiJ9"
}
```

The cursor is opaque. Pass back only a `nextCursor` the server gave you, and never store one across releases. A malformed cursor is a `400 validation_error`, not a silent return to the first page. Characters created while you page are not included; start again without a cursor to see them.

## Create or update a character

`POST /v1/characters` creates a character when the body has no `id`, and updates the character when it has one. A create needs `nodeId` and `name`. `nodeId` links the character to a canvas node; use any label, such as `"scripted"`, when you create it from code.

On an update, only the fields you send are written. Omitted fields keep their values, so a save never overwrites asset buckets that a running job is filling. Send an asset bucket only when you mean to replace it.

**curl**

```bash
curl -X POST https://app.nodaro.ai/v1/characters \
  -H "Authorization: Bearer $NODARO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
"nodeId": "scripted",
"name": "Kira",
"description": "young protagonist with auburn hair",
"style": "realistic",
"seedPrompt": "kira portrait, warm natural lighting"
}'
```

**TypeScript SDK**

```ts
const { id } = await client.characters.create({
nodeId: 'scripted',
name: 'Kira',
description: 'young protagonist with auburn hair',
style: 'realistic',
seedPrompt: 'kira portrait, warm natural lighting',
})

await client.characters.update(id, { gender: 'female', identityLock: 'soft' })
```

**CLI**

```bash
nodaro characters create --name "Kira" \
  --description "young protagonist with auburn hair" \
  --style realistic --seed-prompt "kira portrait, warm natural lighting"

nodaro characters update <id> --gender female
```

```json
{ "id": "3f6c2a9e-8d41-4b7a-9c35-1e2f7a6b0d94", "name": "Kira" }
```

<TypeTable
type={{
id: { type: 'string (uuid)', description: 'The character to update. Omit it to create a character.' },
nodeId: { type: 'string', description: 'Required on create. The canvas node the character belongs to, or any label when there is none.' },
name: { type: 'string', description: 'Required on create. 1 to 200 characters. A name already used by another active character returns 409 name_taken.' },
description: { type: 'string', description: 'Identity notes, up to 2,000 characters.' },
gender: { type: 'string', description: 'Up to 50 characters.' },
style: { type: 'string', description: 'The visual style, for example realistic, anime, 3d-pixar or illustration.' },
baseOutfit: { type: 'string', description: 'The default outfit, up to 1,000 characters.' },
seedPrompt: { type: 'string', description: 'A short prompt that frames the portrait, up to 4,000 characters. A longer value returns 400.' },
identityLock: { type: "'off' | 'soft' | 'strict'", description: 'How strictly generated assets keep the face.', default: 'strict' },
referencePhotos: { type: 'array', description: 'Up to 20 { url, kind } photos. kind is frontFace, sideLeft, sideRight, threeQuarterLeft, threeQuarterRight, frontBody or other. Each kind except other may appear once.' },
realLifeRefsByVariant: { type: 'object', description: 'Reference photo URLs per variant, for example { "smile": [url] }. Up to 20 keys and 5 URLs per key. Keys are trimmed and lowercased.' },
referenceVideosByVariant: { type: 'object', description: 'Reference clip URLs per label, with the same limits. Read them back to feed referenceVideoUrls on Generate Video.' },
person: { type: 'object', description: "Structured appearance choices (hair, eyes, build, age and more). Added to the prompts of the character's own portrait and assets." },
wardrobe: { type: 'object', description: 'Structured wardrobe choices (top, bottom, footwear, palette, era and more). Added to the same prompts.' },
voice: { type: 'object | null', description: 'The voice: { voiceId, voiceName, traits, voiceType?, ttsProvider? }. Send null to clear it.' },
personality: { type: 'object | null', description: '{ mood, speechStyle, movementStyle, behavioralNotes }.' },
canonicalDescription: { type: 'string', description: 'Replace the written description, up to 4,000 characters.' },
projectId: { type: 'string (uuid)', description: 'The project to file the character in.' },
}}
/>

`person` and `wardrobe` shape only the character's own portrait and asset generations. They use the same catalogs as the [Person](https://nodaro.ai/docs/nodes/creative-controls/person) picker; see [Picker catalogs](https://nodaro.ai/docs/developers/picker-catalogs).

When a workflow runs, the character's `voice` fills in any [Text to Speech](https://nodaro.ai/docs/nodes/audio/text-to-speech) node connected after the character: the voice, the voice type and the recommended model. A value set on the Text to Speech node itself wins.

## Generate portrait candidates

`POST /v1/generate-character` starts one job per candidate and returns their ids at once. Poll each job with the [Jobs API](https://nodaro.ai/docs/developers/api/jobs) until it is `completed`.

With `attachToCharacterId`, the first candidate to finish becomes the character's portrait. For a single candidate that is all you need. For several candidates, approve the one you prefer; approval replaces the portrait.

**curl**

```bash
curl -X POST https://app.nodaro.ai/v1/generate-character \
  -H "Authorization: Bearer $NODARO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
"name": "Kira",
"seedPrompt": "kira portrait, warm natural lighting",
"count": 4,
"attachToCharacterId": "3f6c2a9e-8d41-4b7a-9c35-1e2f7a6b0d94"
}'
```

**TypeScript SDK**

```ts
const { jobIds } = await client.characters.generate({
name: 'Kira',
seedPrompt: 'kira portrait, warm natural lighting',
count: 4,
attachToCharacterId: id,
})
```

**CLI**

```bash
nodaro characters generate <id> --count 4 \
  --seed-prompt "kira portrait, warm natural lighting" --watch
```

```json
{
"jobId": "a1c4e8f2-5b3d-4e6a-8f7c-2d9b1e0a3c5f",
"jobIds": [
"a1c4e8f2-5b3d-4e6a-8f7c-2d9b1e0a3c5f",
"b7d2f9a1-6c4e-4a8b-9d3f-3e0c2f1b4d6a",
"c3e5a7b9-8d1f-4c2e-a6b8-4f1d3a2c5e7b",
"d9f1b3c5-2e4a-4d6f-b8c1-5a2e4b3d6f8c"
]
}
```

<TypeTable
type={{
name: { type: 'string', description: 'The character name, 1 to 200 characters.', required: true },
seedPrompt: { type: 'string', description: 'The portrait prompt. Send seedPrompt, description or referencePhotos, or attach to a character that has a description.' },
description: { type: 'string', description: 'Identity notes, up to 2,000 characters.' },
referencePhotos: { type: 'array', description: 'Up to 20 { url, kind } photos to build the portrait from.' },
gender: { type: 'string', description: 'Up to 50 characters.' },
style: { type: 'string', description: 'The visual style.' },
baseOutfit: { type: 'string', description: 'Up to 1,000 characters.' },
count: { type: 'integer', description: 'Candidates to generate, 1 to 10.', default: '1' },
provider: { type: 'string', description: 'The image model id. Omit it for the default model.' },
quality: { type: "'basic' | 'medium' | 'high'", description: 'A quality tier, on models that have one. Changes the price.' },
resolution: { type: 'string', description: '1K, 2K, 4K, 0.5 MP, 1 MP, 2 MP or 4 MP, on models that support it. Changes the price.' },
aspectRatio: { type: "'1:1' | '3:4' | '16:9' | '9:16'", description: 'The portrait frame.', default: '3:4' },
sourceImageUrl: { type: 'string', description: 'An image to start from.' },
attachToCharacterId: { type: 'string (uuid)', description: 'The character that receives the result as its portrait.' },
}}
/>

`quality` and `resolution` price the job exactly like [Generate Image](https://nodaro.ai/docs/nodes/image/generate-image): a 4K or high-quality run costs more than the same model at its base tier. A value the model does not support is changed to the nearest one it does, never refused, and the credits follow the changed value. These routes do not return an `adjustments` list; read the value that ran in the job's `input_data` from `GET /v1/jobs/:id`.

## Generate an expression, angle, pose or lighting variant

`POST /v1/generate-character-asset` generates one variant of the anchor portrait and returns `{ jobId }`. To add the result to the character, send all three attach fields: `attachToCharacterId`, `attachToColumn` and `attachName`. The worker appends `{ name: attachName, url }` to that bucket when the job completes.

**curl**

```bash
curl -X POST https://app.nodaro.ai/v1/generate-character-asset \
  -H "Authorization: Bearer $NODARO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
"name": "Kira",
"assetType": "expressions",
"variant": "smile",
"attachToCharacterId": "3f6c2a9e-8d41-4b7a-9c35-1e2f7a6b0d94",
"attachToColumn": "expressions",
"attachName": "smile"
}'
```

**TypeScript SDK**

```ts
await client.characters.generateAsset({
name: 'Kira',
assetType: 'bodyAngles',
variant: 'front',
attachToCharacterId: id,
attachToColumn: 'body_angles',
attachName: 'front',
})
```

**CLI**

```bash
nodaro characters generate-asset <id> --asset-type expressions --variant smile --watch
```

| Field | What it does |
| --- | --- |
| `name` | Required. The character name. |
| `assetType` | Required. `expressions`, `poses`, `lighting`, `headAngles`, `angles` (same as `headAngles`), `bodyAngles` or `custom`. |
| `variant` | Required. The variant to generate, 1 to 100 characters, for example `smile` or `3/4 left`. |
| `description` | A one-sentence description of this variant, up to 1,000 characters. When you attach to a character and omit it, Nodaro drafts one from the character's canonical description. |
| `sourceImageUrl` | The image to vary, usually the approved portrait. |
| `provider`, `quality`, `resolution` | The image model and its output tier, priced as on Generate Image. |
| `aspectRatio` | `1:1`, `3:4`, `16:9` or `9:16`. The default depends on the type: `1:1` for expressions, `9:16` for poses and body angles, `3:4` for the others. |
| `attachToCharacterId`, `attachToColumn`, `attachName` | Where the result goes. `attachToColumn` is `expressions`, `poses`, `angles`, `body_angles` or `lighting_variations`. A `custom` variant must name its column. |

When you attach the variant to a character, the real-life photos saved under the variant's key in `realLifeRefsByVariant` are sent with the request automatically.

## Animate the character

`POST /v1/generate-character-motion` turns a still of the character into a video clip and returns `{ jobId }`. With `attachToCharacterId` and `attachName`, the clip is appended to the `motions` bucket when it completes.

When you attach to a character and omit `sourceImageUrl`, Nodaro picks the start frame in this order:

1. The `sourceImageUrl` you send, which always wins.
2. The `front` entry of `bodyAngles`. A full-body frame animates much better than a head-and-shoulders portrait.
3. Any other entry of `bodyAngles`, the most recent first.
4. The anchor portrait.

For the best clips, generate a `front` body angle before the first motion.

**curl**

```bash
curl -X POST https://app.nodaro.ai/v1/generate-character-motion \
  -H "Authorization: Bearer $NODARO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
"name": "Kira",
"motionPrompt": "slow head turn left, soft smile",
"provider": "kling",
"attachToCharacterId": "3f6c2a9e-8d41-4b7a-9c35-1e2f7a6b0d94",
"attachName": "head turn"
}'
```

**TypeScript SDK**

```ts
await client.characters.generateMotion({
name: 'Kira',
motionPrompt: 'slow head turn left, soft smile',
provider: 'kling',
attachToCharacterId: id,
attachName: 'head turn',
})
```

**CLI**

```bash
nodaro characters generate-motion <id> \
  --motion-prompt "slow head turn left, soft smile" --attach-name "head turn" --watch
```

| Field | What it does |
| --- | --- |
| `name` | Required. The character name. |
| `motionPrompt` | Required. What moves and how, 1 to 2,000 characters. |
| `provider` | The video model: `kling` (the default), `kling-turbo`, `kling-3.0`, `wan-i2v` or `wan-2.7-i2v`. |
| `sourceImageUrl` | The start frame. Required when you do not attach to a character. |
| `description`, `motionDescription` | A visual description (up to 1,000 characters) and a description of the movement (up to 500). Nodaro drafts both when you attach and omit them. |
| `aspectRatio` | `1:1`, `3:4`, `16:9` or `9:16`. The default is `9:16`, a full-body vertical clip. |
| `attachToCharacterId`, `attachName` | The character and the clip's name in `motions`. |

These models can animate a character. The price is the model's image-to-video price:

| Model | Maker | Modes | Credits | Details |
| --- | --- | --- | --- | --- |
| [Kling 2.6](https://nodaro.ai/docs/models/video/kling-2-6) | Kuaishou | Image to video, Text to video | from 138 | Kling 2.6 I2V — strong motion realism. 5s/10s, optional native audio. |
| [Kling 2.5 Turbo Pro](https://nodaro.ai/docs/models/video/kling-2-5-turbo-pro) | Kuaishou | Image to video, Text to video | from 110 | Faster Kling — good quality at lower cost. Supports end frame. |
| [Kling 3.0](https://nodaro.ai/docs/models/video/kling-3-0) | Kuaishou | Image to video, Text to video | from 270 | Premium Kling 3.0 — variable 3-15s duration, native audio, 720P/1080P. |
| [Wan 2.6 I2V](https://nodaro.ai/docs/models/video/wan-2-6-i2v) | Alibaba | Image to video | from 175 | Wan 2.6 image-to-video — 5/10/15s at 720p/1080p. |
| [Wan 2.7 I2V](https://nodaro.ai/docs/models/video/wan-2-7-i2v) | Alibaba | Image to video | 188 | Wan 2.7 image-to-video — 2–15s at 720p/1080p, supports start+end frame. |

## Approve a portrait

`POST /v1/characters/:id/approve-portrait` sets a completed candidate as the anchor portrait. In the same call, Nodaro looks at the portrait and writes `canonicalDescription`, the text that later prompts use to describe the character. Without that description, a character drifts much more between scenes.

The candidate must be a `completed` job that belongs to you. If writing the description fails, the portrait is still set and `canonicalDescription` is `null`; call `POST /v1/characters/:id/llm-caption` to try again. Both routes are safe to repeat and cost no credits.

**curl**

```bash
curl -X POST https://app.nodaro.ai/v1/characters/3f6c2a9e-8d41-4b7a-9c35-1e2f7a6b0d94/approve-portrait \
  -H "Authorization: Bearer $NODARO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "candidateJobId": "b7d2f9a1-6c4e-4a8b-9d3f-3e0c2f1b4d6a" }'
```

**TypeScript SDK**

```ts
const { portraitUrl, canonicalDescription } =
await client.characters.approvePortrait(id, jobIds[1])

if (canonicalDescription === null) {
await client.characters.recaption(id)
}
```

**CLI**

```bash
nodaro characters approve-portrait <id> --job <jobId>
nodaro characters recaption <id>
```

```json
{
"portraitUrl": "https://cdn.nodaro.ai/characters/kira-portrait-2.png",
"canonicalDescription": "Kira is a woman in her mid-twenties with shoulder-length auburn hair, green eyes and light freckles..."
}
```

`llm-caption` returns `{ canonicalDescription }`. It answers `400 no_portrait` when the character has no portrait yet, and `502` when the description could not be written.

## Archive, restore and copy a character

- **Archive.** `DELETE /v1/characters/:id` archives the character and returns `{ success: true, archived: true }`. The character leaves the default list, but `GET /v1/characters/:id` still returns it, so workflows that use it keep working. On Nodaro Cloud, archiving also cancels a training in progress, refunds its credits and removes the trained model.
- **Restore.** `POST /v1/characters/:id/restore` returns `{ id, name }`. When an active character now has the same name, Nodaro adds a `(restored)` suffix and returns the new name.
- **Delete for good.** No API route deletes a character permanently. Use the archive view in the editor's library.
- **Copy.** `POST /v1/characters/:id/duplicate` returns `{ id, name }` for a new character with a `(copy)` suffix. The copy shares the original's asset URLs until you regenerate them.
- **Usage.** `GET /v1/characters/:id/usage` returns `{ workflowCount, workflows: [{ id, name }] }`. Check it before you archive a character.

| Action | curl | TypeScript SDK | CLI |
| --- | --- | --- | --- |
| Archive | `DELETE /v1/characters/:id` | `client.characters.delete(id)` | `nodaro characters delete <id>` |
| Restore | `POST /v1/characters/:id/restore` | `client.characters.restore(id)` | `nodaro characters restore <id>` |
| Copy | `POST /v1/characters/:id/duplicate` | `client.characters.duplicate(id)` | `nodaro characters duplicate <id>` |
| Usage | `GET /v1/characters/:id/usage` | `client.characters.usage(id)` | `nodaro characters usage <id>` |

## A complete run: create, generate, approve, add variants

### Create the character

`POST /v1/characters` with `nodeId`, `name` and a `seedPrompt`. Keep the returned `id`.

### Generate portrait candidates

`POST /v1/generate-character` with `count: 4` and `attachToCharacterId`. Poll each job id until it is `completed` or `failed`.

### Approve your favorite

`POST /v1/characters/:id/approve-portrait` with that candidate's job id. The portrait and the canonical description are set.

### Add variants and motion

Generate a `front` body angle, then expressions and poses with `POST /v1/generate-character-asset`, and clips with `POST /v1/generate-character-motion`.

## Use the character in other generations

After the assets exist, pass their URLs as reference images to [Generate Image](https://nodaro.ai/docs/nodes/image/generate-image) or [Generate Video](https://nodaro.ai/docs/nodes/video/generate-video). For example, read the `smile` entry of `expressions` and send its URL as a reference with your prompt. Explicit URLs are the simplest choice for code, because they do not depend on how a workflow is wired. See [Run a single node](https://nodaro.ai/docs/developers/api/nodes) for the request fields.

In a workflow, wire the [Character Asset](https://nodaro.ai/docs/nodes/assets/character) node into the image or video node, or mention the character in the prompt with `@`, for example `@kira:1:smile`. [Reference roles](https://nodaro.ai/docs/guides/reference-roles) explains the mention grammar, and [Consistent characters](https://nodaro.ai/docs/guides/consistent-characters) the full method.

## Use it from MCP

AI assistants use the same routes through these tools. Archive and restore are deliberately not available over MCP.

| Tool | What it does |
| --- | --- |
| `list_characters`, `get_character` | Find a character and read its asset URLs. |
| `create_character`, `update_character` | Create a character or change its identity fields. |
| `generate_character` | Generate a portrait (`kind: "main"`) or a variant (`kind: "asset"`). |
| `generate_character_motion` | Animate the character into a clip. |
| `approve_portrait`, `recaption_character` | Approve a portrait, or write its description again. |

See the [MCP tools reference](https://nodaro.ai/docs/mcp/tools).

## Credits

On Nodaro Cloud, character generation uses the same credit prices as the matching nodes.

| Route | Price |
| --- | --- |
| `POST /v1/generate-character` | The image model's price times `count`, reserved for every candidate before the first job starts. |
| `POST /v1/generate-character-asset` | The image model's price, per variant. |
| `POST /v1/generate-character-motion` | The video model's image-to-video price, per clip. |
| `approve-portrait`, `llm-caption` | Free. |

Each model's page lists its exact price. See [Credits](https://nodaro.ai/docs/concepts/credits).

## Errors

Errors use the standard envelope, `{ "error": { "code", "message" } }`. See [Errors](https://nodaro.ai/docs/developers/api/errors).

| Status | Code | Meaning |
| --- | --- | --- |
| `400` | `validation_error` | A field is missing or invalid, a cursor is malformed, or a generate request has no `seedPrompt`, `description` or `referencePhotos`. |
| `400` | `no_portrait` | `llm-caption` was called before the character has a portrait. |
| `401` | `unauthorized` | The token is missing, invalid or revoked. |
| `402` | `insufficient_credits` | Nodaro Cloud only. The account cannot cover the reservation. |
| `404` | `not_found` | No character or job with that id belongs to you. |
| `409` | `name_taken` | Another active character already has that name. |
| `502` | — | The canonical description could not be written. The portrait is unchanged. Try again. |

## Frequently asked questions

### How do I keep the same character in every generation through the API?

Create the character, generate a portrait and approve it. Nodaro then writes a canonical description of the character. Pass the character's asset URLs as reference images to Generate Image or Generate Video, or wire the character into a workflow.

### How many portrait candidates can one request generate?

POST /v1/generate-character accepts a count from 1 to 10. The credits for every candidate are reserved before the first job starts, and a failure in the middle of the batch rolls the whole reservation back.

### Does DELETE /v1/characters/:id remove a character for good?

No. It archives the character, and POST /v1/characters/:id/restore brings it back. Only the archive view in the editor can delete a character permanently.

### Why is canonicalDescription null after I approve a portrait?

The portrait was set, but writing its description failed. Call POST /v1/characters/:id/llm-caption to try again. Approval and captioning cost no credits.

### Which models can animate a character?

Kling 2.6 (the default, provider id kling), Kling 2.5 Turbo Pro, Kling 3.0, Wan 2.6 I2V and Wan 2.7 I2V. Each clip costs the price of that model's image-to-video generation.
