Characters
Create, update, archive and restore characters over REST, generate portrait candidates, expressions, angles and motion clips, and approve the anchor portrait.
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.
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.
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. |
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
doneimport { createClient, StaticTokenAuth } from '@nodaro/sdk'
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)nodaro characters list --limit 100 --json
nodaro characters list --archived{
"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 -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"
}'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' })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{ "id": "3f6c2a9e-8d41-4b7a-9c35-1e2f7a6b0d94", "name": "Kira" }Prop
Type
person and wardrobe shape only the character's own portrait and asset generations. They use the same catalogs as the Person picker; see Picker catalogs.
When a workflow runs, the character's voice fills in any 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 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 -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"
}'const { jobIds } = await client.characters.generate({
name: 'Kira',
seedPrompt: 'kira portrait, warm natural lighting',
count: 4,
attachToCharacterId: id,
})nodaro characters generate <id> --count 4 \
--seed-prompt "kira portrait, warm natural lighting" --watch{
"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"
]
}Prop
Type
quality and resolution price the job exactly like 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 -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"
}'await client.characters.generateAsset({
name: 'Kira',
assetType: 'bodyAngles',
variant: 'front',
attachToCharacterId: id,
attachToColumn: 'body_angles',
attachName: 'front',
})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:
- The
sourceImageUrlyou send, which always wins. - The
frontentry ofbodyAngles. A full-body frame animates much better than a head-and-shoulders portrait. - Any other entry of
bodyAngles, the most recent first. - The anchor portrait.
For the best clips, generate a front body angle before the first motion.
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"
}'await client.characters.generateMotion({
name: 'Kira',
motionPrompt: 'slow head turn left, soft smile',
provider: 'kling',
attachToCharacterId: id,
attachName: 'head turn',
})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 | 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 | Kuaishou | Image to video, Text to video | from 110 | Faster Kling — good quality at lower cost. Supports end frame. |
| 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 | Alibaba | Image to video | from 175 | Wan 2.6 image-to-video — 5/10/15s at 720p/1080p. |
| 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 -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" }'const { portraitUrl, canonicalDescription } =
await client.characters.approvePortrait(id, jobIds[1])
if (canonicalDescription === null) {
await client.characters.recaption(id)
}nodaro characters approve-portrait <id> --job <jobId>
nodaro characters recaption <id>{
"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/:idarchives the character and returns{ success: true, archived: true }. The character leaves the default list, butGET /v1/characters/:idstill 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/restorereturns{ 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/duplicatereturns{ 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/usagereturns{ 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 or 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 for the request fields.
In a workflow, wire the Character Asset node into the image or video node, or mention the character in the prompt with @, for example @kira:1:smile. Reference roles explains the mention grammar, and 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.
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.
Errors
Errors use the standard envelope, { "error": { "code", "message" } }. See 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
Related
Character Studio
Consistent characters
Character training
Character Asset
Jobs
Last updated on
Webhooks
Start a Nodaro workflow from any system with a Webhook Trigger URL, create schedules through the API, and send results to your server with Webhook Output.
Objects
Script props and products over REST: create and archive objects, generate main images, material and angle variants and motion clips, and approve the main image.