Nodaro Docs
DocumentationNode ReferenceModelsAI Agents (MCP)DevelopersSelf-hostingResearch
TypeScript SDK

Characters

Create characters, generate portrait candidates, approve one, and add expressions, poses and motion clips from TypeScript with client.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. See Character Studio for the editor view.

Methods

MethodWhat it does
list(params?)List your characters, a page at a time
get(id)Read one character, with its jobs in progress
create(input), update(id, input) and upsert(input)Create or change a character
delete(id)Archive a character
restore(id)Bring an archived character back
duplicate(id, input?)Copy a character
usage(id)Count the workflows that use a character
generate(input)Generate portrait candidates
generateAsset(input)Generate an expression, pose, lighting or angle variant
generateMotion(input)Animate the portrait into a motion clip
approvePortrait(id, candidateJobId)Make a candidate the character's portrait
recaption(id)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.

import { createClient, StaticTokenAuth } from "@nodaro/sdk"

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 node, or mention it with @ in a prompt. See Consistent characters.

client.characters

list(params?)

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

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

Prop

Type

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

import type { Character } from "@nodaro/sdk"

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

get(id: string): Promise<CharacterDetail>

Prop

Type

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.

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

Prop

Type

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.

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

Prop

Type

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.

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

Prop

Type

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.

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

Prop

Type

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.

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

Prop

Type

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.

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

Prop

Type

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, 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.

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

Prop

Type

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 in image-to-video mode. With attachToCharacterId, the clip is added to the character's motions. Without sourceImageUrl, the character's portrait is used.

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

Prop

Type

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.

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

Prop

Type

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.

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

Prop

Type

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

Last updated on

On this page