NodaroAccount
Nodaro Docs
DocumentationNode ReferenceModelsTemplatesAI Agents (MCP)DevelopersSelf-hostingResearchCompare
TypeScript SDK

Characters

Create, search and train characters, generate portrait candidates, approve one, and add expressions, poses and motion clips 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
train(id, input?)Train a high-fidelity model of the character, on Nodaro Cloud
trainingStatus(id)Read where the character's training stands
deleteLora(id)Remove the character's trained model

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; q?: string; 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.

Search by words. q keeps only the characters whose name or description contains every word of it, in any order, ignoring case. The words are split on spaces, and only the first 5 count, so "red car" finds "Car, red". Every character of a word matches itself: %, _ and * are not wildcards. A blank q is no search, and q is capped at 200 characters. It combines with the other filters and with paging, so send the same q with each cursor.

const { characters } = await client.characters.list({ q: "maya pilot", limit: 20 })

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). It also carries the character's Nodaro Keepers shelf state, catalog, catalogSort and catalogCategory, which are false, 0 and null on your own rows and on every copy.

get(id: string, options?: { candidates?: "alternates" | "all" }): 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",
  subject: { type: "woman", age: "age-30s", hairBase: "base-short-straight" },
  count: 4,
  attachToCharacterId: characterId,
  provider: "gpt-image",
  quality: "high", // priced as gpt-image:high
})

With attachToCharacterId, every candidate that finishes is written to the portrait, so the last one to finish wins. With one candidate, the result becomes the portrait when the job completes. With several candidates, pick one with approvePortrait(). Pass attachPortrait: false to keep every candidate out of the portrait until you approve one. quality and resolution are priced exactly as in Generate Image, so a 4K or high-quality run reserves more credits. A listed value the model does not take is not refused. Nodaro uses a value the model supports instead, or drops the setting when the model has none. The credits follow the value that ran, and the job's input_data shows it.

subject describes who the portrait shows as picker-catalog ids: a person's age, hair and styling, a held prop, a material or an animal. Nodaro writes the wording and adds it to the prompt after seedPrompt, so send the ids instead of writing the words yourself. One of seedPrompt, referencePhotos or description is still required. See Describe who the portrait shows.

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.

Character training

Nodaro Cloud only. On other editions these routes do not exist, and each call throws NotFoundError. A training builds a high-fidelity model of the character from its own images. A Generate Image node whose prompt mentions that one character with @ then runs on the trained model. See Character training for the editor view.

A training reserves 1,650 credits when it starts, takes about 15 minutes, and is refunded if it fails or is cancelled. With an OAuth app token, train() and deleteLora() need the assets:write scope and throw ForbiddenError without it. trainingStatus() needs no scope. A browser session or a personal API token needs no scope.

train(id, input?)

Starts a training and resolves at once with { jobId, trainingId, triggerWord }.

train(id: string, input?: { selectedImageUrls?: string[] }): Promise<TrainCharacterResult>

Prop

Type

By default it trains on every eligible image: the portrait, reference photos, expressions, poses, head and body angles and lighting variations. Duplicates are removed, and at most 20 images are used. At least 4 distinct images must remain. The method allows 3 calls a minute. Training a trained character again costs the full price again.

Besides the typed errors, such as ForbiddenError, InsufficientCreditsError, RateLimitedError and JobBlockedError, a refusal is a NodaroError with one of these codes:

codeStatusMeaning
insufficient_training_images400Fewer than 4 distinct images. message says how many there are.
validation_error400A malformed id or selectedImageUrls.
already_training_or_not_found409A training of this character is running, or the character is not yours, or it is archived.
claim_failed500The training could not be claimed, and nothing started. Retry.
training_dispatch_failed502The training could not start, and the reservation is refunded. The status reads untrained afterwards.
public_url_not_configured, price_not_configured503The deployment is not set up for training.
dedup_race_winner_unresolvable503A duplicate of this request was running and its outcome could not be read. This call reserved nothing. Read trainingStatus() before you retry.
import { NodaroError } from "@nodaro/sdk"

try {
  const { jobId, triggerWord } = await client.characters.train(characterId)
} catch (err) {
  if (err instanceof NodaroError && err.code === "insufficient_training_images") {
    // err.message: "Need at least 4 reference photos to train (have 2). …"
  } else throw err
}

trainingStatus(id)

Reads where the character's training stands.

trainingStatus(id: string): Promise<CharacterTrainingStatus>
FieldDescription
statusuntrained, queued, training, succeeded, failed or cancelled. untrained means never trained, model removed, or a training that could not start.
trainingIdThe latest training.
errorWhy a training failed.
trainedAt, versionThe last successful training. They stay set through a new training, a failed or cancelled one, or one that could not start.
triggerWordThe word Nodaro adds to prompts for you.
imageCountHow many images the latest training used.

Generation uses the trained model only while status is succeeded. The method throws NotFoundError for a character that is not yours or is archived.

There is no wait helper. Poll about every 10 seconds while the status is queued or training. The training's job, the jobId from train(), also ends completed, failed or cancelled, so you can follow client.jobs.getStatus(jobId) instead.

let training = await client.characters.trainingStatus(characterId)
while (training.status === "queued" || training.status === "training") {
  await new Promise((resolve) => setTimeout(resolve, 10_000))
  training = await client.characters.trainingStatus(characterId)
}
if (training.status !== "succeeded") console.warn(training.status, training.error)

deleteLora(id)

Removes the character's trained model. It cancels a training that is running and refunds its reservation, deletes the model and clears the record. trainingStatus() then reads untrained, and generations go back to reference images. It cannot be undone: a new model is a new, paid training.

deleteLora(id: string): Promise<void>

It throws ForbiddenError for an OAuth app token without assets:write, and NotFoundError for a character that is not yours or is archived.

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.

How do I train a character from the SDK?

Call client.characters.train(id) on Nodaro Cloud, then poll client.characters.trainingStatus(id) until the status is no longer queued or training. A training costs 1,650 credits, takes about 15 minutes, and is refunded if it fails.

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

Last updated on

On this page