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
| Method | What 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:
code | Status | Meaning |
|---|---|---|
insufficient_training_images | 400 | Fewer than 4 distinct images. message says how many there are. |
validation_error | 400 | A malformed id or selectedImageUrls. |
already_training_or_not_found | 409 | A training of this character is running, or the character is not yours, or it is archived. |
claim_failed | 500 | The training could not be claimed, and nothing started. Retry. |
training_dispatch_failed | 502 | The training could not start, and the reservation is refunded. The status reads untrained afterwards. |
public_url_not_configured, price_not_configured | 503 | The deployment is not set up for training. |
dedup_race_winner_unresolvable | 503 | A 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>| Field | Description |
|---|---|
status | untrained, queued, training, succeeded, failed or cancelled. untrained means never trained, model removed, or a training that could not start. |
trainingId | The latest training. |
error | Why a training failed. |
trainedAt, version | The last successful training. They stay set through a new training, a failed or cancelled one, or one that could not start. |
triggerWord | The word Nodaro adds to prompts for you. |
imageCount | How 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.
Related
Character Studio
Consistent characters
Character Asset
Characters
Character training
Last updated on
3D scenes
Generate an editable 3D scene from a prompt, edit it, render it to MP4, and run 3D Render Pro from TypeScript with client.scene3d and the 3D scene nodes.
Locations
Create locations, generate establishing shots, approve one, and add time-of-day, weather, season, angle and motion variants from TypeScript.