@nodaro/sdk API Reference

Complete reference for every public export of @nodaro/sdk. For a walkthrough-style introduction, see the SDK Quickstart.

Table of contents


createClient(options)

Factory that returns a NodaroClient instance with all resource subobjects attached.

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

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

Options (ClientOptions):

Field Type Required Description
baseUrl string yes Backend URL like "https://nodaro.example.com". Use "" for same-origin in browser apps. Trailing slash is stripped.
auth Auth yes Auth provider — StaticTokenAuth, supabaseAuth(...), or CallbackAuth.
fetch typeof fetch no Custom fetch implementation. Default: globalThis.fetch.
timeoutMs number no Per-request timeout. Default: 60_000.

The instance exposes 20 resource objects: workflows, projects, jobs, executions, nodes, characters, locations, objects, pipelines, reduce, promptHelper, apps, developerApps, oauth, voices, credits, uploads, presets, pickerCatalogs, community. It also exposes a low-level request<T>(method, path, options) method for endpoints not yet wrapped by a resource.

class NodaroClient

You normally call createClient, but the class is also exported for typechecking (function takesClient(c: NodaroClient) { ... }).

import { NodaroClient } from "@nodaro/sdk"

client.me()

Resolves the authenticated user’s canonical identity (GET /v1/me). A token- introspection primitive: any valid bearer token (a first-party Supabase JWT or a developer-app OAuth token) resolves to its owner’s identity. Throws UnauthorizedError (401) when the token is missing or invalid.

const me = await client.me()
// { id, email, displayName, avatarUrl, tier }

Signature: me(): Promise<UserIdentity>

Returns UserIdentity:

Field Type Description
id string Nodaro user id (= the Supabase auth user id).
email string The user’s email.
displayName string \| null Human-readable display name (from profiles.full_name); null if unset.
avatarUrl string \| null Avatar URL; null if unset.
tier string Subscription tier (e.g. "free", "pro").

Auth providers

Every request calls auth.getToken() and sends Authorization: Bearer <token> when a non-null token is returned.

Auth (interface)

interface Auth {
  getToken(): Promise<string | null>
}

Any object that satisfies this shape can be used as auth.

class StaticTokenAuth

Wraps a fixed string. Use for server-side code with an OAuth access token, an API token (ndr_...), or a developer-app token (ndr_app_...).

import { StaticTokenAuth } from "@nodaro/sdk"
new StaticTokenAuth("ndr_app_abc123...")

Constructor: new StaticTokenAuth(token: string)

class CallbackAuth

Calls a user-supplied function on every request. The function may be sync or async, and may return null to skip the header (anonymous request).

import { CallbackAuth } from "@nodaro/sdk"

new CallbackAuth(async () => {
  const session = await sessionStore.read()
  return session?.accessToken ?? null
})

Constructor: new CallbackAuth(fn: () => string | null | Promise<string | null>)

supabaseAuth(supabase)

Pulls the JWT live from a Supabase v2 client. Use for browser frontends talking to a Nodaro instance backed by the same Supabase project.

import { supabaseAuth } from "@nodaro/sdk"
import { createClient as createSupabase } from "@supabase/supabase-js"

const supabase = createSupabase(URL, ANON_KEY)
const auth = supabaseAuth(supabase)

Signature: supabaseAuth(supabase: SupabaseLikeClient): Auth

The argument is structurally typed — only supabase.auth.getSession() is called. Any client matching that shape works.


Errors

All errors thrown by resource methods extend NodaroError. Catch the most specific subclass first.

class NodaroError

Base class for every API error.

class NodaroError extends Error {
  readonly code: string    // e.g. "internal_error", "validation_failed"
  readonly status: number  // HTTP status
}

Constructor: new NodaroError(message: string, code: string, status: number)

class UnauthorizedError extends NodaroError

HTTP 401. Token missing, expired, or invalid.

class ForbiddenError extends NodaroError

HTTP 403. Permission denied. When the server returns code: "insufficient_scope", the missing scope is exposed via missingScope.

class NotFoundError extends NodaroError

HTTP 404. Resource doesn’t exist or isn’t visible to this caller.

class RateLimitedError extends NodaroError

HTTP 429. Apply backoff and retry.

class InsufficientCreditsError extends NodaroError

HTTP 402. Caller doesn’t have enough credits to start the operation. Both fields are present on production servers but optional in the type for forward-compat.

class StorageExceededError extends NodaroError

HTTP 413. User’s storage cap is reached.

throwFromResponse(status, body)

Internal helper that maps (status, JSON body) to the right error class and throws it. Exported so custom transports can reuse it. Returns never.

import { throwFromResponse } from "@nodaro/sdk"
throwFromResponse(403, { error: { code: "insufficient_scope", message: "...", missingScope: "workflows:execute" } })
// → throws ForbiddenError with .missingScope === "workflows:execute"

Resources

Every resource is constructed automatically by createClient and reachable via client.<resource>. The classes are also exported for advanced typechecking but rarely need to be imported directly: WorkflowsResource, ProjectsResource, JobsResource, ExecutionsResource, NodesResource, CharactersResource, LocationsResource, ObjectsResource, PipelinesResource, ReduceResource, PromptHelperResource, AppsResource, DeveloperAppsResource, OAuthResource, VoicesResource, CreditsResource, UploadsResource, PresetsResource, PickerCatalogsResource, CommunityResource.

All “data” responses follow the envelope { data: T } — the SDK returns the envelope as-is. Mutation responses (delete, cancel) return { success: true }.

client.workflows

list(params)

list(params: { projectId: string }): Promise<{ data: Workflow[] }>

Lists workflows in a project. Returns metadata only — nodes, edges, settings, and sourcePrompt are omitted.

const { data } = await client.workflows.list({ projectId })

Throws UnauthorizedError, ForbiddenError, NotFoundError (project not visible).

get(id)

get(id: string): Promise<{ data: Workflow }>

Fetches a workflow including its full nodes/edges/settings.

const { data: wf } = await client.workflows.get(workflowId)

getPublic(id)

getPublic(id: string): Promise<{ data: Workflow }>

Fetches a publicly-shared workflow by id (GET /v1/public/workflows/:id) — the unauthenticated share-by-link read. Returns the workflow’s nodes/edges/settings ONLY when the workflow is opted into sharing server-side (settings.studio.shared === true); otherwise throws NotFoundError. No auth required — the SDK omits the bearer when no token exists.

const { data: wf } = await client.workflows.getPublic(workflowId)

create(input)

create(input: CreateWorkflowInput): Promise<{ data: Workflow }>

Creates a workflow under a project. input.projectId is required; everything else is optional and falls back to server defaults. Returns the full record.

const { data: wf } = await client.workflows.create({
  projectId,
  name: "My workflow",
  nodes: [],
  edges: [],
})

update(id, input)

update(id: string, input: UpdateWorkflowInput): Promise<{ data: Workflow }>

PATCHes a workflow. Any subset of fields is allowed.

Optimistic concurrency: pass expectedVersion (the integer version from a prior read — bumped by the database on every content change) to make the update conditional; on a mismatch the API returns 409 workflow_conflict with currentVersion and currentUpdatedAt so you can refetch and retry. expectedUpdatedAt (string token) remains supported. Transient run-state keys on node data (executionStatus, currentJobId, progress counters) are stripped server-side and never persist.

await client.workflows.update(id, { name: "Renamed", expectedVersion: 7 })

thumbnailUrl sets the workflow’s preview image — an already-hosted image URL, or null to clear it:

await client.workflows.update(id, { thumbnailUrl: "https://cdn.example.com/thumb.jpg" })

delete(id)

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

Deletes a workflow.

await client.workflows.delete(id)

run(id, params?)

run(id: string, params?: RunWorkflowParams): Promise<RunWorkflowResult>

Starts an execution and returns immediately with { executionId, status }. Optionally restrict to a subset of node IDs.

const { executionId } = await client.workflows.run(id, { nodeIds: ["node-1"] })

Throws InsufficientCreditsError if the user can’t cover the worst-case cost. Requires workflows:execute scope when called via OAuth.

export(workflowId, opts?)

export(workflowId: string, opts?: { assets?: boolean }): Promise<{ data: WorkflowExport }>

Exports a workflow as a portable JSON bundle. Pass opts.assets = true to include character/object/location entity data in the bundle.

const { data: bundle } = await client.workflows.export(workflowId, { assets: true })

import(input)

import(input: WorkflowExport & { projectId: string }): Promise<{ data: Workflow }>

Imports a WorkflowExport bundle into the specified project. Re-creates any bundled assets (characters, objects, locations) under your account. Returns the full record of the newly created workflow.

const { data: wf } = await client.workflows.import({ ...bundle, projectId })

client.projects

list()

list(): Promise<{ data: Project[] }>

Lists the authenticated user’s projects.

const { data } = await client.projects.list()

get(id)

get(id: string): Promise<{ data: Project }>
const { data } = await client.projects.get(id)

create(input)

create(input: CreateProjectInput): Promise<{ data: Project }>
const { data } = await client.projects.create({ name: "New project" })

update(id, input)

update(id: string, input: UpdateProjectInput): Promise<{ data: Project }>

At least one field must be supplied.

await client.projects.update(id, { description: "Updated" })

delete(id)

delete(id: string): Promise<{ success: true }>
await client.projects.delete(id)

client.jobs

A “job” is a single AI generation unit (one image, one video render, one TTS call). Workflows produce one job per AI node.

get(id)

get(id: string): Promise<{ data: Job }>
const { data: job } = await client.jobs.get(jobId)

The returned Job uses snake_case fields to match the wire format. Sensitive fields (provider, provider_cost, credits_actual) are stripped server-side for non-admin callers.

getStatus(id)

getStatus(id: string): Promise<{ data: JobStatusResult }>

Returns the lean status of a job — id, status, progress, output_data, and error_message (GET /v1/jobs/:id/status). Far less wire + CPU cost than get() because it skips input_data JSONB and the public sanitize pass. Intended for poll loops. Same auth and ownership semantics as get().

const { data } = await client.jobs.getStatus(jobId)
if (data.status === "completed") console.log(data.output_data)

cancel(id)

cancel(id: string): Promise<CancelJobResult>

Cancels a job and refunds any reserved credit holds. Returns { success: true, cancelled: number }.

const { cancelled } = await client.jobs.cancel(jobId)

client.executions

A “workflow execution” is one orchestrator-driven run of a workflow. It groups N jobs (one per AI node) plus inline node states.

get(id)

get(id: string): Promise<{ data: WorkflowExecution }>

Returns the full execution including per-node state map. Falls back to a synthetic single-node-job shape on the server when the ID matches a standalone job.

const { data } = await client.executions.get(executionId)
console.log(data.status, data.completedNodes, data.totalNodes)

listForWorkflow(workflowId, params?)

listForWorkflow(
  workflowId: string,
  params?: ListExecutionsForWorkflowParams,
): Promise<ListExecutionsPage<WorkflowExecutionSummary>>

Cursor-paginated list of executions for one workflow. Merges proper executions with standalone single-node jobs.

Params:

Field Type Description
limit number Page size.
cursor string Opaque cursor from a previous page.
status string Comma-separated, e.g. "pending,running".
source "editor" \| "all" "editor" excludes app-run / webhook / schedule executions.
const { data, nextCursor } = await client.executions.listForWorkflow(
  workflowId,
  { limit: 20, status: "completed" },
)

cancel(id, params?)

cancel(id: string, params?: CancelExecutionParams): Promise<{ success: true }>

Cancels an execution. Three modes:

await client.executions.cancel(executionId, { mode: "after_current" })
await client.executions.cancel(executionId, { mode: "discard" })

client.nodes

Public node-metadata discovery. Both endpoints are publicly cacheable for 5 minutes server-side.

list()

list(): Promise<{ data: NodeDescriptor[] }>

Lists every node type the server supports.

const { data: nodes } = await client.nodes.list()
const imageGenerators = nodes.filter(n => n.category === "ai-image")

get(type)

get(type: string): Promise<{ data: NodeDescriptor }>

Fetches one descriptor by its type slug (e.g. "generate-image", "generate-video").

const { data } = await client.nodes.get("generate-image")
console.log(data.providers, data.creditCost)

run(type, params?)

run(type: string, params?: Record<string, unknown>): Promise<RunNodeResult>

Run a single node directly without wrapping it in a workflow. Posts params as the request body to POST /v1/<type> — the route convention every generation node follows (generate-image, image-to-video, text-to-speech, etc.). This is the SDK equivalent of the MCP server’s verb tools and the path the Nodaro CLI uses for nodaro nodes run <type>.

Most node types are async: the response includes { jobId } and the actual generation runs on a worker. Poll client.jobs.get(jobId) until completed. Inline node types (combine-text, etc.) return their full result synchronously without a jobId field.

const result = await client.nodes.run("generate-image", {
  prompt: "a snow leopard in the mountains",
  provider: "recraft",
})
if ("jobId" in result) {
  const { data: job } = await client.jobs.get(result.jobId)
  console.log(job.output_data)
}

Seedance 2 video (run("text-to-video" | "generate-video", …)): seedance-2 (full) accepts resolution: "4k" and aspectRatio: "adaptive" (plus "21:9"); seedance-2-fast / seedance-2-mini are 480p / 720p only. resolution / aspectRatio are pass-through strings — an unsupported value is ignored, never a 400. Start/end frames and references can coexist (the frames become prompt-directed Image N references; the resolver picks the mode, no toggle). Reference videos are billed unit × (input + output) duration — the runtime ffprobes each referenceVideoUrls clip and scales the per-second -ref rate by the input-video plus output duration. Per-resolution rates are in the Generate Video node docs.

Text to Speech provider default. run("text-to-speech", …) and runAndWait("text-to-speech", …) default provider to elevenlabs-v3 when omitted — but only when text is within v3’s per-request cap (3,000 chars; see the per-model caps table in the Text to Speech node docs). Text longer than that without an explicit provider falls back to elevenlabs-turbo (cap 40,000) instead, so legacy integrations that always omit provider don’t get silently truncated by v3’s tighter cap. An explicit provider is always respected regardless of text length (its own cap still clamps the stored record, unchanged).

Typed structured references. run("generate-image" | "generate-video", …) have typed overloads — GenerateImageParams / GenerateVideoParams (both extend StructuredReferenceParams). Pass connectedReferences: ConnectedReference[] (the editor’s wired-reference shape, re-exported from the SDK) + referenceOrder for labeled, ordered references the route assembles into @image_N directives — instead of hand-building a prose “Image N is …” guide. Each ConnectedReference may also carry an opt-in (default-off) identityLock?: { enabled: boolean; text?: string }: with enabled: true the route prepends a short identity-lock fidelity line for that reference (text overrides the built-in per-source wording; {ref} is the placeholder for the reference’s binding — reference image A / @image_N), honored when the route assembles in the hybrid reference format. See the Reference Roles guide for the role-label + lock model.

Assemble Narrated Video. run("assemble-narrated-video", …) also has a typed overload — AssembleNarratedVideoParams: blocks: { videoUrl: string; audioUrl?: string }[] (1–60, in play order) plus voiceVolume (0–200, default 100), clipAudioVolume (0–200, default 40), maxSlowdown (1–2, default 1.5), trimStartFrames / trimEndFrames (0–120, default 0). See the Assemble Narrated Video node docs for the fit policy and credit formula (3 + ceil(blocks / 6)).

Reasoning effort. LLM-backed feature routes accept an optional reasoningEffort field in the request body: "none" | "low" | "medium" | "high" | "xhigh" | "max", model-dependent (see the model table in the Generate Text node docs). Omit it — or pick a level the model doesn’t support — for the vendor default (“Auto”). xhigh and max bill one tier up (economy → standard, standard → premium); see Reasoning effort for the exact rule and worked examples. Workflow/canvas LLM nodes carry the same field on their node data (reasoningEffort?: LlmReasoningEffort), and client.promptHelper.* accepts it directly in its request body.

client.nodes.run(type, params) POSTs params straight to POST /v1/<type> — that matches the registered route only for generate-script, image-critic, qa-check, and describe-to-picker. Other LLM-backed node types register at a nested path instead: llm-chat/v1/llm-chat/generate, after-effects/v1/after-effects/generate, motion-graphics/v1/motion-graphics/generate, lottie-overlay/v1/lottie-overlay/generate, 3d-title/v1/3d-title/generate, image-to-text/v1/image-to-text/describe, video-composer/v1/scene-graph/generate. For those, call client.request("POST", "<path>", { body: params }) directly, or use client.promptHelper.* (always /v1/prompt-helper/wizard, regardless of node type).

// Bare-path node type — client.nodes.run() posts directly to /v1/generate-script.
await client.nodes.run("generate-script", {
  prompt: "A 3-scene product launch script for a smart water bottle.",
  llmModel: "gpt-5.6-sol",
  reasoningEffort: "high",
})

// client.promptHelper.* takes the same field and works for any node type —
// it always posts to /v1/prompt-helper/wizard.
await client.promptHelper.enhance({
  nodeType: "generate-image",
  prompt: "a snow leopard in the mountains",
  reasoningEffort: "high",
})

Every other node type keeps the generic Record<string, unknown> body — generate-image, generate-video, and assemble-narrated-video are currently the only three with dedicated typed params.

runAndWait(type, params?, opts?)

runAndWait(
  type: string,
  params?: Record<string, unknown>,
  opts?: RunAndWaitOptions,
): Promise<NodeJobOutput>

Runs a single async node to completion: calls run(), extracts the jobId, then client-polls jobs.getStatus(jobId) every opts.pollMs (default 2000 ms) until a terminal status, up to opts.maxMs (default ~15 min).

Resolves the job’s typed output_data (NodeJobOutput) on completed.

RunAndWaitOptions:

Field Type Default Description
signal AbortSignal Abort the poll loop; rejects with JobAbortedError.
onProgress (status: JobStatusResult) => void Called with each lean status observed.
pollMs number 2000 Poll interval in ms.
maxMs number 900_000 Wall-clock cap before JobTimeoutError.

Throws (all typed, catchable by instanceof):

Slow recoveries can outlive the default maxMs. If the platform’s worker abandons a job after the provider already delivered, the job stays processing while the reconcile system self-heals it — the status payload carries recovering: true during that window, and recovery can take tens of minutes for slow models. A JobTimeoutError does NOT cancel the job: it usually still completes server-side and lands in your library; re-fetch with jobs.get(jobId) later, or raise maxMs for long-running models.

const output = await client.nodes.runAndWait("generate-image", {
  prompt: "a snow leopard in the mountains",
  provider: "recraft",
})
console.log(output.imageUrl)

runMany(type, paramsList, opts?)

runMany(
  type: string,
  paramsList: Record<string, unknown>[],
  opts?: RunAndWaitOptions,
): Promise<RunManyResult[]>

Fan out N async runs of the same node type concurrently — the candidate-grid path (generate N stills/clips in parallel). Each runs via runAndWait(); resolves once ALL settle, to an array of { jobId, output } in input order. Rejects if any single run rejects. A shared signal aborts the whole batch.

const results = await client.nodes.runMany("generate-image", [
  { prompt: "snow leopard, sunrise" },
  { prompt: "snow leopard, golden hour" },
  { prompt: "snow leopard, blue hour" },
])
for (const { jobId, output } of results) {
  console.log(jobId, output.imageUrl)
}

client.characters

Script the full character lifecycle — identity edits, portrait + asset generation, motion clips, and LLM-captioned approval.

A “character” is the canonical identity row that Character Studio drives (characters table). Each row carries the portrait URL, six asset buckets (expressions, poses, motions, angles, bodyAngles, lightingVariations), reference photos, and the LLM caption that anchors identity in downstream prompts.

list(params?)

list(params?: ListCharactersParams): Promise<{ characters: Character[] }>

Lists the caller’s characters. By default returns active characters only; pass archived: true for an “archive” view. projectId further restricts to a single project. limit caps the result (server default 100, max 500).

const { characters } = await client.characters.list({ projectId, limit: 50 })

get(id)

get(id: string): Promise<CharacterDetail>

Fetches a single character + three live-progress buckets (pendingJobs, portraitCandidates, previousCandidates) the studio uses to rehydrate spinners after a reload.

const character = await client.characters.get(characterId)

Soft-deleted characters are returned by id intentionally so canvas nodes that hold a stale characterDbId keep loading.

upsert(input) / create(input) / update(id, input)

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

upsert() creates when input.id is omitted and updates when it is set. create() and update() are thin wrappers that pin id for you. On UPDATE only the fields you supply are written; omitted fields are not touched — including name, which is optional on UPDATE (the route accepts partial updates without forcing you to re-send the existing name).

Name collisions return 409 name_taken. To auto-number a placeholder, pass the placeholder name imported from @nodaro/shared.

const { id } = await client.characters.create({
  nodeId: "scripted",
  name: "Kira",
  description: "young protagonist with auburn hair",
  style: "realistic",
  seedPrompt: "kira portrait, warm natural lighting",
  identityLock: "strict", // off | soft | strict — face-preservation strength for Studio asset generation (default strict)
})

delete(id)

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

Soft-deletes (archives) a character. The row is hidden from list() by default but still loadable via get(id). Use restore(id) to un-archive.

restore(id)

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

Un-archives a soft-deleted character. If the name now collides with another active character, the server auto-suffixes "(restored)" and returns the effective name.

duplicate(id, input?)

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

Forks a character to a new row with "(copy)" suffix. Asset URLs are shared by reference; the new row diverges by regenerating any of them.

usage(id)

usage(id: string): Promise<CharacterUsage>

Returns the count of workflows that reference this character. Powers the library’s “Archive” confirmation modal.

generate(input)

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

Fires the portrait-generation pipeline (POST /v1/generate-character). With count > 1, all jobs are reserved up-front before any is enqueued — mid-batch failures roll back atomically.

When attachToCharacterId is set, the worker writes the result directly to the row’s source_image_url; for multi-candidate runs, use approvePortrait() to pick a candidate.

Optional quality ("medium" / "high" / "basic") and resolution ("1K" / "2K" / "4K" / "0.5 MP" / "1 MP" / "2 MP" / "4 MP") select the image model’s output tier and are credit-affecting — they price exactly like Generate Image (composite ids such as gpt-image:high / nano-banana-pro:4K, so a 4K/high run reserves more than the model’s base cost). A value the chosen model doesn’t support is ignored, never rejected. generateAsset() accepts the same two fields.

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

generateAsset(input)

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

Generates a single expression / pose / lighting / angle variant from the character’s anchor portrait. Pass the attachTo* triple to auto-append the result to the row’s named bucket on completion.

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

generateMotion(input)

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

Animates the character’s portrait into a motion clip via Generate Video (image-to-video mode). The result is appended to the motions[] bucket when attachToCharacterId is set. The route can fall back to the row’s anchor portrait when sourceImageUrl is omitted.

await client.characters.generateMotion({
  name: "Kira",
  motionPrompt: "slow head turn left, soft smile",
  provider: "kling",
  attachToCharacterId,
  attachName: "head turn",
})

approvePortrait(id, candidateJobId)

approvePortrait(id: string, candidateJobId: string): Promise<ApprovePortraitResult>

Picks a completed generate() candidate as the character’s canonical portrait. Sets source_image_url and fires an LLM caption (Claude Sonnet vision) inline. Returns the new portrait URL plus the caption.

canonicalDescription is null when the LLM call sub-failed (portrait still set — retry with recaption()).

const { portraitUrl, canonicalDescription } =
  await client.characters.approvePortrait(characterId, candidateJobId)

recaption(id)

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

Re-runs the LLM caption against the current portrait. Returns 400 no_portrait if none is set; 502 on LLM failure.

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

client.locations

Script the full location lifecycle — identity edits, establishing-shot + variant generation, atmosphere motion clips, and LLM-captioned approval.

A “location” is the canonical environment row that Location Studio drives (locations table). Each row carries the main image URL, six asset buckets (timeOfDay, weather, seasons, angles, lighting, atmosphereMotions), reference photos, and the LLM caption that anchors the setting in downstream prompts. See Location Platform for the full data-model walkthrough.

list(params?)

list(params?: ListLocationsParams): Promise<{ locations: Location[] }>

Lists the caller’s locations. By default returns active locations only; pass archived: true for an “archive” view.

const { locations } = await client.locations.list()
const { locations: archived } = await client.locations.list({ archived: true })

listArchived(params?)

listArchived(params?: Omit<ListLocationsParams, "archived">): Promise<{ locations: Location[] }>

Convenience wrapper for list({ archived: true }). Returns soft-deleted rows so callers can drive a UI “Archived” tab without re-encoding the query param. Mirrors client.objects.listArchived.

const { locations: archived } = await client.locations.listArchived()

get(id)

get(id: string): Promise<LocationDetail>

Fetches a single location including pendingJobs (in-flight asset generations the studio uses to rehydrate spinners after a reload) and previousCandidates (completed candidate main images whose URL differs from the current sourceImageUrl, newest first, max 5 — the “pick from N / keep the original” strip; promote one with approveMainImage(id, jobId)). Soft-deleted locations are returned by id intentionally so canvas nodes that hold a stale locationDbId keep loading.

const location = await client.locations.get(locationId)

create(input) / update(id, input)

create(input: CreateLocationInput): Promise<{ id: string }>
update(id: string, input: UpdateLocationInput): Promise<UpdateLocationResult>

create() requires name + nodeId (the route 400s otherwise). For MCP / SDK callers without a canvas node, use the "mcp-managed" sentinel.

update() is a partial — only the fields you pass get written. Worker- owned asset buckets are intentionally NOT exposed on this surface (a stale snapshot save would clobber append_location_asset writes from a worker).

UpdateLocationInput fields:

Field Type Description
name string Location name.
description string Free-text description.
category string Location category.
style string Visual style (e.g. "realistic", "anime").
sourceImageUrl string Main establishing-shot URL.
referencePhotos LocationReferencePhoto[] Mood-board refs (cap 20).
canonicalDescription string LLM-authored caption.
styleLock boolean Whether asset gens should anchor to canonical style.
piiConsentAt string ISO-8601 timestamp recording when PII consent was captured for reference photos.
expectedUpdatedAt string Optimistic-concurrency token (row’s current updated_at).

Optimistic-concurrency: pass expectedUpdatedAt to require the row’s updated_at still matches; on mismatch the route returns 409 concurrent_modification. The SDK surfaces that as a generic NodaroError with the same code — catch it, re-fetch, merge, and retry.

const { id } = await client.locations.create({
  nodeId: "mcp-managed",
  name: "Rainy Tokyo Alley",
  description: "Neon-soaked alley with vending machines",
  category: "urban",
  style: "realistic",
})

await client.locations.update(id, {
  canonicalDescription: "...",
  styleLock: false,
  // PII consent for reference photos (Phase 2 #7) — set when first
  // attaching `referencePhotos` to record that the user has rights.
  piiConsentAt: new Date().toISOString(),
  expectedUpdatedAt: location.updatedAt,
})

delete(id) / restore(id)

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

Soft-delete + un-archive. delete() is the only delete operation the SDK exposes; permanent destruction is UI-only by design. If a restored name collides (case-insensitive) with an active row, the server auto-suffixes (restored) and returns the effective name.

await client.locations.delete(locationId)
const { name } = await client.locations.restore(locationId)

generate(input)

generate(input: GenerateLocationInput): Promise<GenerateLocationResult>

Fires POST /v1/generate-location to produce one or more candidate establishing-shot images. With count > 1, all jobs are reserved up-front before any is enqueued — mid-batch failures roll back atomically.

When attachToLocationId is set AND count === 1, the worker writes the result directly to the row’s source_image_url; otherwise call approveMainImage() after picking a candidate.

Optional quality ("medium" / "high" / "basic") and resolution ("1K" / "2K" / "4K" / "0.5 MP" / "1 MP" / "2 MP" / "4 MP") select the image model’s output tier and are credit-affecting — they price exactly like Generate Image (composite ids such as gpt-image:high / nano-banana-pro:4K, so a 4K/high run reserves more than the model’s base cost). A value the chosen model doesn’t support is ignored, never rejected. generateAsset() accepts the same two fields.

// Single candidate — auto-attaches on completion
const { jobId } = await client.locations.generate({
  name: "Rainy Tokyo Alley",
  description: "Neon-soaked alley with vending machines",
  attachToLocationId: locationId,
})

// Multi-candidate
const { jobIds } = await client.locations.generate({
  name: "Rainy Tokyo Alley",
  count: 4,
})

generateAsset(input)

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

Fires POST /v1/generate-location-asset to produce a single variant. assetType is one of timeOfDay / weather / seasons / angles / lighting / custom. When the studio path is set (attachToLocationId + attachToColumn + attachName), the worker appends { name: attachName, url: <result> } to the named JSONB bucket on completion.

const { jobId } = await client.locations.generateAsset({
  name: "Rainy Tokyo Alley",
  assetType: "weather",
  variant: "storm",
  attachToLocationId: locationId,
  attachToColumn: "weather",
  attachName: "storm",
})

generateSurroundContinuation(input)

generateSurroundContinuation(input: GenerateSurroundContinuationInput): Promise<{ jobId: string }>

Cloud edition only. The route is registered on every edition, but Community/Business requests get an immediate 403 edition_required before any processing happens.

Fires POST /v1/generate-surround-continuation to produce the next seamless 360° ring view as an image-to-image continuation of referenceImageUrl (the previous ring view). The platform builds the half-carry composite server-side (carry the reference’s trailing half per direction, gray the rest), paints the gray region, then color-harmonizes the painted half to the carried half so there is no tonal seam down the frame’s center — the carried half stays pixel-exact, so adjacent ring views stitch perfectly. direction is right / up / down; carriedFraction defaults to 0.5. When the studio path is set, the worker appends the result to the location’s bucket (studio uses attachToColumn: "angles", attachName: "Surround 45°").

const { jobId } = await client.locations.generateSurroundContinuation({
  referenceImageUrl: previousRingView,
  direction: "right",
  degrees: 45,
  provider: "nano-banana-pro",
  aspectRatio: "16:9",
  attachToLocationId: locationId,
  attachToColumn: "angles",
  attachName: "Surround 45°",
})

generateMotion(input)

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

Fires POST /v1/generate-location-motion to animate the location’s establishing shot into an atmospheric motion clip (Generate Video, image-to-video mode). The attach column is hardcoded server-side to atmosphere_motions (locations have a single motion bucket so callers don’t supply attachToColumn).

// New atmosphere clip from the approved main image
const { jobId } = await client.locations.generateMotion({
  name: "Rainy Tokyo Alley",
  motionPrompt: "slow dolly-in, neon signs flicker, light rain falling",
  sourceImageUrl: mainImageUrl,
  provider: "kling",
  attachToLocationId: locationId,
  attachName: "neon dolly-in",
})

approveMainImage(id, candidateJobId)

approveMainImage(id: string, candidateJobId: string): Promise<ApproveMainImageResult>

Approves a completed generate() candidate as the location’s main image. Sets source_image_url + fires the LLM caption (Claude Sonnet vision) inline. Returns the new main-image URL plus the caption.

Caption-failure semantics: canonicalDescription is null when the LLM sub-call failed (the wire sends "", but the SDK normalizes ""null before returning so callers see string | null). The main image is still set; call recaption() to retry.

const { sourceImageUrl, canonicalDescription } =
  await client.locations.approveMainImage(locationId, candidateJobId)

recaption(id)

recaption(id: string): Promise<RecaptionLocationResult>

Re-fires the LLM caption against the location’s current main image. 502s on LLM failure (unlike approveMainImage which preserves the side-effect and normalizes the caption to null); 400 no_source_image if no main image is set yet.

const { canonicalDescription } = await client.locations.recaption(locationId)

client.objects

Script the full object (prop / product / vehicle / etc.) lifecycle — identity edits, main-image + variant generation, motion clips, and LLM-captioned approval.

An “object” is the canonical product / prop row that Object Studio drives (objects table). Each row carries the main image URL, four asset buckets (angles, materials, variations, motionClips), reference photos, and the LLM caption that anchors the prop in downstream prompts. See Object Platform for the full data-model walkthrough.

list(params?)

list(params?: ListObjectsParams): Promise<{ objects: Object[] }>

Lists the caller’s objects. By default returns active objects only; pass archived: true for an “archive” view. Optional projectId scopes the result to a single project.

const { objects } = await client.objects.list()
const { objects: archived } = await client.objects.list({ archived: true })

Object shadows the JS global, which TypeScript handles cleanly via local-scope resolution. Callers who need both can alias as import type { Object as NodaroObject } from "@nodaro/sdk".

listArchived(params?)

listArchived(params?: Omit<ListObjectsParams, "archived">): Promise<{ objects: Object[] }>

Convenience wrapper for list({ archived: true }). Returns soft-deleted rows so callers can drive a UI “Archived” tab without re-encoding the query param.

const { objects } = await client.objects.listArchived()

get(id)

get(id: string): Promise<ObjectDetail>

Fetches a single object including pendingJobs (in-flight asset generations the studio uses to rehydrate spinners after a reload).

Soft-deleted (archived) objects are NOT returned by id — the route enforces deleted_at IS NULL and surfaces archived rows as a uniform 404 not_found. The SDK throws NotFoundError.

const object = await client.objects.get(objectId)

create(input) / update(id, input)

create(input: CreateObjectInput): Promise<{ id: string }>
update(id: string, input: UpdateObjectInput): Promise<UpdateObjectResult>

create() requires name + nodeId (the route 400s otherwise). For MCP / SDK callers without a canvas node, use the "mcp-managed" sentinel.

update() is a partial — only the fields you pass get written. Worker- owned asset buckets are intentionally NOT exposed on this surface (a stale snapshot save would clobber append_object_asset writes from a worker).

Optimistic-concurrency: pass expectedUpdatedAt to require the row’s updated_at still matches; on mismatch the route returns 409 concurrent_modification. The SDK surfaces that as a generic NodaroError with the same code — catch it, re-fetch, merge, and retry.

Objects do not carry a piiConsentAt field. Reference photos on object rows attach without a dedicated consent gate (unlike locations Phase 2 #7, objects are inanimate by definition).

const { id } = await client.objects.create({
  nodeId: "mcp-managed",
  name: "Antique Lantern",
  description: "Weathered brass lantern with hand-engraved filigree",
  category: "tool",
  style: "realistic",
})

await client.objects.update(id, {
  canonicalDescription: "...",
  styleLock: false,
  expectedUpdatedAt: object.updatedAt,
})

delete(id) / restore(id)

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

Soft-delete + un-archive. delete() is the idempotent soft path — repeating it on an already-archived row is a no-op. If a restored name collides (case-insensitive) with an active row, the server auto-suffixes (restored) and returns the effective name.

await client.objects.delete(objectId)
const { name } = await client.objects.restore(objectId)

permanentDelete(id)

permanentDelete(id: string): Promise<{ success: true; permanent: true }>

Hard-delete (permanent) an object — the row + every R2 asset it references. Archived rows ONLY: active objects return 400 not_archived. Call delete() first to archive, then permanentDelete() to destroy.

Mirrors the app_runs permanent-delete pattern (archive-first) so a stray SDK / curl caller cannot bypass the studio’s archive-first UI flow.

await client.objects.delete(objectId)
await client.objects.permanentDelete(objectId)

The MCP surface intentionally omits this operation — destructive ops driven by an LLM are unsafe to expose.

generate(input)

generate(input: GenerateObjectInput): Promise<GenerateObjectResult>

Fires POST /v1/generate-object to produce one or more candidate main images. With count > 1, all jobs are reserved up-front before any is enqueued — mid-batch failures roll back atomically.

When attachToObjectId is set AND count === 1, the worker writes the result directly to the row’s source_image_url; otherwise call approveMainImage() after picking a candidate.

GenerateObjectResult always returns { jobIds: string[] } (one id per candidate). jobId? is a deprecated count === 1 back-compat alias — prefer jobIds. Iterate result.jobIds regardless of count:

// Single candidate — auto-attaches on completion
const result = await client.objects.generate({
  name: "Antique Lantern",
  description: "Weathered brass lantern",
  attachToObjectId: objectId,
})

// jobIds is always present — one entry per candidate
for (const jobId of result.jobIds) {
  // poll each candidate (worker auto-attaches on completion when count === 1)
}

seedPromptHint (parameter-picker pass-through) is a top-level field — pass it to compose a catalog selection (e.g. “antique brass lantern” from the Material picker) into the generated prompt.

generateAsset(input)

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

Fires POST /v1/generate-object-asset to produce a single variant. assetType is one of angles / materials / variations / motion / custom. When the studio path is set (attachToObjectId + attachToColumn + attachName), the worker appends { name: attachName, url: <result> } to the named JSONB bucket on completion.

Studio-gated LLM draft: when attachToObjectId is set and description is omitted, the route first invokes an LLM to draft a per-variant prompt fragment off the parent object’s canonical_description + the new variant name. Without attachToObjectId, the route trusts the caller-supplied prompt as-is.

attachToColumn is REQUIRED for assetType === "custom" — the worker can’t infer the bucket from the asset type. For canonical asset types (angles / materials / variations / motion), the column is derived automatically by the route.

const { jobId } = await client.objects.generateAsset({
  name: "Antique Lantern",
  assetType: "materials",
  variant: "gold",
  attachToObjectId: objectId,
  attachToColumn: "materials",
  attachName: "gold",
})

generateMotion(input)

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

Fires POST /v1/generate-object-motion to animate the object’s main image into a motion clip (Generate Video, image-to-video mode). The attach column is hardcoded server-side to motion_clips (objects have a single motion bucket so callers don’t supply attachToColumn).

Object-specific defaults vs location:

Pass refineFromVideoUrl to route through video-to-video using that clip as the source instead of running Generate Video from sourceImageUrl — use to iterate an existing clip with a new prompt without shifting composition.

sourceImageUrl is REQUIRED. Image-to-video needs a source frame and the route has no fallback — supply the canonical product-shot URL explicitly.

// New motion clip from the approved main image
const { jobId } = await client.objects.generateMotion({
  name: "Antique Lantern",
  motionPrompt: "slow 360 rotation, soft golden rim light",
  sourceImageUrl: mainImageUrl,
  provider: "kling-turbo",
  attachToObjectId: objectId,
  attachName: "rotate-360",
})

// Refine an existing clip (video-to-video)
const { jobId: refineJobId } = await client.objects.generateMotion({
  name: "Antique Lantern",
  motionPrompt: "same shot but slow hover instead of rotation",
  sourceImageUrl: mainImageUrl,
  refineFromVideoUrl: existingRotationClipUrl,
  provider: "wan-i2v",
  attachToObjectId: objectId,
})

approveMainImage(id, candidateJobId, expectedUpdatedAt?)

approveMainImage(
  id: string,
  candidateJobId: string,
  expectedUpdatedAt?: string,
): Promise<ApproveObjectMainImageResult>

Approves a completed generate() candidate as the object’s main image. Sets source_image_url + fires the LLM caption (Claude Sonnet vision) inline. Returns the new main-image URL plus the caption.

Caption-failure semantics: canonicalDescription is null when the LLM sub-call failed (the wire sends "", but the SDK normalizes ""null before returning so callers see string | null). The main image is still set; call recaption() to retry.

Optimistic-concurrency: pass expectedUpdatedAt to gate the update on the row’s current updated_at; on mismatch the route returns 409 concurrent_modification carrying the fresh token.

const { sourceImageUrl, canonicalDescription } =
  await client.objects.approveMainImage(objectId, candidateJobId)

recaption(id)

recaption(id: string): Promise<RecaptionObjectResult>

Re-fires the LLM caption against the object’s current main image. 502s on LLM failure (unlike approveMainImage which preserves the side-effect and normalizes the caption to null); 400 main_image_required if no main image is set yet.

The route is a pure idempotent retry — it does NOT accept an expectedUpdatedAt parameter (per Phase E1 calibration finding: backend route is idempotent retry, not gated on optimistic-concurrency). The method signature is therefore recaption(id) with no second argument.

const { canonicalDescription } = await client.objects.recaption(objectId)

client.creatures

Creature library CRUD (/v1/creatures) — the creature row is a structural sibling of objects (angles / poses / variations / motionClips buckets, species free-text delta) with two creature-specific additions:

Making a creature talk — the speech routes are generic, so no creature-specific endpoints exist (or are needed); drive them through the node runner exactly like Boards drive generate-image:

// 1. Render speech with the creature's voice.
const speech = await client.nodes.runAndWait("text-to-speech", {
  text: "I knocked the vase off the shelf. I regret nothing.",
  voice: creature.voice!.voiceId,
  provider: creature.voice!.ttsProvider,
  voiceType: creature.voice!.voiceType,
})
// 2. Lip-sync the audio onto the creature's main image.
const clip = await client.nodes.runAndWait("lip-sync", {
  imageUrl: creature.sourceImageUrl!,
  audioUrl: speech.audioUrl,
  provider: "kling-avatar",
})

The same node also dubs an existing video — pass videoUrl + a video-input provider instead of an image. volcengine-lipsync is the cheapest dubbing option and the only one with multi-speaker support (extra fields pass straight through to the route; via the CLI use --param videoUrl=… --param provider=volcengine-lipsync …):

const dub = await client.nodes.runAndWait("lip-sync", {
  videoUrl: "https://…/scene.mp4",
  audioUrl: "https://…/new-vocal.mp3",
  provider: "volcengine-lipsync",
  mode: "basic",          // complex scenes
  openScenedet: true,     // multi-speaker: scene detection + speaker ID (basic mode)
  audioDurationSec: 42,   // buckets per-second pricing; absent → 5-min ceiling, no refund
})

Binding a creature into a shot uses the shared reference contract: toConnectedReference({ kind: "creature", id, name, url, description }) (from @nodaro/shared) emits a wired-creature reference — it auto-attaches to generate-image and receives a creature/animal-subject identity directive (anatomy, markings, coloration lock) with zero typing.


client.pipelines

Story-to-Video pipeline operations. Pipelines orchestrate multi-stage AI production runs (script → characters → objects → locations → shot list → scene images → animate + audio + edit → post merge).

create(input)

create(input: PipelineInput): Promise<{ id: string }>

Start a new pipeline (headless film generation) — the programmatic equivalent of the studio’s “Create film”. In Auto mode the engine self-advances to completion; poll get() for status and getTimeline() for the assembled output. In manual/guided mode, drive it with pendingApprovals() + approveStage() / approveSubGate(). Requires pipelines:execute scope.

const { id } = await client.pipelines.create({ /* PipelineInput */ })

get(id)

get(id: string): Promise<PipelineRecord>

Fetch current pipeline state: status, current_stage, credit counters, mode, and failure_reason (set when status='failed'). Poll this to track a headless Auto run to completion. Requires pipelines:read.

const pipeline = await client.pipelines.get(id)
console.log(pipeline.status, pipeline.current_stage)

list()

list(): Promise<PipelineRecord[]>

List the caller’s pipelines (most recent first). Requires pipelines:read.

const pipelines = await client.pipelines.list()

cancel(id)

cancel(id: string): Promise<{ ok: true }>

Cancel a running pipeline. Unspent reserved credits refund. Idempotent on an already-terminal pipeline. Requires pipelines:execute.

await client.pipelines.cancel(id)

pendingApprovals(id)

pendingApprovals(id: string): Promise<PendingApproval[]>

Stages currently awaiting_approval. Empty in a clean Auto run (the engine self-approves); populated in manual/guided mode at each gate. Requires pipelines:read.

const approvals = await client.pipelines.pendingApprovals(id)

approveStage(id, stage, edits?)

approveStage(id: string, stage: PipelineStageName, edits?: unknown): Promise<{ ok: true }>

Approve a stage so the engine advances. An optional edits JSON-Patch is applied to the stage output before approval. Requires pipelines:approve.

await client.pipelines.approveStage(id, "script")
// With edits (JSON Patch):
await client.pipelines.approveStage(id, "script", [{ op: "replace", path: "/title", value: "New Title" }])

rejectStage(id, stage, feedback)

rejectStage(id: string, stage: PipelineStageName, feedback: string): Promise<{ ok: true }>

Reject a stage with feedback; the engine re-runs it incorporating the note. Requires pipelines:approve.

await client.pipelines.rejectStage(id, "script", "Make the story darker and more suspenseful")

approveSubGate(id, gate)

approveSubGate(id: string, gate: SubGateName): Promise<{ ok: true; gate: SubGateName; resumed_at: string }>

Approve a Stage-7 sub-gate (dialogue_recheck / silent_cut) so the orchestrator resumes from the next sub-step. Requires pipelines:approve.

await client.pipelines.approveSubGate(id, "dialogue_recheck")

getStage(id, stage)

getStage(id: string, stage: PipelineStageName): Promise<{ status: string; output: unknown; critic_feedback: unknown }>

Read a single stage’s status, output, and critic_feedback. Useful for inspecting the script/plan before approving. Requires pipelines:read.

const { status, output } = await client.pipelines.getStage(id, "script")

getTimeline(id)

getTimeline(id: string): Promise<PipelineTimeline>

Assembled timeline — ordered scene composites + durations + audio URLs + live animate progress (animateProgress). The output a headless caller renders or hands to a downstream editor. Requires pipelines:read.

const timeline = await client.pipelines.getTimeline(id)
for (const scene of timeline.scenes) {
  console.log(scene.compositeUrl, scene.durationSeconds)
}

branch(id, { fromStage })

branch(id: string, input: BranchPipelineInput): Promise<BranchPipelineResult>

Re-run a completed pipeline from a specific stage. Creates a new pipeline with lineage tracked. Upstream stages are cloned as approved. The original pipeline remains in status='completed'. Requires pipelines:execute scope.

const result = await client.pipelines.branch("pipe-1", { fromStage: "scene_images" })
console.log(`New pipeline: ${result.pipelineId}`)
// result: { pipelineId, clonedStages, clonedEntities }

chatStage(pipelineId, stage, message)

chatStage(
  pipelineId: string,
  stage: ChatEnabledStage,
  message: string,
): Promise<ChatStageResult>

Send a chat message to the Showrunner Refinement Director (Guided Mode). Persists user + assistant turns; returns the assistant’s reply and an optional proposed_change the user can applyChatProposal() to commit.

Requires pipelines:approve scope. The pipeline must have mode='guided' and the stage must be awaiting_approval.

const { content, proposed_change } = await client.pipelines.chatStage(
  id,
  "script",
  "Can you make the protagonist's motivation clearer in scene 2?",
)

applyChatProposal(pipelineId, stage, turnId)

applyChatProposal(
  pipelineId: string,
  stage: ChatEnabledStage,
  turnId: string,
): Promise<ApplyChatProposalResult>

Accept a proposed change from a prior assistant turn. Routes through applyStageEdit (validates JSON Patch + per-stage schema + reference integrity, inserts a new attempt row, flips the stage to approved).

Returns { applied: true, attemptId, newOutput } on success, or { applied: false, error } on recoverable failures (the backend already inserted a follow-up assistant turn with a hint). Hard failures throw via the standard error pipeline (HTTP 409). Requires pipelines:approve scope.

const result = await client.pipelines.applyChatProposal(id, "script", turnId)
if (result.applied) {
  console.log("Approved:", result.newOutput)
} else {
  console.log("Recoverable failure:", result.error.code)
}

getStageChat(pipelineId, stage)

getStageChat(pipelineId: string, stage: ChatEnabledStage): Promise<{ turns: ChatTurn[] }>

Fetch the chat history for a stage. Returns an empty array when no turns exist yet. Used by the frontend chat panel on initial mount; subsequent updates arrive via SSE (chat:turn events). Requires pipelines:read scope.

const { turns } = await client.pipelines.getStageChat(id, "script")

client.reduce

Run the Reduce (fan-in) node directly — pick the best of N inputs, concatenate, vote, or merge JSON. Mirrors the MCP reduce tool.

run(input)

run(input: ReduceInput): Promise<ReduceResult>

ReduceInput:

Field Type Required Description
strategyId ReduceStrategyId yes pick-best-llm | concat | first-non-empty | count | vote | merge-json
strategyConfig Record<string, unknown> no Strategy-specific config (see below). Defaults to {} (each strategy’s defaults).
inputs string[] yes Up to 1000 input strings.
workflowId string no Associates this reduce run with a workflow (for execution-history display).

strategyConfig per strategy:

Strategy Config shape
pick-best-llm { criteria: string, inputKind?: "text" \| "image-url" }
concat { separator?: string } (default "\n\n")
first-non-empty {}
count {}
vote { caseSensitive?: boolean } (default false)
merge-json { strategy?: "deep" \| "shallow" } (default "deep")

ReduceResult:

{
  jobId: string
  output: string         // chosen / joined value (stringified)
  meta: {
    selectedIndex?: number  // set by pick-best-llm, vote
    reasoning?: string      // set by pick-best-llm
    summary: string         // always present
  }
}
const result = await client.reduce.run({
  strategyId: "pick-best-llm",
  strategyConfig: { criteria: "sharpest", inputKind: "image-url" },
  inputs: [url1, url2, url3, url4, url5],
})
console.log(result.output, result.meta.reasoning)

Throws a NodaroError (status 400, code: "no_valid_inputs") when every input is empty / whitespace. Credits are reserved by the same creditGuard middleware used by all generation routes, so insufficient credits surface as InsufficientCreditsError.


client.promptHelper

AI prompt assistance for generation nodes. All three methods delegate to POST /v1/prompt-helper/wizard (see API Integration §12) and reserve credits per call.

All three inputs also accept optional llmModel and reasoningEffort fields (the latter is model-dependent — unsupported or omitted levels fall back to the vendor default). Both are forwarded to the underlying LLM call and affect credit cost the same way as every other LLM-backed node — see Reasoning effort. The CLI exposes the same lever as --llm-model <id> / --reasoning-effort <level> on nodaro prompt wizard/analyze/generate/enhance.

analyze(input)

analyze(input: AnalyzeInput): Promise<{ jobId: string; questions: WizardQuestion[] }>

Turns a rough idea into guided questions for a target node type. Pair the returned questions with generate().

const { questions } = await client.promptHelper.analyze({
  nodeType: "generate-image",
  prompt: "a snow leopard",
})

generate(input)

generate(input: GenerateInput): Promise<{ jobId: string; prompt: string; recommendedModel?: RecommendedModel }>

Builds a single optimized prompt from the selected answers. Each selection is { category, value, isCustom }.

const { prompt } = await client.promptHelper.generate({
  nodeType: "generate-image",
  selections: [{ category: "subject", value: "snow leopard", isCustom: false }],
})

enhance(input)

enhance(input: EnhanceInput): Promise<{ jobId: string; prompt: string; recommendedModel?: RecommendedModel }>

One-shot “improve this prompt” — skips the questions round-trip and returns the optimized prompt directly.

const { prompt } = await client.promptHelper.enhance({ nodeType: "generate-image", prompt: "a snow leopard" })

client.apps

Browse and run published apps — a workflow wrapped in a curated input/output presentation. list() and get() are public; run() and the run-history methods authenticate as the caller.

list(params?)

list(params?: ListAppsParams): Promise<ListAppsResult>

Cursor-paginated browse of published apps. Optional search, category, and limit (server caps at 50).

const { data, nextCursor } = await client.apps.list({ search: "headshot", limit: 20 })

get(slug)

get(slug: string): Promise<{ data: PublishedAppDetail }>

Fetches one app’s metadata plus its inputSchema (the fields end users fill in) and outputs mapping.

const { data: app } = await client.apps.get("pro-headshot")

run(slug, inputs?)

run(slug: string, inputs?: Record<string, unknown>): Promise<AppRunResult>

Triggers an app run. inputs keys must match the app’s input-schema field names. Returns { executionId, status, runId? } — poll via client.executions.get(executionId).

const { executionId } = await client.apps.run("pro-headshot", { photo: url })

listRuns(slug, params?) / getRun(slug, runId)

listRuns(slug: string, params?: ListAppRunsParams): Promise<{ data: AppRun[]; nextCursor?: string | null }>
getRun(slug: string, runId: string): Promise<{ data: AppRun }>

List past runs for an app, or fetch one run by id.

deleteRun(slug, runId)

deleteRun(slug: string, runId: string): Promise<{ success: true; archived: true }>

Archives (soft-deletes) a run. Restoration and permanent deletion are UI-only by design — SDK / MCP / API delete callers can’t destroy data.

await client.apps.deleteRun("pro-headshot", runId)

client.developerApps

Manage your own OAuth developer apps. Only the owner can read or modify their apps; secrets are returned exactly once.

list()

list(): Promise<{ data: DeveloperApp[] }>
const { data } = await client.developerApps.list()

get(id)

get(id: string): Promise<{ data: DeveloperApp }>
const { data } = await client.developerApps.get(appId)

create(input)

create(input: CreateDeveloperAppInput): Promise<{ data: CreateDeveloperAppResult }>

Creates an app. The response includes clientSecret — store it now, the server only keeps a hash.

const { data } = await client.developerApps.create({
  name: "My integration",
  redirectUris: ["https://example.com/oauth/callback"],
  scopesRequested: ["workflows:read", "workflows:execute"],
})
console.log(data.clientId, data.clientSecret) // save both

Input rules:

update(id, input)

update(id: string, input: UpdateDeveloperAppInput): Promise<{ data: DeveloperApp }>
await client.developerApps.update(appId, {
  redirectUris: ["https://example.com/oauth/callback", "https://staging.example.com/oauth/callback"],
})

delete(id)

delete(id: string): Promise<{ success: true }>
await client.developerApps.delete(appId)

rotateSecret(id)

rotateSecret(id: string): Promise<RotateSecretResult>

Generates a new clientSecret and invalidates the old one. The new secret is returned exactly once.

const { clientSecret } = await client.developerApps.rotateSecret(appId)

client.oauth

OAuth 2.0 + RFC 7009 endpoints used by third-party app servers. The full authorization-code flow is documented in OAuth Flow.

exchangeCode(input)

exchangeCode(input: ExchangeCodeInput): Promise<AccessTokenResponse>

Server-side authorization-code exchange. The SDK adds grant_type: "authorization_code" automatically.

NEVER call this from a browserclient_secret must stay on the server.

const tokens = await client.oauth.exchangeCode({
  client_id: process.env.NODARO_CLIENT_ID!,
  client_secret: process.env.NODARO_CLIENT_SECRET!,
  code: req.query.code as string,
  redirect_uri: "https://example.com/oauth/callback",
})
// tokens: { access_token, token_type, scope, expires_in }

revoke(token)

revoke(token: string): Promise<{ success: true }>

Revokes an access token (RFC 7009). Always returns success even for unknown tokens — the spec forbids leaking validity.

await client.oauth.revoke(accessToken)

getAppInfo(clientId)

getAppInfo(clientId: string): Promise<OAuthAppInfo>

Fetches public metadata about a developer app for rendering a consent screen. Public route — no auth needed.

const info = await client.oauth.getAppInfo("app_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d")
// { name, description, logoUrl, homepageUrl, scopesRequested }

client.voices

ElevenLabs voices: the premade catalog, the community Voice Library, the signed-in user’s voice clones, and the voice changer.

list()

list(): Promise<Voice[]>

List the premade ElevenLabs voices (GET /v1/voices). Falls back to a curated set server-side when no ElevenLabs API key is configured.

const voices = await client.voices.list()

searchLibrary(params?)

searchLibrary(params?: VoiceLibraryParams): Promise<VoiceLibraryResponse>

Search the shared/community Voice Library (GET /v1/voices/library). All params are optional and forwarded as a querystring; undefined / null / empty-string values are omitted so server defaults apply. hasMore in the response drives “load more” pagination.

Each returned voice may carry model-verification hints derived from the library’s verified_languages metadata:

const { voices, hasMore } = await client.voices.searchLibrary({ search: "deep", language: "en" })
const v = voices[0]
await client.nodes.run("text-to-speech", {
  text: "Hello!",
  voice: v.voice_id,
  voiceType: "library",
  ...(v.recommendedProvider ? { provider: v.recommendedProvider } : {}),
})

listClones()

listClones(): Promise<VoiceClone[]>

List the signed-in user’s voice clones (GET /v1/voice-clones). Unwraps the { voiceClones } envelope to the bare array.

const clones = await client.voices.listClones()

createClone(input)

createClone(input: { name: string; audioUrl: string }): Promise<VoiceClone>

Clone a voice from an already-uploaded audio URL (POST /v1/voice-clones/from-url). Costs credits. Returns the created VoiceCloneelevenlabsVoiceId is the id to use at text-to-speech time.

const clone = await client.voices.createClone({
  name: "My Custom Voice",
  audioUrl: "https://cdn.example.com/sample.mp3",
})
console.log(clone.elevenlabsVoiceId)

deleteClone(id)

deleteClone(id: string): Promise<void>

Delete one of the user’s voice clones (DELETE /v1/voice-clones/:id).

await client.voices.deleteClone(cloneId)

change(input)

change(input: {
  voiceId: string
  audioUrl?: string
  videoUrl?: string
  stability?: number
  similarityBoost?: number
  style?: number
  removeBackgroundNoise?: boolean
}): Promise<{ jobId: string }>

Replace the voice in a recording — or in a whole talking video — with a different voice (POST /v1/voice-changer). Pass audioUrl to revoice audio→audio, or videoUrl to revoice an entire clip: the server demuxes the audio, runs speech-to-speech, and remuxes the new voice onto the original video. Exactly one of audioUrl / videoUrl is required; when both are sent, video wins. style is a style exaggeration factor (0–1; default 0 — >0 amplifies delivery at the cost of latency/stability). removeBackgroundNoise off keeps the music/SFX bed under the new voice; on yields a clean voice-only result. Runs async — poll client.jobs.get(jobId).

// Audio → audio
const { jobId } = await client.voices.change({
  audioUrl: "https://cdn.example.com/speech.mp3",
  voiceId: "Rachel",
})

// Video → revoiced video (output_data has videoUrl + audioUrl)
const { jobId: vjobId } = await client.voices.change({
  videoUrl: "https://cdn.example.com/talking.mp4",
  voiceId: "Aria",
})

recast(input)

type VoiceChangerProVoice =
  | string
  | {
      voiceId: string
      stability?: number          // 0–1
      similarityBoost?: number    // 0–1
      style?: number              // 0–1, default 0
      useSpeakerBoost?: boolean
      seed?: number               // int 0–4294967295 — reproducible STS for this speaker
      volumeMode?: "match" | "normalize" | "manual"  // default "match"
      volume?: number             // 0–200 (%), used only when volumeMode === "manual"
    }

recast(input: {
  audioUrl?: string
  videoUrl?: string
  orderedVoices: Array<VoiceChangerProVoice | null>  // 1–8 entries; null = keep that speaker's original voice
  model?: string
  preserveBackground?: boolean             // default true
  separationQuality?: "fast" | "best"      // default "fast"
  removeBackgroundNoise?: boolean
  musicVolumeMode?: "match" | "normalize" | "manual"  // level of preserved background, default "match"
  musicVolume?: number                     // 0–200 (%), used only when musicVolumeMode === "manual"
  voiceFx?: {                              // reverb/echo on the combined voices, pre-background-remix
    preset: AudioFxPreset                  // reverb space / telephone / megaphone / echo / custom
    wetDryMix?: number                     // 0–100, reverb wetness
    delayMs?: number                       // 20–2000, echo delay
    decay?: number                         // 0–1, echo decay
  }
}): Promise<{ jobId: string }>

Recast each detected speaker in a multi-speaker recording to a different voice (POST /v1/voice-changer-pro). orderedVoices maps speaker-detection positions to voices — speaker 0 → orderedVoices[0], speaker 1 → orderedVoices[1], etc. Speakers beyond the end of orderedVoices keep their original voice. Each entry is either a bare voice id (premade name or ElevenLabs UUID) or an object with per-voice ElevenLabs speech-to-speech settings (stability, similarityBoost, style, useSpeakerBoost, seed) plus a loudness volumeMode ("match" matches the original speaker, "normalize" applies loudnorm, "manual" uses volume as a percentage). A per-voice seed (integer 0–4294967295) makes that speaker’s recast reproducible across runs.

An entry may also be null — a keep-slot: that speaker keeps their original voice while later speakers are still recast. At least one entry must be non-null (all-null is rejected). Keep-slots don’t cost credits — pricing counts recast speakers only.

Pass audioUrl for audio → audio recast, or videoUrl to recast the audio track of a video clip (the server demuxes, recasts, and remuxes).

Voice and music are always separated first — before recasting, the source is split into an isolated vocal stem and a music/SFX stem. preserveBackground (default true) only controls whether that music/instrumental stem is mixed back under the new voices; set it false for a clean voice-only result. separationQuality selects the quality of the voice/music separation: "fast" (default, quicker — preserves more of the voice) or "best" (finer voice/music separation). removeBackgroundNoise additionally denoises the result. musicVolumeMode sets the level of the preserved background (only relevant when preserveBackground is on): "match" (default) keeps the original level, "normalize" loudnorms it, "manual" uses musicVolume%. voiceFx applies a reverb/echo to the combined recast voices before the background is mixed back in (so the effect sits on the voices, not the music/SFX bed): reverb presets ("room", "hall", "church", …) use wetDryMix; the "echo" / "custom" presets use delayMs + decay. Cloud-only — costs credits and runs async; poll client.jobs.get(jobId) for the result (output_data.videoUrl + output_data.audioUrl in video mode).

// Two-speaker audio recast (bare voice ids)
const { jobId } = await client.voices.recast({
  audioUrl: "https://cdn.example.com/dialogue.mp3",
  orderedVoices: ["Rachel", "Aria"],
  preserveBackground: true,
})

// Recast speakers 1 and 3, keep speaker 2's original voice (keep-slot)
const { jobId: kept } = await client.voices.recast({
  audioUrl: "https://cdn.example.com/panel.mp3",
  orderedVoices: ["Rachel", null, "Aria"],
})

// Reproducible recast (per-voice seed) + a hall reverb on the voices
const { jobId: reverbed } = await client.voices.recast({
  audioUrl: "https://cdn.example.com/dialogue.mp3",
  orderedVoices: [
    { voiceId: "Rachel", seed: 12345 },
    { voiceId: "Aria", seed: 67890 },
  ],
  voiceFx: { preset: "hall", wetDryMix: 35 },
})

// Per-voice settings + finer separation
const { jobId: tuned } = await client.voices.recast({
  audioUrl: "https://cdn.example.com/dialogue.mp3",
  orderedVoices: [
    { voiceId: "Rachel", stability: 0.6, similarityBoost: 0.8 },
    { voiceId: "Aria", volumeMode: "manual", volume: 120 },
  ],
  separationQuality: "best",
})

// Multi-speaker video recast
const { jobId: vjobId } = await client.voices.recast({
  videoUrl: "https://cdn.example.com/interview.mp4",
  orderedVoices: ["Callum", "Charlotte", "Liam"],
})

client.credits

Authenticated user’s credit balance and per-model cost previews.

balance()

balance(): Promise<UserBalance>

GET /v1/user/credits → the authenticated user’s credit balance and tier info. Throws UnauthorizedError (401) when signed out.

UserBalance:

Field Type Description
total number Total available credits.
subscription number Credits from the current subscription cycle.
topup number One-off purchased credits.
dailySpent number Credits spent in the current calendar day.
dailyLimit number \| null Daily spending cap (null = no cap).
monthlyAllocation number Credits allocated per billing cycle.
tier string Subscription tier (e.g. "free", "pro").
features Record<string, unknown> Feature flags for the tier.
periodEnd string \| null ISO-8601 end of the billing period.
appCreditsAllowance number Credits earned for app usage (free tier only).
const balance = await client.credits.balance()
console.log(`${balance.total} credits available (${balance.tier} tier)`)

modelCosts(ids)

modelCosts(ids: string[]): Promise<ModelCostsResult>

POST /v1/credits/model-costs → batch credit cost lookup for editor cost previews. Capped at the first 50 identifiers. Preserves fault-isolation: identifiers with no pricing row land in missing; lookup failures in errors, instead of failing the whole batch.

ModelCostsResult:

Field Type Description
data Record<string, number> Priced identifier → credit cost.
missing string[] Identifiers with no pricing row (render '—').
errors string[] Identifiers where the lookup itself failed.
const { data, missing } = await client.credits.modelCosts(["recraft:v3", "kling:v2.1"])
console.log(data["recraft:v3"])  // e.g. 2
if (missing.length) console.warn("No price for:", missing)

client.uploads

Upload a file to R2 and get back a public URL + storage metadata.

upload(file)

upload(file: File): Promise<UploadResult>

Upload one file (POST /v1/upload, multipart). The SDK’s request method detects the FormData body and lets the runtime set the multipart boundary. Returns the persisted asset’s public URL and storage metadata. Throws StorageExceededError (413) over the storage cap.

UploadResult:

Field Type Description
url string Public R2 URL of the stored asset.
assetId string \| null Storage row id; null when unauthenticated.
thumbnailUrl string \| null Generated thumbnail URL (images/video); null for audio or on failure.
category string Server-classified asset category ("image" / "video" / "audio").
filename string Display filename (server override or original).
mimeType string Final MIME type after server normalization.
sizeBytes number Stored byte size.
r2Key string R2 object key.
const result = await client.uploads.upload(file)
console.log(result.url)        // use as sourceImageUrl / audioUrl / videoUrl
console.log(result.assetId)    // reference back to the storage row

client.presets

Read your saved node presets and the built-in factory catalog. Read-only over the SDK today. A preset’s data is captured node config — merge it into a node’s data when you build a workflow to “apply” the preset. Requires the presets:read scope for OAuth app tokens (no-op for user/API-key auth).

list(nodeType?)

list(nodeType?: string): Promise<NodePreset[]>

GET /v1/node-presets → your custom presets, newest first. Pass a nodeType (e.g. "generate-image") to filter.

const presets = await client.presets.list("generate-image")
const cinematic = presets.find((p) => p.name === "Cinematic Portrait")
// apply: spread cinematic.data into the node's config when creating a workflow

listGroups(nodeType?)

listGroups(nodeType?: string): Promise<NodePresetGroup[]>

GET /v1/node-preset-groups → your preset folders/sections.

listFactory(nodeType)

listFactory(nodeType: string): Promise<FactoryPresetsResult>

GET /v1/node-presets/factory → the built-in catalog for nodeType.

const { data } = await client.presets.listFactory("generate-video")
const orbit = data.find((p) => p.id === "generate-video/orbit-360")

client.pickerCatalogs

Discover the valid values for parameter-picker nodes — the curated catalogs (setting, mood, person, lens, framing, …) whose selection contributes a descriptive clause to a downstream node’s prompt. Both endpoints are public (no auth) and publicly cacheable for 5 minutes server-side (Cache-Control: public, max-age=300).

This is the over-the-wire discovery surface. If you can import from @nodaro/shared, prefer doing so — the catalogs ship as pure, typed, tree-shakeable data (see Parameter Picker Catalogs). The SDK endpoints exist for clients that can’t bundle the package (and back the MCP get_picker_catalog tool).

list()

list(): Promise<{ data: PickerCatalogSummary[] }>

GET /v1/picker-catalogs → a directory of every picker: nodeType, label, catalogId, kind ("single" / "multi"), valueField (single-dim) or fields (multi-dim), and optionCount.

const { data } = await client.pickerCatalogs.list()
const moods = data.find((c) => c.nodeType === "mood")

get(nodeType, opts?)

get(nodeType: string, opts?: GetPickerCatalogOptions): Promise<{ data: PickerCatalog }>

GET /v1/picker-catalogs/:nodeType → one picker’s catalog of valid ids. Single- dim pickers carry options; multi-dim pickers carry dimensions (one { field, label, options } per field). 404 not_found for an unknown type.

GetPickerCatalogOptions:

Field Type Description
detail "compact" | "full" "compact" (default): id, label, category, icon. "full": additionally includes each option’s description and promptHint (the prompt fragment it injects).
category string Single-dim pickers: filter options to one category.
field string Multi-dim pickers (person / styling / framing): return only this dimension’s field.
const { data } = await client.pickerCatalogs.get("mood", { detail: "full" })
const serene = data.options?.find((o) => o.id === "serene")
console.log(serene?.promptHint) // the clause this option injects downstream

client.community

Browse, favorite, clone, and report the admin-curated community library of shared characters, locations, and objects. See Community Library for the feature overview and the likeness/consent safety rules.

Multi-user editions only. These routes exist on Business and Cloud instances; on a Community (single-user) instance they return 404 (surfaced as NotFoundError).

Publishing is intentionally NOT in the SDK. It is an admin/editor-only action, and the publish route rejects the personal/OAuth tokens the SDK uses.

A CommunityEntityType is "character" | "location" | "object". A listing is returned as a CommunityCard (snake_case fields mirroring the wire shape).

browse(params?)

browse(params?: BrowseCommunityParams): Promise<BrowseCommunityResult>

GET /v1/community/browse → a page of public listings plus a nextCursor. Pass the returned nextCursor back as cursor to fetch the next page (null when there are no more results).

BrowseCommunityParams:

Field Type Description
entityType CommunityEntityType Filter to a single asset kind.
q string Full-text search across title / description / tags.
category string Filter to a single category.
sort "newest" \| "popular" Order results. Defaults to "newest".
cursor string Cursor token from a previous page.
limit number Page size; the backend caps at 50 (default 20).
const { data, nextCursor } = await client.community.browse({
  entityType: "character",
  sort: "popular",
  limit: 20,
})

get(slug)

get(slug: string): Promise<{ data: CommunityCard }>

GET /v1/community/detail/:slug → a single listing by its slug. Throws NotFoundError when the listing is missing or inactive.

const { data: listing } = await client.community.get("detective-mara")

favorites()

favorites(): Promise<{ data: CommunityCard[] }>

GET /v1/community/favorites → the listings you’ve favorited.

const { data: faves } = await client.community.favorites()

clone(id, entityType)

clone(id: string, entityType: CommunityEntityType): Promise<CloneListingResult>

POST /v1/community/listings/:id/clone → copy a listing into your library as an independent snapshot (its assets are copied into your own storage, so the clone survives the original being changed or taken down). Returns { entityType, id } — the new asset’s kind and id. Requires the assets:write scope when called with an OAuth app token. Throws StorageExceededError (413) when your account is over its storage limit.

const { id } = await client.community.clone(listingId, "character")
// `id` is the new character in your own library

favorite(id)

favorite(id: string): Promise<FavoriteListingResult>

POST /v1/community/listings/:id/favorite → toggle a favorite. Returns { favorited }true after adding, false after removing.

const { favorited } = await client.community.favorite(listingId)

report(id, reason)

report(id: string, reason: CommunityReportReason): Promise<ReportListingResult>

POST /v1/community/listings/:id/report → flag a listing for moderation. reason is one of "real_person_no_consent" (depicts a real person without consent), "inappropriate", "ip_violation", or "other". Returns { ok: true }.

await client.community.report(listingId, "real_person_no_consent")

Type re-exports

Every type used in a public method signature is re-exported from @nodaro/sdk. Import them with import type { ... }.

Client identity

Workflows

Projects

Jobs

Executions

Nodes

Characters

Locations

Objects

Pipelines

Reduce

Prompt helper

Voices

Credits

Uploads

Developer apps

OAuth

Apps

Community

Picker catalogs

Generic node/edge

Re-exported from @nodaro/shared for convenience:


See also