# Characters

> Create characters, generate portrait candidates, approve one, and add expressions, poses and motion clips from TypeScript with client.characters.

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

**`client.characters`** scripts everything Character Studio does: it creates and edits characters, generates portrait candidates, approves one as the character's face, and adds expressions, poses, lighting, angles and motion clips. A character keeps its portrait, its asset collections, its reference photos and a caption that describes it, so every later image and video can show the same person. The methods call the [Characters REST API](https://nodaro.ai/docs/developers/api/characters). See [Character Studio](https://nodaro.ai/docs/guides/character-studio) for the editor view.

## Methods

| Method | What it does |
| --- | --- |
| [`list(params?)`](#listparams) | List your characters, a page at a time |
| [`get(id)`](#getid) | Read one character, with its jobs in progress |
| [`create(input)`, `update(id, input)` and `upsert(input)`](#upsertinput-createinput-and-updateid-input) | Create or change a character |
| [`delete(id)`](#deleteid) | Archive a character |
| [`restore(id)`](#restoreid) | Bring an archived character back |
| [`duplicate(id, input?)`](#duplicateid-input) | Copy a character |
| [`usage(id)`](#usageid) | Count the workflows that use a character |
| [`generate(input)`](#generateinput) | Generate portrait candidates |
| [`generateAsset(input)`](#generateassetinput) | Generate an expression, pose, lighting or angle variant |
| [`generateMotion(input)`](#generatemotioninput) | Animate the portrait into a motion clip |
| [`approvePortrait(id, candidateJobId)`](#approveportraitid-candidatejobid) | Make a candidate the character's portrait |
| [`recaption(id)`](#recaptionid) | Write the character's description again |

## Create a character from start to finish

The usual order: create the character, generate portrait candidates, approve one, then add variants on top.

```ts

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

async function waitForJob(jobId: string) {
for (;;) {
const { data } = await client.jobs.getStatus(jobId)
if (data.status === "completed" || data.status === "failed" || data.status === "cancelled") return data
await new Promise((resolve) => setTimeout(resolve, 3_000))
}
}

// 1. Create the character
const { id: characterId } = await client.characters.create({
nodeId: "scripted",
name: "Kira",
description: "Young protagonist with auburn hair",
style: "realistic",
seedPrompt: "Kira portrait, warm natural lighting",
})

// 2. Generate 4 portrait candidates
const { jobIds } = await client.characters.generate({
name: "Kira",
seedPrompt: "Kira portrait, warm natural lighting",
count: 4,
attachToCharacterId: characterId,
})
const results = await Promise.all(jobIds.map(waitForJob))
const firstDone = jobIds[results.findIndex((r) => r.status === "completed")]

// 3. Approve one candidate as the portrait
const { portraitUrl, canonicalDescription } = await client.characters.approvePortrait(characterId, firstDone)

// 4. Add a smile
await client.characters.generateAsset({
name: "Kira",
assetType: "expressions",
variant: "smile",
attachToCharacterId: characterId,
attachToColumn: "expressions",
attachName: "smile",
})

// 5. Animate the portrait
await client.characters.generateMotion({
name: "Kira",
motionPrompt: "Slow head turn to the left, soft smile",
provider: "kling",
attachToCharacterId: characterId,
attachName: "head turn",
})
```

Read the character again with `get()` when the jobs finish. It then has a portrait, a smile expression and a motion clip. Wire it into generations with the [Character Asset](https://nodaro.ai/docs/nodes/assets/character) node, or mention it with `@` in a prompt. See [Consistent characters](https://nodaro.ai/docs/guides/consistent-characters).

## client.characters

### list(params?)

Lists your characters, newest first. By default it returns active characters only.

```ts
list(params?: { projectId?: string; archived?: boolean; limit?: number; cursor?: string }): Promise<{
characters: Character[]
nextCursor: string | null
}>
```

<TypeTable
type={{
projectId: { type: 'string', description: "Only characters of this project." },
archived: { type: 'boolean', default: 'false', description: "true lists archived characters instead." },
limit: { type: 'number', default: '100', description: "The page size, at most 500." },
cursor: { type: 'string', description: "The nextCursor of the previous page." },
}}
/>

One call returns at most `limit` characters, so a single call is not the full list for everyone. Page until `nextCursor` is `null`:

```ts

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

Pass `nextCursor` back as it is, and do not store or parse it. A malformed cursor fails with `validation_error` instead of starting again from the first page.

### get(id)

Reads one character, with three extra lists the studio uses after a reload: `pendingJobs` (variants still generating), `portraitCandidates` (candidates of the current portrait run, with their progress) and `previousCandidates` (earlier candidates).

```ts
get(id: string): Promise<CharacterDetail>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The character id." },
}}
/>

```ts
const character = await client.characters.get(characterId)
console.log(character.sourceImageUrl, character.expressions, character.motions)
```

An archived character is still returned by id, so workflow nodes that point at it keep loading. A `Character` has its portrait in `sourceImageUrl`, six asset collections (`expressions`, `poses`, `motions`, `angles`, `bodyAngles` and `lightingVariations`, each a list of `{ name, url }`), `boards`, `voice`, `personality`, `canonicalDescription` and `identityLock`.

### upsert(input), create(input) and update(id, input)

`upsert()` creates a character when `input.id` is absent and updates it when `id` is set. `create()` and `update()` are shortcuts that set `id` for you. An update writes only the fields you send; the others, `name` included, stay as they are.

```ts
upsert(input: UpsertCharacterInput): Promise<{ id: string; name?: string }>
create(input: Omit<UpsertCharacterInput, "id"> & { name: string }): Promise<{ id: string; name?: string }>
update(id: string, input: Omit<UpsertCharacterInput, "id">): Promise<{ id: string; name?: string }>
```

<TypeTable
type={{
id: { type: 'string', description: "upsert only: the character to update. Omit it to create one." },
name: { type: 'string', description: "The name. Required on create." },
nodeId: { type: 'string', description: "The canvas node the character belongs to. Scripts can pass any label, such as scripted." },
projectId: { type: 'string', description: "The project to file the character under." },
workflowId: { type: 'string', description: "The workflow the character comes from." },
description: { type: 'string', description: "A free-text description." },
gender: { type: 'string', description: "The gender." },
style: { type: '"realistic" | "anime" | "3d-pixar" | "illustration"', description: "The visual style." },
baseOutfit: { type: 'string', description: "The default outfit." },
seedPrompt: { type: 'string', description: "The prompt portraits start from." },
sourceImageUrl: { type: 'string', description: "The portrait URL." },
imageProvider: { type: 'string | null', description: "The image model the portrait was made with." },
canonicalDescription: { type: 'string', description: "The description used in prompts. approvePortrait() writes it for you." },
identityLock: { type: '"off" | "soft" | "strict"', default: '"strict"', description: "How strongly the face is preserved when variants are generated." },
referencePhotos: { type: 'ReferencePhoto[]', description: "Reference photos, each { url, kind }. kind is frontFace, sideLeft, sideRight, threeQuarterLeft, threeQuarterRight, frontBody or other." },
voice: { type: '{ voiceId, voiceName, traits, voiceType?, previewUrl?, ttsProvider? } | null', description: "The character's voice. null removes it." },
personality: { type: '{ mood, speechStyle, movementStyle, behavioralNotes } | null', description: "Personality notes for scripts and prompts." },
'expressions, poses, lightingVariations, angles, bodyAngles, motions': { type: 'Array<{ name: string; url: string }>', description: "The asset collections, each replaced as a whole." },
boards: { type: 'Array<{ name, url, type?, sourceImages? }>', description: "Reference boards of the character." },
selectedAssetByVariant: { type: 'Record<string, string>', description: "The chosen take of each variant." },
}}
/>

```ts
const { id } = await client.characters.create({
nodeId: "scripted",
name: "Kira",
description: "Young protagonist with auburn hair",
style: "realistic",
identityLock: "strict",
})

await client.characters.update(id, { baseOutfit: "Green raincoat and boots" })
```

A name that is already in use fails with `409 name_taken`.

### delete(id)

Archives a character. It disappears from `list()` but still loads with `get(id)`. Use `restore()` to bring it back.

```ts
delete(id: string): Promise<{ success: true; archived: true }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The character id." },
}}
/>

```ts
await client.characters.delete(characterId)
```

### restore(id)

Brings an archived character back. When its name is now used by another active character, the server adds `(restored)` to the name and returns the name it used.

```ts
restore(id: string): Promise<{ id: string; name: string }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The character id." },
}}
/>

```ts
const { name } = await client.characters.restore(characterId)
```

### duplicate(id, input?)

Copies a character to a new one whose name ends in `(copy)`. The copy shares the asset URLs of the original until you generate new ones.

```ts
duplicate(id: string, input?: { nodeId?: string; projectId?: string }): Promise<{ id: string; name: string }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The character to copy." },
nodeId: { type: 'string', description: "The canvas node the copy belongs to." },
projectId: { type: 'string', description: "The project of the copy." },
}}
/>

```ts
const { id: copyId, name } = await client.characters.duplicate(characterId)
```

### usage(id)

Returns how many workflows use a character, and which ones. The editor shows it before archiving.

```ts
usage(id: string): Promise<{ workflowCount: number; workflows: Array<{ id: string; name: string }> }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The character id." },
}}
/>

```ts
const { workflowCount } = await client.characters.usage(characterId)
```

### generate(input)

Generates portrait candidates (`POST /v1/generate-character`). With `count` above 1, every job is reserved before any starts, so a failure part way through rolls the whole batch back.

```ts
generate(input: GenerateCharacterInput): Promise<{ jobId: string; jobIds: string[] }>
```

<TypeTable
type={{
name: { type: 'string', required: true, description: "The character's name." },
description: { type: 'string', description: "A description of the character." },
seedPrompt: { type: 'string', description: "The prompt to generate from." },
userPrompt: { type: 'string', description: "Extra instructions." },
gender: { type: 'string', description: "The gender." },
style: { type: '"realistic" | "anime" | "3d-pixar" | "illustration"', description: "The visual style." },
baseOutfit: { type: 'string', description: "The outfit." },
sourceImageUrl: { type: 'string', description: "A photo to base the portrait on." },
referencePhotos: { type: 'ReferencePhoto[]', description: "Reference photos of the person." },
provider: { type: 'string', description: "The image model id." },
count: { type: 'number', description: "How many candidates to generate." },
aspectRatio: { type: '"1:1" | "3:4" | "16:9" | "9:16"', description: "The portrait shape." },
quality: { type: 'string', description: "medium, high or basic, on models with a quality setting. It 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. It changes the price." },
attachToCharacterId: { type: 'string', description: "The character to write the result to." },
}}
/>

```ts
const { jobIds } = await client.characters.generate({
name: "Kira",
seedPrompt: "Kira portrait, warm natural lighting",
count: 4,
attachToCharacterId: characterId,
provider: "gpt-image",
quality: "high", // priced as gpt-image:high
})
```

With `attachToCharacterId` and one candidate, the result becomes the portrait when the job completes. With several candidates, pick one with `approvePortrait()`. `quality` and `resolution` are priced exactly as in [Generate Image](https://nodaro.ai/docs/nodes/image/generate-image), so a 4K or high-quality run reserves more credits. A value the model does not support is ignored, not refused.

### generateAsset(input)

Generates one variant from the character's portrait: an expression, a pose, a lighting setup or an angle. With `attachToCharacterId`, `attachToColumn` and `attachName`, the result is added to that collection of the character when the job completes.

```ts
generateAsset(input: GenerateAssetInput): Promise<{ jobId: string }>
```

<TypeTable
type={{
assetType: { type: '"expressions" | "poses" | "lighting" | "angles" | "headAngles" | "bodyAngles" | "custom"', required: true, description: "The kind of variant." },
variant: { type: 'string', required: true, description: "The variant to make, such as smile or three-quarter view." },
name: { type: 'string', required: true, description: "The character's name." },
description: { type: 'string', description: "The character's description." },
userPrompt: { type: 'string', description: "Extra instructions." },
sourceImageUrl: { type: 'string', description: "The image to start from. Usually the portrait." },
realLifeRefs: { type: 'string[]', description: "Real photos that show the variant." },
provider: { type: 'string', description: "The image model id." },
aspectRatio: { type: '"1:1" | "3:4" | "16:9" | "9:16"', description: "The image shape." },
quality: { type: 'string', description: "As in generate(). It changes the price." },
resolution: { type: 'string', description: "As in generate(). It changes the price." },
attachToCharacterId: { type: 'string', description: "The character to add the result to." },
attachToColumn: { type: 'string', description: "The collection to add it to, such as expressions." },
attachName: { type: 'string', description: "The name of the new entry." },
}}
/>

```ts
await client.characters.generateAsset({
name: "Kira",
assetType: "expressions",
variant: "smile",
attachToCharacterId: characterId,
attachToColumn: "expressions",
attachName: "smile",
})
```

### generateMotion(input)

Animates the character's portrait into a motion clip, with [Generate Video](https://nodaro.ai/docs/nodes/video/generate-video) in image-to-video mode. With `attachToCharacterId`, the clip is added to the character's `motions`. Without `sourceImageUrl`, the character's portrait is used.

```ts
generateMotion(input: GenerateMotionInput): Promise<{ jobId: string }>
```

<TypeTable
type={{
motionPrompt: { type: 'string', required: true, description: "What the character does." },
name: { type: 'string', required: true, description: "The character's name." },
sourceImageUrl: { type: 'string', description: "The image to animate. The default is the portrait." },
provider: { type: 'string', description: "The video model id, such as kling." },
description: { type: 'string', description: "The character's description." },
motionDescription: { type: 'string', description: "A longer description of the motion." },
realLifeRefs: { type: 'string[]', description: "Real references for the motion." },
aspectRatio: { type: '"1:1" | "3:4" | "16:9" | "9:16"', description: "The clip shape." },
attachToCharacterId: { type: 'string', description: "The character to add the clip to." },
attachName: { type: 'string', description: "The name of the new clip." },
}}
/>

```ts
await client.characters.generateMotion({
name: "Kira",
motionPrompt: "Slow head turn to the left, soft smile",
provider: "kling",
attachToCharacterId: characterId,
attachName: "head turn",
})
```

### approvePortrait(id, candidateJobId)

Makes a completed candidate from `generate()` the character's portrait. A vision model then writes the character's description, and the method returns both.

```ts
approvePortrait(id: string, candidateJobId: string): Promise<{ portraitUrl: string; canonicalDescription: string | null }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The character id." },
candidateJobId: { type: 'string', required: true, description: "The job id of the completed candidate." },
}}
/>

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

`canonicalDescription` is `null` when the description could not be written. The portrait is still set; call `recaption()` to try again.

### recaption(id)

Writes the character's description again from its current portrait.

```ts
recaption(id: string): Promise<{ canonicalDescription: string }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The character id." },
}}
/>

```ts
const { canonicalDescription } = await client.characters.recaption(characterId)
```

Fails with `400 no_portrait` when the character has no portrait, and with a 502 when the vision model fails.

## Frequently asked questions

### How do I create a character with the Nodaro SDK?

Call client.characters.create with a nodeId and a name, then client.characters.generate for portrait candidates, and client.characters.approvePortrait to pick one. The character can then be used in any image or video generation.

### Does generating a character portrait cost credits?

Yes. A portrait costs the same as a Generate Image run on the chosen model. quality and resolution change the price, for example a 4K or high-quality run costs more.

### What happens when I delete a character?

delete archives the character. It disappears from list() but still loads by id, so workflows that use it keep working. restore brings it back.

### What does identityLock do?

It sets how strongly the face is preserved when Nodaro generates the character's expressions, poses and other variants: off, soft or strict. The default is strict.
