@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)- Auth providers
- Errors
- Resources
client.workflowsclient.projectsclient.jobsclient.llmclient.videoProclient.recastclient.studioclient.copilotclient.executionsclient.nodesclient.modelsclient.charactersclient.locationsclient.objectsclient.creaturesclient.pipelinesclient.reduceclient.promptHelperclient.appsclient.developerAppsclient.oauthclient.voicesclient.mediaclient.audioclient.creditsclient.uploadsclient.libraryclient.presetsclient.pickerCatalogsclient.shotsclient.communityclient.templatesclient.tutorialsclient.organizationsclient.workspacesclient.edit
- Type re-exports
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. |
workspaceId |
string |
no | The workspace every request acts in, sent as X-Nodaro-Workspace. See client.withWorkspace. Omit for the caller’s personal space. |
clientLabel |
string |
no | Value sent as the X-Nodaro-Client header. Default sdk/<version>. The backend records it as the job’s origin, so an operator can tell SDK traffic from CLI traffic from browser sessions. @nodaro/cli overrides it with cli/<version>; set it yourself only if you are building another wrapper. The DEFAULT label is not sent from a browser (the Origin header already identifies the app, and Nodaro prefers it) — an explicit clientLabel is always sent. |
The instance exposes 35 resource objects: workflows, projects, jobs,
videoPro, executions, nodes, characters, locations, objects,
creatures, pipelines, reduce, promptHelper, apps, developerApps,
oauth, voices, llm, media, audio, credits, uploads, library,
presets, pickerCatalogs, catalogs, models, shots, recast, studio,
community, templates, tutorials, organizations, workspaces. 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.withWorkspace(workspaceId)
Returns a new client that acts in workspaceId, sharing this one’s auth,
base URL, timeout and fetch. Pass null for the personal space.
const classroom = client.withWorkspace(workspaceId)
await classroom.workflows.run(workflowId) // lands in the class
await client.workflows.run(workflowId) // lands in the personal space
A new client rather than a setter, deliberately: a mutable selection means two concurrent operations race over which workspace they are in, and the loser creates work in the wrong place with nothing failing. A per-workspace client cannot be raced.
The workspace decides scope, never access: which workspace a list reads from and where a create lands. Reading, updating, deleting or running something you name by id is governed by that object’s own workspace — so a forgotten workspace cannot hide your work and a wrong one cannot reach anyone else’s. See Selecting a workspace.
Signature: withWorkspace(workspaceId: string | null): NodaroClient
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, isAdmin }
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 |
Stored subscription tier (e.g. "free", "pro"). For the entitlement tier actually enforced (including "payg"), read effectiveTier from client.credits.balance(). |
isAdmin |
boolean |
Whether the user holds an admin role. Descriptive only — use it to decide whether to render admin UI instead of capability-probing an admin endpoint; every admin API stays enforced server-side regardless. |
On an instance with organizations the same object also
carries organizations, workspaces, lastWorkspaceId. Three states, and
collapsing them is wrong in a way users feel:
| What you see | What it means | What to do |
|---|---|---|
| the fields are absent | this instance has no organizations at all | never show a switcher |
| present and empty | the account belongs to none | offer to create or join one |
organizationsUnavailable: true |
the lookup failed | keep the selection you already had — telling someone their school vanished during a cache blip is worse than a stale switcher |
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.
createSharedSupabaseClient(options) — @nodaro/sdk/supabase
Browser Supabase client that stores the session in cookies instead of localStorage — optionally scoped to a parent domain so several apps on sibling subdomains share one login (sign in on any of them, signed in on all; sign out anywhere, signed out everywhere).
import { createSharedSupabaseClient } from "@nodaro/sdk/supabase"
import { supabaseAuth } from "@nodaro/sdk"
const supabase = createSharedSupabaseClient({
url: SUPABASE_URL,
anonKey: SUPABASE_ANON_KEY,
cookieDomain: ".example.com", // optional — omit for host-only cookies
})
const auth = supabaseAuth(supabase)
Signature: createSharedSupabaseClient<Db = any>(options: { url: string; anonKey: string; cookieDomain?: string }): SupabaseClient<Db>
cookieDomainis applied only when the page’s hostname is that domain or a subdomain of it; on any other host (localhost, preview URLs) cookies stay host-only, so local dev keeps per-origin sessions.- On first load it adopts an existing localStorage session (the storage supabase-js used previously) into the cookie, then removes the old key — an already-signed-in user survives the switch. Dead tokens are discarded.
- Ships as a separate subpath so the base SDK stays Supabase-free.
@supabase/ssrand@supabase/supabase-jsare optional peer dependencies — install both to use this export.
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.
code = "unauthorized",status = 401- Constructor:
new UnauthorizedError(message?: string)
class ForbiddenError extends NodaroError
HTTP 403. Permission denied. When the server returns
code: "insufficient_scope", the missing scope is exposed via missingScope.
code = "forbidden",status = 403missingScope?: string— set when scope check failed- Constructor:
new ForbiddenError(message?: string, missingScope?: string)
class NotFoundError extends NodaroError
HTTP 404. Resource doesn’t exist or isn’t visible to this caller.
code = "not_found",status = 404- Constructor:
new NotFoundError(message?: string)
class RateLimitedError extends NodaroError
HTTP 429. Apply backoff and retry.
code = "rate_limited",status = 429- Constructor:
new RateLimitedError(message?: string)
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.
code = "insufficient_credits",status = 402required?: numberavailable?: number- Constructor:
new InsufficientCreditsError(message?: string, required?: number, available?: number)
class StorageExceededError extends NodaroError
HTTP 413. User’s storage cap is reached.
code = "storage_exceeded",status = 413limitBytes?: number- Constructor:
new StorageExceededError(message?: string, limitBytes?: number)
class WorkflowConflictError extends NodaroError
HTTP 409, on either of two codes: workflow_conflict — an
optimistic-concurrency update (workflows.update with
expectedUpdatedAt/expectedVersion) rejected because another writer updated
the row first — and production_busy, the studio production routes’ own 409,
where the row kept changing under a read-modify-write until the server ran out
of retries. Same situation, same remedy: re-read and apply again. So it is the
same ERROR, and catch (e) { if (e instanceof WorkflowConflictError) … } covers
both, while code stays truthful about which arrived.
code—"workflow_conflict"(the default) or"production_busy": theWorkflowConflictCodeunion, though the field itself is the base class’sstring;status = 409currentUpdatedAt?: string— the row’s currentupdated_atcurrentVersion?: number— the row’s currentversioncurrentRecord?: Record<string, unknown>— the full current workflow (when the server includes it), so no follow-up GET is needed to merge- Constructor:
new WorkflowConflictError(message?: string, currentUpdatedAt?: string, currentVersion?: number, currentRecord?: Record<string, unknown>, code?: WorkflowConflictCode)
export type WorkflowConflictCode = "workflow_conflict" | "production_busy".
class StudioOpError extends NodaroError
A studio operation batch was refused, and the error names WHICH operation was wrong. Nothing in the batch was written — a batch applies atomically or not at all — so fix that one operation and send the whole batch again.
opIndex: number— zero-based index into theopsarray you sentcode/status— whatever the server sent- Constructor:
new StudioOpError(message: string, code: string, status: number, opIndex: number)
Selected by shape, not by a list of codes: any 4xx whose body carries a
numeric error.opIndex arrives as this class. So a refusal reason the server
adds later reaches you as a StudioOpError with no SDK release in between.
import { StudioOpError } from "@nodaro/sdk"
try {
await client.studio.productions.ops(id, { ops, baseVersion })
} catch (err) {
if (err instanceof StudioOpError) {
console.error(`operation ${err.opIndex} was refused: ${err.message}`)
}
}
class StudioPreviewUnavailable extends NodaroError
Not an HTTP error — thrown by studio.productions.ops(id, { …, dryRun: true })
when the deployment cannot preview a batch. Nothing was sent: the SDK proves
the flag on an empty batch first (see
the write protocol), and this is that probe coming back
without the preview marker, so the caller’s own batch never left the process.
Tell the user you cannot preview here rather than applying the batch blind.
- Constructor:
new StudioPreviewUnavailable(message?: string) code = "studio_preview_unavailable",status = 0
class StudioPreviewAppliedError extends NodaroError
The opposite, and the reason the second answer is checked too: a preview was asked for and the batch was applied. The probe and the batch are two requests, and a fleet mid-rollout can serve them from different deployments — the one that took the batch parsed the body in strip mode, dropped the flag and wrote. The SDK cannot recall a request that has already been answered; what it can do is refuse to hand the result back as a preview, which would otherwise show a person what ALREADY happened under the heading of what would.
To recover: adopt what is on applied — its production and version are
now the truth, exactly as a plain apply’s caller adopts them. Do not re-send the
batch, and do not present it for approval.
- Constructor:
new StudioPreviewAppliedError(applied: StudioOpsResponse | undefined, message?: string) code = "studio_preview_applied",status = 0applied: StudioOpsResponse | undefined— the apply reply as the route sent it.undefinedwhen the answer carried no body to read: whether the batch was applied is unknown from here, so re-read the production before deciding anything.
class JobBlockedError extends NodaroError
HTTP 422 job_blocked. A job policy registered by this deployment refused the
generation before it ran — no job was created and nothing was charged.
message is user-safe text written by the deployment’s policy (or by the platform, when the policy supplies none); show it as-is.
Do not retry the identical request: the platform does not retry a refused
request, and whether it would be judged differently is the deployment’s
policy’s business. Only occurs on deployments that register a job policy (see
deployment.md).
- Constructor:
new JobBlockedError(message?: string) code = "job_blocked",status = 422
class JobHeldError extends NodaroError
Not an HTTP error — thrown by nodes.runAndWait() / runMany() on the first
poll tick that observes pending_review, so a held job never burns maxMs
and never masquerades as a JobTimeoutError. The job is not cancelled:
the output exists, a human is reviewing it, and the reservation stays
reserved until they decide. Re-fetch with jobs.get(jobId) later, or poll
jobs.getStatus() yourself. Only occurs on deployments that register a job
policy.
- Constructor:
new JobHeldError(message: string, jobId: string)— same argument order as the other poll-loop errors. code = "job_held",status = 0,jobId
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, LlmResource, ExecutionsResource,
NodesResource, CharactersResource, LocationsResource, ObjectsResource,
PipelinesResource, ReduceResource, PromptHelperResource, AppsResource,
DeveloperAppsResource, OAuthResource, VoicesResource, CreditsResource,
UploadsResource, PresetsResource, PickerCatalogsResource, CatalogsResource, 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, currentUpdatedAt, and currentRecord — the full
current workflow — so you can merge your changes onto the fresh record and
retry without a follow-up GET. The SDK surfaces the 409 as
WorkflowConflictError (fields: currentUpdatedAt, currentVersion,
currentRecord). expectedUpdatedAt (string token) remains supported.
Transient run-state keys on node data (executionStatus, currentJobId,
progress counters) are stripped server-side and never persist.
import { WorkflowConflictError } from "@nodaro/sdk"
try {
await client.workflows.update(id, { settings, expectedUpdatedAt: loadedAt })
} catch (err) {
if (err instanceof WorkflowConflictError && err.currentRecord) {
const merged = mergeSettings(err.currentRecord.settings, settings)
await client.workflows.update(id, {
settings: merged,
expectedUpdatedAt: err.currentUpdatedAt,
})
} else throw err
}
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. Throws NotFoundError when the id doesn’t exist or
isn’t yours — the delete is never a silent no-op.
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 })
Portability. A bundle is only as portable as the media it points at. When
nodes reference URLs that another instance cannot fetch — typically a
self-hosted install’s own storage on localhost, a LAN address, or an
.internal name — the bundle carries a portability section listing them:
bundle.portability?.unreachableMedia
// [{ nodeId: "n1", nodeLabel: "Video URL", field: "videoUrl", url: "http://localhost:3000/storage/…" }]
Absent when every media URL is publicly reachable. Such a bundle still imports, but those nodes will not run on the other instance until the media is re-uploaded there.
import(input)
import(input: WorkflowExport & { projectId: string }): Promise<{ data: Workflow; importReport?: WorkflowImportReport }>
Imports a WorkflowExport bundle into the specified project. Re-creates any
bundled assets (characters, objects, creatures, locations) under your account
and re-points the graph at them — both the entity nodes’ *DbId fields and
every @-chip (ConnectedReference) bound in node data or in the workflow’s
freeform settings, so a graph that binds its entities only through chips
arrives bound rather than dangling. Returns the full record of the newly created
workflow.
Media the bundle references on other hosts is copied onto this instance’s
storage where it is reachable (up to 25 distinct files for the graph’s media
and 25 more for the bundled entities’ — the two budgets are separate, so neither
can starve the other; images up to 20 MB, video/audio up to 50 MB), so the
workflow runs from local copies rather than someone else’s host. URLs in
settings follow those copies but never trigger one of their own. A bundled
entity’s images are copied
whoever hosts them — they are the exporter’s bytes, and their delete, quota
sweep and retention reaper answer to the exporter, not to you. Those copies
count against your storage quota; when it runs out the workflow still lands and
the entities that did not fit are named in the report.
importReport says what happened:
const { data: wf, importReport } = await client.workflows.import({ ...bundle, projectId })
importReport
// {
// rehosted: 3, // copied onto this instance
// unreachable: [{ nodeId, nodeLabel, field, url }], // private hosts — left as-is
// skipped: [{ nodeId, field, url, reason: "HTTP 404" }],
// assetIdMap: { "<bundled entity id>": "<the row created for it>" },
// assetsSkipped: [{ kind: "character", id, name: "Kira", reason: "Storage limit exceeded" }],
// }
assetIdMap is present whenever the bundle carried assets. The server has
already re-pointed every chip inside the nodes it stored; the map is for chips
a client holds outside the graph. assetsSkipped appears only when
something was left out.
setVisibility(id, visibility)
setVisibility(id: string, visibility: WorkflowVisibility): Promise<{ data: Workflow }>
Sets a workflow’s visibility — "private" (the creator plus anyone explicitly
added as a collaborator) or "workspace" (everyone in the workflow’s
workspace). Only the creator or a workspace admin may change it; anyone else
gets 403. A thin wrapper over update() — the visibility lever also lives on
PATCH /v1/workflows/:id.
move(id, { projectId })
move(
id: string,
params: { projectId: string },
): Promise<{ data: Workflow; droppedCollaborators: { userId: string; name: string | null }[] }>
Moves a workflow to another project (its folder is cleared). If the move takes
the workflow out of a workspace, collaborator grants that came from that
workspace are dropped and returned as droppedCollaborators.
sharedWithMe()
sharedWithMe(): Promise<{ data: (Workflow & { grantedRole: CollaboratorRole })[] }>
Workflows other people shared with you — grants on work that is not in a
workspace you belong to (workspace work already appears in that workspace’s own
lists). Each carries the grantedRole you hold.
collaborators
The people a workflow is shared with, reached as client.workflows.collaborators:
collaborators.list(workflowId): Promise<{ data: Collaborator[] }>
collaborators.add(workflowId, { userId?, email?, role }): Promise<{ data: { userId, role } }>
collaborators.update(workflowId, userId, { role }): Promise<{ data: { userId, role } }>
collaborators.remove(workflowId, userId): Promise<{ success: true }>
add takes exactly one of userId or email (any address — the person
need not already have an account), at role "viewer" or "editor". remove
also lets a collaborator remove themselves. Listing never returns email
addresses.
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. Non-admin
callers receive an explicit allowlist of job fields — id, status,
progress, input_data, output_data, error_message, error_hint,
created_at, started_at, completed_at, user_id, credits, job_type,
source, source_detail, plus recovering while a processing job is being
recovered and credit_status (the job’s credit-reservation lifecycle —
"reserved" | "committed" | "refunded" | null, derived server-side
from the usage log). error_hint is a structured, user-safe failure verdict
(JobErrorHint) present on a job the worker classified as a final provider
content-policy block (kind: "safety-block" — see Generate
Image
for what it means and when a fallback model is offered) or that a job policy
registered by the deployment rejected (kind: "policy-block", carrying
policyId, hookPoint: "request" | "result" and reason — user-safe text
written by the deployment’s policy, or by the platform when the policy supplies none; show it as-is). Admin callers
additionally receive provider, provider_cost, display_cost,
credits_actual, error_detail (the provider’s redacted raw error) and
reconcile_attempts. Any other column never reaches any caller. Server-only
values inside job JSON, including Recast’s private pre-watermark remux base,
are removed recursively for every caller, including administrators.
list(params?)
list(params?: { type?: string; origin?: string; limit?: number; cursor?: string }): Promise<{ data: Job[]; next: string | null }>
Your jobs, newest first (GET /v1/jobs), cursor-paginated (limit ≤ 100;
pass next back as cursor). type matches the job’s input_data.type
(the route that created it — "llm-structured", "video-analysis", …) and
origin matches input_data.origin (the client app that sent it). Both are
exact-match and combine. A page may hold fewer than limit rows — even none —
and still carry a next; page on next, never on data.length.
const { data: runs, next } = await client.jobs.list({ type: "llm-structured", origin: "studio" })
getStatus(id)
getStatus(id: string): Promise<{ data: JobStatusResult }>
Returns the lean status of a job — id, status, progress, output_data,
error_message, error_hint, and credit_status (GET /v1/jobs/:id/status).
Far less wire + CPU cost than
get() because it skips input_data JSONB and cost/provider columns. Its
output_data still receives the server-only JSON redaction described above.
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)
delete(id)
delete(id: string): Promise<DeleteJobResult>
Deletes a job and the private media it produced (DELETE /v1/jobs/:id). Only
the job’s owner (or an admin) may delete it; a job that is still running is
deleted as-is — cancel(id) it first when its worker should stop. Returns
{ success: true }.
await client.jobs.delete(jobId)
client.llm
Structured LLM output: your system prompt and JSON Schema in, a validated
object out. Billed under llm-structured per model tier.
structured(input)
structured<T>(input: LlmStructuredInput): Promise<{ jobId: string; output: T; usage: { inputTokens: number; outputTokens: number } }>
POST /v1/llm/structured, synchronous — a call may run several minutes,
longer than the client’s default 60 s timeoutMs. Construct the client with
a larger timeoutMs for it, or use structuredJob.
structuredJob(input)
structuredJob(input: LlmStructuredJobInput): Promise<{ jobId: string }>
The same call as a job (POST /v1/llm/structured/jobs). Poll
jobs.getStatus(jobId); output_data is an LlmStructuredJobOutput —
{ stage } while running, then { output, inputTokens, outputTokens }.
label names the run; videoUrl (+ videoAnalysis) analyzes a video first
and drafts from the analysis (the analysis is a separate job you own,
output_data.analysisJobId). Throws NotFoundError on a platform that
predates the route. An instance that proxies its LLM calls to nodaro.ai
answers 503 provider_unavailable permanently — surfaced as a generic
NodaroError with that code; treat it as unavailable here, not as transient.
const { jobId } = await client.llm.structuredJob({
system, input: brief, jsonSchema, schemaName: "studio_production", origin: "studio", label: brief.slice(0, 80),
})
client.videoPro
Run control for the segmented long-video engine (Generate Video Pro, Cloud edition). Generation is dispatched like any node run; these act on an existing run.
stop(jobId)
stop(jobId: string): Promise<StopVideoProResult>
Gracefully stops a processing run: the in-flight segment is abandoned (still billed), remaining segments are skipped, the completed segments are stitched into the job’s final video, and the untouched reserve is refunded. A job that hasn’t started is cancelled with a full refund. Keep polling the job — it completes with output_data.pro.stopped = true.
await client.videoPro.stop(jobId)
const { data } = await client.jobs.getStatus(jobId) // → completed, partial video
continueRun(jobId, opts?)
continueRun(jobId: string, opts?: { fromSegment?: number }): Promise<ContinueVideoProResult>
Continues a stopped / failed / completed run as a new job — segments below fromSegment (1-based; default = first not-yet-delivered) are reused, everything from it on is regenerated. Billed only for the regenerated segments plus the flat pro fee. Returns the new jobId to poll.
const { jobId: childId, fromSegment } = await client.videoPro.continueRun(jobId, { fromSegment: 4 })
client.studio
Studio production routes are supplied by the Cloud plugin. Use capabilities()
to check per-operation support before exposing planned-frame controls. A missing
plugin returns the usual 404. These methods preserve the API response envelope.
| Method | Purpose |
|---|---|
capabilities() |
Read plan versions and manual operation support |
skill() |
Read the production authoring format |
list({ limit?, cursor?, includeArchived? }) |
List productions; rows are in response.data.data |
get(id, { detail?, shotId? }) |
Read a production and capabilities without reconciling jobs |
validatePlan(plan) |
Validate a plan without creating it |
create({ name?, plan? }) |
Create a production |
clone(id, { name?, projectId?, expectedVersion? }) |
Copy a saved production; optional destination project must belong to the caller |
importBundle({ bundle, projectId? }) |
Import a portable production through the compatible writer, remapping its frame and sequence IDs |
appendBundle(id, { bundle, expectedVersion, afterShotId?, applyFilm? }) |
Append a complete bundle slice to an editable production with fresh IDs and an exact revision check |
edit(id, { ops, baseVersion?, strict?, clientRequestId? }) |
Apply semantic operations with revision conditions |
saveEditorState(id, { expectedVersion, graph, clientRequestId? }) |
Save ordinary editor fields against the loaded revision; preserve protected frame and job state |
setShared(id, { shared, allowEditableCopy?, expectedVersion? }) |
Change link sharing through the visibility-authorized route |
generateKeyframe(id, { keyframeId, expectedRevision, clientRequestId?, overrides? }) |
Generate a planned frame without accepting it |
generateShot(id, input) |
Submit or quote a still/clip request |
reconcile(id) |
Record completed jobs without accepting candidates |
acceptKeyframe(id, review, concurrency?) |
Explicitly accept a reviewed candidate |
edit(id, { ops: [{ op: "reject_keyframe_result", keyframeId, expectedRevision, resultKey, expectedAcceptedResultKey, reason? }], baseVersion, strict: true }) |
Record Needs revision without generating; requires operations.rejectKeyframes support |
edit(id, { ops: [{ op: "update_sequence_plan", sequenceId, expectedRevision, name, segments }], baseVersion, strict: true }) |
Edit ordered { shotId, startKeyframeId, endKeyframeId } segments while preserving existing scene IDs; requires operations.editSequencePlans |
edit(id, { ops: [{ op: "detach_sequence_segment", sequenceId, shotId, mode, splitSequence: true, expectedSequenceRevision }], baseVersion, strict: true }) |
Make one segment independent and split remaining continuous groups; mode is clear or keep-accepted; requires operations.editSequencePlans |
The acceptance review supplies keyframeId, expectedRevision, resultKey,
expectedAcceptedResultKey (or null), and requirementChecks. Each check names
its requirementId and an outcome of pass or waived; waivers require a
waivedReason. Optional concurrency conditions are baseVersion, strict and
clientRequestId. Conflicts are returned through the normal SDK error path;
the SDK never selects a different result or retries acceptance against a newer
revision automatically.
Check capabilities.operations.saveEditorState before using the editor-save
method. It always sends a strict revision condition. A conflicting save returns
HTTP 409; keep the local draft and reload before resolving the conflict. The
snapshot cannot change frame plans, acceptance, endpoint bindings, linked-job
history, protected recycle-bin entries or sharing. Use their dedicated semantic
actions instead.
reconcile(id) also checks submitted jobs belonging to scenes in the recycle
bin. A completed clip stays in that scene’s stored graph with its original
endpoint pins; it does not recreate the scene on the timeline. Restore it with
restore_trashed to recover the result. Clients should reconcile once when
reopening an editable dependency production even if no pending marker is
visible, because the submission response may have been lost. Reconciliation
never starts generation or accepts a candidate.
Use edit with a strict baseVersion for remove_shot, restore_trashed and
purge_trashed. Detach a bound sequence segment before removing its scene.
When emptying a scoped bin, send purge_trashed for the displayed entry IDs;
clear_trash empties every bin, including planned frames. Entries carrying
frame dependencies are exempt from automatic pruning of ordinary bin entries.
Check capabilities.operations.revisionedSharing and pass expectedVersion to
setShared to bind sharing or unsharing to the reviewed revision. A concurrent
edit returns HTTP 409 workflow_conflict; the SDK does not retry against the
new revision. Only callers allowed to change visibility can use this route.
Check capabilities.operations.cloneLinkedProductions before copying a linked
production. Pass its loaded expectedVersion; a changed source returns HTTP 409.
The server copies retained frame inputs and historical parent/endpoint pins,
remaps frame/scene/sequence IDs, and clears active jobs and frame acceptance.
The new production starts private and visible. Review and accept its frames
before generating dependent media; copying submits no generation jobs and uses
destination storage quota for retained inputs.
When capabilities.operations.editableSharedCopies is true, an owner or workspace
admin may call setShared(id, { shared: true, allowEditableCopy: true, expectedVersion }).
This permits authenticated link viewers to call clone for the saved live plan,
prompts, cast descriptions, retained reference inputs and take history. The bin
and private review notes are excluded. Copies start private, with fresh frame
acceptance. Turning copying off or unsharing blocks subsequent copy requests;
already admitted copies remain independent. The public media projection carries
only publicView.editableCopyAllowed, never the editable plan itself.
appendBundle posts to /v1/studio/productions/:id/import-bundle and requires
workflows:write plus edit access to the destination. It returns the production
and importedShotIds/importedKeyframeIds. It preserves existing scenes, frames,
jobs, sharing and unrelated settings, and merges imported cast roles. The imported
frame closure needs fresh acceptance. Omit afterShotId to append at the end;
an unknown anchor or stale expectedVersion fails. applyFilm: true explicitly
adopts the incoming film look; existing music, cuts and film brief stay in place.
Check appendPlannedBundles or appendLinkedBundles in capabilities first.
Linked media has the same owned-source and verified-copy requirements as a new
production import. Neither method starts generation or retries a revision conflict.
importBundle posts to /v1/studio/productions/import-bundle and creates a new,
private production with remapped scene, frame and sequence IDs. Check
capabilities.operations.importPlannedBundles for recipes or ungenerated plans,
and importLinkedBundles for retained frame media. A linked bundle carries
nodaroStudio.sourceWorkflowId as a lookup hint; the server checks ownership and
verifies every retained image proof before copying bytes. Missing source access
or forged provenance refuses the import before creating its destination. Recipe
bundles need no source production. Neither path transfers acceptance or active
jobs, and neither generates media. Other existing media remains linked by URL.
Optional projectId must belong to the caller. The SDK returns the production
response envelope, including the new ID, rather than a raw workflow graph.
Frame generation has no dry-run option. generateShot accepts dryRun for a
quote. Both use an explicit clientRequestId for safe caller retries. A generated
portrait is optional for description-only cast references. See
dependent-frame behavior.
Linked-clip quotes include inputHash, accepted endpointPins, normalized
duration/resolution/aspect ratio/audio settings, and the creditIdentifier used
for the estimate. Pass the reviewed inputHash as expectedInputHash when
generating the clip. Changed settings or accepted endpoint pins return HTTP 409
sequence_quote_changed before submission; request and review a fresh quote.
Credits remain an estimate; the generation route reserves the current price.
When capabilities.operations.retakeLinkedClips is true, pass
retakeResultKey with kind: "clip" and shotId to quote a native linked take
using dryRun: true. Submit the same take with the reviewed expectedInputHash
and a fresh clientRequestId. Omit mode, overrides, and count: retakes use
the original stored request and retained endpoint images, even after plans or
acceptance change. Existing takes stay in history. A take without a verifiable
original request or retained images is refused; older and copied takes may be
unavailable for this operation. A retake does not reproduce identical video bytes.
client.recast
Recast runs + the authored-script import lane (“movie as JSON”). Cloud edition only — these routes 404 on self-hosted installs. Full REST contract: API integration §13c–13d; authoring guide: Recast authoring.
authoringSkill()
authoringSkill(): Promise<string>
GET /v1/video-analysis/authoring-skill → the generated authoring guide
(markdown): the script document contract, enum vocabularies, bounds, audio
rules, and a validated worked example. Free.
validateScript(script)
validateScript(script: Record<string, unknown>): Promise<RecastScriptValidation>
POST /v1/video-analysis/import/validate → { valid, errors, warnings }.
Each error carries path, message, and usually a hint written for an LLM
repair loop — fix and re-validate until valid: true. Free, persists nothing.
importScript(script, { rightsAttested: true })
importScript(script: Record<string, unknown>, opts: { rightsAttested: true }): Promise<RecastScriptImportResult>
POST /v1/video-analysis/import → { jobId, created, warnings, json }.
Stores the validated document as a completed analysis job; json is the
document with server-derived fields — always prefer it over your input.
Idempotent (created: false on an identical re-import). rightsAttested:
true is required (403 otherwise): authored recasts render Faithful —
exactly as written, so only import work you own. Free.
Run lane
estimate(input: EstimateRecastInput): Promise<RecastEstimate> // quote credits, free
create(input: CreateRecastInput): Promise<{ recastId: string }> // buys the plan
get(recastId: string): Promise<RecastRunSnapshot> // poll status + pending interactive step
estimateRescore(recastId: string, input: EstimateRecastRescoreInput): Promise<RecastRescoreQuote>
rescore(recastId: string, input: RecastRescoreRequestV2): Promise<RecastRescoreResponse>
start(recastId: string, opts?): Promise<{ gvpJobId?: string }> // render a planned run (idempotent)
resolveGate(recastId: string, input: ResolveRecastGateInput): Promise<Record<string, unknown>>
create requires workflowId (an existing workflow you own) and
analysisJobId; quote with estimate first — creating buys the plan. On
interactive runs the platform advances every non-gate step server-side; poll
get() and answer pending gates (cast / sheet / anchors / music) with
resolveGate — the pick itself is free. Gates only open for gate kinds the
run’s create declared in clientCapabilities (e.g. ["sheet-gate"]);
undeclared kinds are decided automatically.
For a completed take, get() may also return
capabilities.audioLayers: 1 and a server-authored
RecastAudioManifestV1. Its present object is the logical lane set;
layers contains the CORS-ready preview files that actually exist;
bakedEffectiveGain describes the current download; and
pendingRescore identifies a durable in-flight operation. Do not synthesize a
revision or infer baked contents from a missing preview derivative.
Use estimateRescore() before rescore() and pass the same complete desired
operation to both. The paid call additionally requires a UUID requestId; reuse
it only for an identical transport retry:
const status = await client.recast.get(recastId)
const revision = status.audio?.revision
if (status.capabilities?.audioLayers === 1 && revision) {
const operation = {
expectedAudioRevision: revision,
mix: {
music: { gain: 60, muted: false },
video: { gain: 85, muted: false },
},
} satisfies EstimateRecastRescoreInput
const quote = await client.recast.estimateRescore(recastId, operation)
if (!quote.noOp) {
const result = await client.recast.rescore(recastId, {
...operation,
requestId: crypto.randomUUID(),
})
// Poll get(recastId); status.audio.pendingRescore is reload-safe.
console.log(result)
}
}
An operation may contain a mix, one Music replacement (audioUrl or
sections), or a replacement plus its desired mix. RecastRescoreResponse is
either { recastId, jobId } or the job-free no-op response
{ recastId, noOp: true, audioRevision }. See
API integration §13c for manifest
semantics, validation, compatibility, and 400 / 409 errors.
For a revisioned replacement, normally send the complete desired mix. Omitting
it is a narrow compatibility path and succeeds only for the exact fixed legacy
bake (bed Music 35 + Video 100, or replace Music 100); otherwise the server
returns 409 legacy_mix_mismatch.
client.studio
client.studio.productions.* — the studio production document, from a script.
A production is a workflow whose settings.studio holds the shots: each one a
framed still, an optional animated clip, and the plan, looks, cast bindings and
voice that made them. Cloud edition only — these routes 404 where they are
not served; feature-detect with list(). Full REST contract:
Studio productions API; over MCP:
Studio productions over MCP.
The ENVELOPES are typed; the production DOCUMENT is not. production is
Record<string, unknown> and ops is unknown[] — the field-level types ship
with the studio app, the one consumer that narrows them. Everything a caller
branches on is typed: version, rebased, receipts, warnings, a quote’s
credits, a run’s jobIds.
Reads
skill(): Promise<StudioSkillResponse> // GET …/skill — free
validatePlan(plan): Promise<StudioValidatePlanResponse> // POST …/validate — free
list(opts?): Promise<StudioListProductionsResponse> // GET … — newest first
get(id, opts?): Promise<StudioProduction> // GET …/:id — pure, never lands a job
exportPlan(id, opts?): Promise<StudioExportPlanResponse> // GET …/:id/export-plan — priced, runs nothing
get(id, { detail: "full" }) adds every result with the context that
regenerates it; { shotId } reads one shot, the cheap re-read after a
generation. list({ includeArchived: true }) opts into the rows the dashboard
hides. skill() returns the authoring guide, the full catalog, the plan’s JSON
Schema and the operating guide, rendered server-side from the version that is
live — so they describe the platform you are actually talking to.
The write protocol
create(input?): Promise<StudioProductionResponse> // POST … — optionally lands a plan
ops(id, input): Promise<StudioOpsResponse> // POST …/:id/ops — an atomic batch
ops(id, input & { dryRun: true }): Promise<StudioOpsDryRunResponse> // …the same batch, previewed
reconcile(id): Promise<StudioReconcileResponse> // POST …/:id/reconcile — land finished jobs
importPlan(id, plan, opts?): Promise<StudioProductionResponse>
describe(id, input): Promise<StudioJobStartedResponse> // POST …/:id/describe — a Director run
Every change is an operation, addressed by stable key — a shot id, a role
slug, a result’s job id or url — never by position. That is what lets two
writers hold the same production open: a batch composed against a slightly older
version still applies to the newest document, and the response says
rebased: true (strict: true refuses instead, with a 409). A batch is
atomic: one bad operation refuses the whole batch as a StudioOpError naming
its index, and nothing is written. receipts is one past-tense line per
operation, each carrying impact — { keyframeIds, shotIds } — where the
operation’s effect reaches past the thing it names, so the caller knows what
else to refresh. Adopt production wholesale and carry version forward as the next
baseVersion. The operation vocabulary is served, not shipped: read it from
skill()’s operating part.
Previewing a batch. dryRun: true asks what the batch WOULD do, so a person
can approve an assistant’s edits before they land. The reply is
StudioOpsDryRunResponse — { dryRun: true, version, receipts, warnings }, with
no production and no rebased, because no document was produced. Each receipt
is the apply’s own receipt plus class (the operation’s confirmation class, out
of the vocabulary’s table) and restorable (present only where the operation put
something in the bin). The refusals the batch itself earns, the preview earns
too: a bad operation is still a StudioOpError with its opIndex, strict:
true still conflicts. Contention is the one it cannot reach — a preview never
swaps, so it never gives up busy the way an apply under a rival writer does.
import { StudioPreviewAppliedError, StudioPreviewUnavailable } from "@nodaro/sdk"
try {
const preview = await client.studio.productions.ops(id, { ops, baseVersion, dryRun: true })
for (const r of preview.receipts) console.log(r.class, r.summary, r.restorable ?? false)
} catch (err) {
if (err instanceof StudioPreviewUnavailable) {
// Nothing was sent. Say so; do not fall back to applying the batch.
} else if (err instanceof StudioPreviewAppliedError) {
// The batch LANDED. Adopt `err.applied.production` / `.version` — do not
// re-send it, and do not present it for approval. When `applied` is
// undefined there was no body to read: re-read with `get(id)` first.
}
}
Spell dryRun: true as a literal in the call’s own object, because the literal
is what selects the preview overload: passed through a variable it widens to
boolean, and the call then types as an apply while the runtime still takes the
preview path.
A preview is TWO requests, deliberately; an apply stays one. The route parses
its body in strip mode, so a deployment that predates the preview drops the flag
and APPLIES the batch — a caller that learned by sending would already have
written. So the SDK proves the flag on an EMPTY batch first — nothing of yours
rides on it — and sends your batch only when that answer carries the marker.
When it does not you get StudioPreviewUnavailable, and your batch never left;
when the deployment refuses the probe outright, that error is passed through as
it stands. And because a fleet mid-rollout can serve the second request from
another deployment, the second answer is checked too: a batch that came back
APPLIED throws StudioPreviewAppliedError carrying that write, rather than
being dressed up as a preview. Both are status = 0 — catch by type. The REST
contract for the two-step is in the
Studio productions API.
Generation and media
generate(id, input): Promise<StudioGenerateResult> // POST …/:id/generate
generateStill(id, shotId, opts?): Promise<StudioGenerateResult>
generateClip(id, shotId, opts?): Promise<StudioGenerateResult>
frame(id, input): Promise<StudioMediaResponse> // waits (seconds)
voice(id, input): Promise<StudioMediaResponse> // waits (seconds)
revoice(id, input): Promise<StudioJobStartedResponse> // starts; lands via its marker
music(id, input): Promise<StudioJobStartedResponse> // starts; lands via its marker
Run-then-poll: a still or clip run submits the jobs, records a pending marker
and returns — nothing blocks for minutes. reconcile() turns the finished jobs
into results with no browser in the loop. The request is assembled server-side
from the shot’s own plan, looks and bound references, so a scripted run and a
press of the button in the app produce the same media; overrides changes THIS
run without changing the shot. For a clip the lane is chosen from the
inputs and reported back as lane — never passed in.
const quote = await client.studio.productions.generateStill(id, "shot-2", {
count: 2,
dryRun: true,
})
if (isStudioGenerateEstimate(quote)) console.log(quote.credits) // null = unpriced, not free
const run = await client.studio.productions.generateStill(id, "shot-2", {
count: 2,
clientRequestId: crypto.randomUUID(),
})
dryRun: true prices the run and writes nothing — the reply is the quote,
narrowed with isStudioGenerateEstimate. clientRequestId makes a retry safe:
the same token answers with the jobs the first call started (deduped: true),
having submitted and charged nothing. Never retry a spend without it — and it
is accepted on every spending call here, frame and voice included, not only
on the two that quote.
Audience and copies
share(id): Promise<StudioProduction> // POST …/:id/share — opt in
unshare(id): Promise<StudioProduction> // POST …/:id/unshare — and back out
clone(id, input?): Promise<StudioProduction>
Sharing is its own route rather than an operation: who may see the work is
decided by the owner, never as a side effect of a batch that was editing
something else. A clone starts private and visible — shared and archived
never travel — and is copied through YOUR view of the source.
client.copilot
The assistant’s conversation surface (/v1/copilot/*). A thread is the
conversation, opened on a workflow; a turn is one message and everything the
assistant does in answer to it. Cloud edition only, and in-app only: every
route here refuses a caller who is not carrying a user’s own session JWT with
403 in_app_only — an OAuth app token or an API token must not drive someone’s
copilot. A deployment with the feature switched off answers 503
feature_disabled where a thread would be opened or a turn sent, rather than a
404, so “not available here” and “no such thread” stay distinguishable.
Unlike client.studio.productions, these methods return the API envelope
as it arrives: create/list/get resolve to { data: … }, and the payload is
inside it. stream is the exception — it yields frames, not a response.
create(input): Promise<{ data: { thread: CopilotThread; workflow: CopilotThreadWorkflow } }>
list({ workflowId }): Promise<{ data: { thread: CopilotThread | null } }>
get(id, { after?, limit? }): Promise<{ data: { thread: CopilotThread; messages: CopilotMessage[] } }>
archive(id): Promise<{ data: { archived: true } }>
cancel(id): Promise<{ data: { cancelling: true; turnId: string } }>
stream(id, opts): AsyncGenerator<CopilotStreamFrame>
create takes workflowId (an existing workflow) or prompt (the server
creates one, seeded by it, with an optional name). Re-opening a workflow that
already has an active thread answers THAT thread rather than a second one.
list reads the active thread for a workflow, or null. get reads a thread
with its messages — after returns only what followed that sequence number,
which is how a panel catches up, and limit caps the page at the server’s own
ceiling. archive is archival, not deletion: the messages stay readable, and it
is refused while a turn is running. cancel stops the running turn and answers
which one it asked to stop; that turn’s own stream ends with a done frame.
A thread carries runMode ("ask" — propose and wait — or "auto", which runs
within autoRunLimitCredits), modelTier, allowPublishing, userTurnCount
and, from get, a derived status / activeTurnId. surface names WHICH
assistant the thread belongs to; it is optional on the type, and its absence
means the deployment did not say. The SDK does not choose one — a thread opened
through create is the deployment’s own default.
A turn is a stream of typed frames
stream(threadId, { message, baseVersion?, tier?, signal? }) POSTs the message
and yields the turn’s frames as they arrive (server-sent events), so a caller
renders prose, tool activity and proposals live instead of waiting for a
finished answer. It is the only method here that spends.
for await (const frame of client.copilot.stream(threadId, { message: "Tidy the graph" })) {
if (frame.type === "token") process.stdout.write(frame.data.text)
if (frame.type === "run_proposed") await askTheUser(frame.data)
if (frame.type === "done") break
}
CopilotStreamFrame is a discriminated union on type. The SDK models
metadata (the turn’s ids, model, runMode, autoRunLimitCredits and the rest
of what it is running under), token (a chunk of prose), tool_call
(started → finished / failed), workflow_updated, workflow_created,
run_proposed (what the turn wants to run, for a person to confirm),
memory_saved, usage (tokens, and creditsCharged) and finally done
("completed" | "capped" | "cancelled") or error. A frame’s data is passed
through untouched, so a field this package does not model still reaches you —
and a frame KIND it does not model is skipped rather than thrown, so a newer
server cannot break an older caller. Read the frames you know and ignore the
rest.
One kind is missing today rather than newer. run_proposed is the canvas
surface’s frame; a thread on the studio surface offers its card as
action_proposed, which this version does not model — so stream skips it
along with everything else it does not know, and a caller on that surface does
not see the proposal come through.
Two lifetime rules, both deliberate:
- The client’s
timeoutMsis not applied to a turn — a turn legitimately runs for minutes, and inheriting the request timeout would cut the assistant off mid-answer. You own the lifetime: passsignal, or stop iterating (the reader cancels the body, which ends the request). - Aborting rejects the iteration with the platform’s raw
AbortError, not aNodaroError, whether it fires before the first frame or between two of them. Wrap the loop intry/catchif a cancelled turn is an ordinary ending in your UI. A non-2xx on the opening request still throws the ordinary typed error, before any frame.
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)
Reading nodeStates[nodeId].output. A node carries output when it
completed — and ALSO when it failed but the run retained a structured
result. The 3D-scene authoring nodes are the case that exists today: once the
repair budget is spent and only the visual reviewer refuses, the run has already
published a real, renderable revision, so the node fails and the revision is in
output.plan. Gate on the FIELD, never on the status — and never read a present
output as success:
import { nodeStateMayCarryOutput } from "@nodaro/sdk"
const node = data.nodeStates["scene-1"]
if (node.status === "failed") {
console.error(node.error) // the verdict stands
if (node.output?.plan) {
// …and the draft it retained is still here, billed and addressable.
}
}
nodeStateMayCarryOutput(node.status) // "completed" | "failed" → true
OUTPUT_BEARING_NODE_STATUSES (the same two statuses, as a Set) and the
NodeExecutionStatus union are exported beside it.
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:
- default (no
mode) — cancels immediately, killing in-flight jobs and refunding reserved credit holds (status"cancelled"). mode: "after_current"— sets the execution to"stopping"so in-flight nodes finish (and land on the canvas + My Library) before the run stops.mode: "discard"— stops scheduling new nodes WITHOUT cancelling in-flight jobs (external AI calls can’t be killed mid-flight). Those jobs finish and are saved to My Library, but their results are detached from the live canvas (status"discarded"). No refund — the jobs completed.
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) // creditCost: Cloud only
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)
}
The input/source nodes work the same way. A scraper answers synchronously —
the response carries both a jobId (for history) and the data — so you can use
the result directly. Meta Ads (meta-ads-scrape), for example, pulls public
Facebook + Instagram ads from Meta’s Ad Library by keyword, advertiser or Page:
const result = await client.nodes.run("meta-ads-scrape", {
mode: "search", // or "pages" (pageUrls) / "advertiser" (see below)
query: "running shoes",
count: 20, // 1..100 ads; pricing is 1 credit per requested ad, tiered
period: "30d", // 24h | 7d | 30d | all
// formats: ["vertical"], // keep only phone/square/web creatives (may return fewer)
// analyze: true, // attach a per-ad AI analysis object (extra credits per ad)
})
console.log(result.json) // the array of ads (copy, CTA, images, videos, …)
// By advertiser name instead of keyword — mode "pages" with advertiserNames,
// resolved to Facebook Pages server-side (verified match first):
const byAdvertiser = await client.nodes.run("meta-ads-scrape", {
mode: "pages",
advertiserNames: ["Nike", "Adidas"],
count: 30,
})
console.log(byAdvertiser.resolvedAdvertisers) // [{ name, pageId, url }, …]
Needs an
APIFY_API_TOKENon the server, or a connected nodaro.ai account (the scrape — including advertiser resolution and AI analysis — is relayed and billed there). The CLI runs the identical path:nodaro nodes run meta-ads-scrape --param mode=search --param query="running shoes".
Parameter corrections. For the image node types (
generate-image,image-to-image,edit-image) the result may carryadjustments— one entry per parameter the server corrected because the chosen model does not accept the value you sent. The run proceeds with the corrected value and the credits reserved match it.adjustmentsis absent when nothing changed.const result = await client.nodes.run("generate-image", { prompt: "a snow leopard", provider: "gpt-image-2", aspectRatio: "3:2", }) if ("adjustments" in result && result.adjustments?.length) { for (const a of result.adjustments) { console.warn(`${a.field}: ${a.from} → ${a.to ?? "(dropped)"} — ${a.reason}`) } }Full semantics: Parameter corrections.
Seedance 2 video (
run("text-to-video" | "generate-video", …)):seedance-2(full) acceptsresolution: "4k"andaspectRatio: "adaptive"(plus"21:9");seedance-2-fast/seedance-2-miniare 480p / 720p only, andseedance-2-5spans 480p / 720p / 1080p (no 4K).seedance-2-5runs to 30s in a single call (the rest stop at 15s) and takes 30 image / 10 video / 10 audio references; when a start frame is wired it renders at that frame’s aspect and rejects an explicitaspectRatio. MiniMax Hailuo 3 (minimax-h3) takes the same reference fields (9 images / 3 videos / 3 audio) atresolution: "2K"(default) or"768P"(cheaper per-second rate; any other value renders and bills as 2K) — ref-video input seconds bill like Seedance 2 at the selected tier’s rate, and input images beyond the first 5 add a per-image surcharge (audio refs are free but must accompany an image/video ref). Wan 3.0 (wan-3,wan-3-prime) takes 10 image / 5 video / 5 audio references, each video and audio clip 1–15 s and ≤ 15 s combined per type — but its reference arrays are mutually exclusive with the start/end frame fields (imageUrl/endFrameUrl), so sending both is a conflict rather than the Seedance-style fold-in.durationis a whole number 2–30 (default 5);resolutionis480p/720p/1080p(send the lowercase display value — the platform normalizes to the provider’s uppercase enum, and an omitted or unsupported value renders and bills at 720p);aspectRatiodefaults to"adaptive". Reference videos bill output seconds only here, and input + output duration must stay ≤ 30 s.wan-3-primeis the high-speed, higher-priced SKU on the same schema. Gemini Omni Flash (gemini-omni-flash) is the cheaper, faster sibling ofgemini-omni-videoon an identical request shape — same 4 / 6 / 8 / 10durationmenu (8 s when omitted), same 720p / 1080p / 4K tiers, same 16:9 / 9:16-only aspect, same 7-unit input quota.resolution/aspectRatioare pass-through strings — an unsupported value is ignored, never a 400. Start/end frames and references can coexist (the frames become prompt-directedImage Nreferences; the resolver picks the mode, no toggle). Reference videos are billedunit × (input + output)duration — the runtime ffprobes eachreferenceVideoUrlsclip and scales the per-second-refrate 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", …)andrunAndWait("text-to-speech", …)defaultprovidertoelevenlabs-v3when omitted — but only whentextis 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 explicitproviderfalls back toelevenlabs-turbo(cap 40,000) instead, so legacy integrations that always omitproviderdon’t get silently truncated by v3’s tighter cap. An explicitprovideris always respected regardless of text length (its own cap still clamps the stored record, unchanged).
Typed structured references.
run("generate-image" | "generate-video" | "text-to-video", …)(and the same three onrunAndWait) have typed overloads —GenerateImageParams/GenerateVideoParams/TextToVideoParams(all extendStructuredReferenceParams).text-to-videois the prompt-only video lane, so itspromptis REQUIRED and the frame fields (imageUrl/endFrameUrl) belong togenerate-video, the image-to-video lane. PassconnectedReferences: ConnectedReference[](the editor’s wired-reference shape, re-exported from the SDK) +referenceOrderfor labeled, ordered references the route assembles into@image_Ndirectives — instead of hand-building a prose “Image N is …” guide. EachConnectedReferencemay also carry an opt-in (default-off)identityLock?: { enabled: boolean; text?: string }: withenabled: truethe route prepends a short identity-lock fidelity line for that reference (textoverrides 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.
A name you have no picture for. The same three params take
describedReferences: DescribedReference[](also re-exported from the SDK) — up to 10{ name, description }entries for a subject you can name and describe but have no media for: a cast role nothing is bound to yet, a character that exists only in the script. They carry no url, so nothing is attached and no@image_Nseat is used; each becomes a<Name> — <description>.line, and correlation is by NAME — leave the name in your prompt prose and the line tells the model who it is. They are a reference channel on their own: send them with noconnectedReferencesand the route still assembles. Per-use wording for a reference you DO have a picture for goes on the entry itself asdescriptionOverride(it fills the directive’s description slot ahead of the entity’s stored description, which stays the label).
Captioning the video / audio rails.
GenerateVideoParamsandTextToVideoParamstakereferenceVideoCaptions/referenceAudioCaptions— string arrays index-aligned withreferenceVideoUrls/referenceAudioUrls, rendered as@video_N: <caption>./ `@audio_N:.` and bounded by the number of rail references that actually ship. A blank entry is a hole in the alignment, not a line.
Naming an image reference in the prompt. On
run("generate-image", …), a media reference (source: "wired-image"or"manual") is mentionable by the slug of itsdefaultName—@<name-slug>:<index>[:<role>], e.g.@town:1or@town:1:background. The mention renders that reference’s binding (or its role phrase) at the position you typed it, instead of leaving it in the trailing auto-attach block. There is no slug field to set: name the reference and it becomes mentionable, and the index is correlation only — never a seat you compute.~lock/~nolockapply as on character mentions.@nodaro/sharedexports the grammar itself (imageMentionSlug,parseImageMentionToken,findImageMentionTokens,knownImageSlugsFromRefs) so a client can render the same preview the server will assemble, andtoConnectedReference({ kind: "image", … })builds the reference entry. Honored in the hybrid reference format; under the legacy format the token stays literal text. See API Integration for the full grammar.
Cinematic direction by id.
run("generate-image" | "generate-video" | "text-to-video", …)also takes an optionaldirectionobject — a flat map of catalog ids (shotSize,lightingStyle,style,mood,photographer,era, …) the platform folds into the prompt as its own hint clauses — the look ones into a trailing[style]:section (a film line then a scene line), so you send ids and the wording stays platform-owned. Values are a single id or an array (multi-pick dimensions honor their own cap; exceeding a dimension’s cap truncates rather than 400ing, while the wire bounds — 8 entries per key, 100 characters per id — do reject). Absent ≠ empty: a missing key means “no hint”, an emptydirectionleaves yourpromptuntouched, and unknown keys / unknown ids are skipped silently rather than rejected — so deploy the platform before a client that starts sending new dimensions. Valid ids come fromclient.pickerCatalogs.list()(on a deployment with registered catalog packs, pack-added ids are listed and accepted but render no clause — only base-catalog ids fold). The video runs take the same object and add the motion dimensions (cameraMotion, which folds first,actionFx, thetemporal*keys,transition,loopSubject); motion dimensions render as short terms and stay in the prompt body, where look dimensions render full clauses in the[style]:section, and a stills-only key sent to a video run is accepted and simply contributes nothing, so one look map serves both surfaces.run("extend-video", …)has nodirection— its prompt continues an existing clip. Full key list and semantics in the API integration guide (video specifics: the video routes).
Assemble Narrated Video.
run("assemble-narrated-video", …)also has a typed overload —AssembleNarratedVideoParams:blocks: { videoUrl: string; audioUrl?: string }[](1–60, in play order) plusvoiceVolume(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
reasoningEffortfield 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”).xhighandmaxbill 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 nodedata(reasoningEffort?: LlmReasoningEffort), andclient.promptHelper.*accepts it directly in its request body.
Advanced mode. The same routes accept
advancedMode: true(Gemini models only), which runs the request on the provider’s own API rather than through the aggregator. That is the only lane wheretemperature,maxTokensand the full reasoning-effort range actually take effect — on the default lane those levers are not reliably honoured. It bills one credit tier up, and this bump is independent of the effort bump above. A model with no direct lane returns400 advanced_mode_unsupported. Canvas LLM nodes carry the same field on their nodedata(advancedMode?: boolean), and the CLI exposes it as--advanced.
client.nodes.run(type, params)POSTsparamsstraight toPOST /v1/<type>— that matches the registered route only forgenerate-script,image-critic,qa-check, anddescribe-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, callclient.request("POST", "<path>", { body: params })directly, or useclient.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,text-to-video, andassemble-narrated-videoare currently the only four 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). A pending_review
status ends the poll on the first tick it is observed — with JobHeldError,
not by waiting out maxMs — because that status is a human’s decision pending,
not the job’s own progress.
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):
InsufficientCreditsError/StorageExceededError— surfaced byrun()before any poll.JobFailedError— terminalfailed/cancelled(carrieserror_message+jobId).JobTimeoutError—maxMsdeadline exceeded.JobAbortedError—signalfired.JobHeldError— the job enteredpending_review(code = "job_held", carriesjobId; see Errors). Only on deployments that register a job policy. It does NOT cancel the job: the output exists and a human is reviewing it, and the reservation staysreservedfor the whole hold. Do not re-run the request — a duplicate would be held too. Re-fetch withjobs.get(jobId)later: it resolves tocompleted(approved),failed(rejected, witherror_hint.kind === "policy-block"and a user-safereason) orcancelled(you cancelled it — a held job is cancellable like any in-flight job), or surface “awaiting review” to your user and polljobs.getStatus()yourself.JobBlockedError— surfaced byrun()before any poll: the deployment’s job policy refused the generation (HTTP 422job_blocked, see Errors).
Slow recoveries can outlive the default
maxMs. If the platform’s worker abandons a job after the provider already delivered, the job staysprocessingwhile the reconcile system self-heals it — the status payload carriesrecovering: trueduring that window, and recovery can take tens of minutes for slow models. AJobTimeoutErrordoes NOT cancel the job: it usually still completes server-side and lands in your library; re-fetch withjobs.get(jobId)later, or raisemaxMsfor 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.models
list(options?)
list(opts?: { kind?: "image" | "video" | "audio"; mode?: string; family?: string; featuredOnly?: boolean }): Promise<ModelsListResult>
GET /v1/models → the model catalog grouped by kind and vendor family:
capability sheets (modes, features, aspectRatios, resolutions,
durations), per-variant credit pricing (Cloud only — editions without a
credit system omit the field), compact promptTips, and the
doctrineCovered truth flag — true only when a sourced per-family prompt
doctrine exists for the model, so “vendor doctrine” badges can never
overclaim. The same projection the MCP list_models tool serves, so the two
surfaces cannot drift. Public endpoint; cached 5 minutes.
const catalog = await client.models.list({ kind: "video", mode: "i2v" })
for (const section of catalog.sections)
for (const family of section.families)
for (const m of family.models) console.log(m.id, m.doctrineCovered)
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).
Cursor-paginated — one call returns at most limit rows, so a single
call is not “all characters” for anyone above that count. Page until
nextCursor is null:
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)
A single page, when that is all you need:
const { characters, nextCursor } = await client.characters.list({ projectId, limit: 50 })
nextCursor is opaque — echo it back as cursor, never parse or persist it.
The underlying keyset is (created_at, id) ordered DESC, so rows sharing a
created_at are not skipped at page boundaries. A malformed cursor throws a
validation_error rather than silently restarting from page 1.
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[]; nextCursor?: string | null }>
Lists the caller’s locations. By default returns active locations only;
pass archived: true for an “archive” view.
Pagination is opt-in: without limit you get the full legacy listing
and no nextCursor; with limit (max 500) you get one page plus a
nextCursor to pass back as cursor — loop until it is null, the same
pattern as characters.list().
const { locations } = await client.locations.list()
const { locations: archived } = await client.locations.list({ archived: true })
const page = await client.locations.list({ limit: 100 }) // page 1
const next = await client.locations.list({ limit: 100, cursor: page.nextCursor! })
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_requiredbefore 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.
Pagination is opt-in: without limit you get the full legacy listing
and no nextCursor; with limit (max 500) you get one page plus a
nextCursor to pass back as cursor — loop until it is null, the same
pattern as characters.list(). Applies to creatures.list() identically.
const { objects } = await client.objects.list()
const { objects: archived } = await client.objects.list({ archived: true })
const page = await client.objects.list({ limit: 100 }) // page 1
const next = await client.objects.list({ limit: 100, cursor: page.nextCursor! })
Objectshadows the JS global, which TypeScript handles cleanly via local-scope resolution. Callers who need both can alias asimport 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
piiConsentAtfield. 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.
attachToColumnis REQUIRED forassetType === "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:
providerdefaults to"kling-turbo"(not location’s"kling").aspectRatiodefaults to"1:1"server-side viaresolveObjectAspectRatio({ assetType: "motion" })— objects are product-showcase framing, not cinematic establishing shots. Objects have their own 5-valueObjectAspectRatioenum (1:1/3:4/16:9/9:16/4:3) with4:3added vs. the character set to support classic product-catalogue aspect ratios.
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.
sourceImageUrlis 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:
boards— named Creature Boards (Array<{ name, url }>, max 24): dense reference sheets rendered by thegenerate-image/creature-boardfactory preset, one per variant/mood. USER-owned: flows through create and update (whole-array replace), unlike the worker-owned asset buckets. Community publish snapshots boards and clones hand each consumer their own copy to extend.voice— the “talking creature” stack (CreatureVoice | null), IDENTICAL in shape and flow toCharacter["voice"]:{ voiceId, voiceName, traits, voiceType?, previewUrl?, ttsProvider? }. Passvoice: nullon update to clear. On community publish the voice carries by KIND exactly like characters — premade + library voices carry fully; custom clones reduce to display name + preview sample (never a usablevoiceIdcross-user).
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", llmModel?: string } — llmModel picks the judge model (economy/standard/premium credit tiers apply) |
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, reasoningEffort, advancedMode, temperature and maxTokens 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?, opts?)
run(slug: string, inputs?: Record<string, unknown>, opts?: RunAppOptions): 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).
opts.inputOverrides is the advanced escape hatch: nested
{ nodeId: { field: value } } raw node data for THIS run, which reaches fields
the app does not expose to its end users — such as
promptPrefix / promptSuffix.
const { executionId } = await client.apps.run("pro-headshot", { photo: url })
// Wrap the app's prompt with hidden text for one run
await client.apps.run(
"pro-headshot",
{ photo: url },
{ inputOverrides: { n1: { promptPrefix: "Studio portrait of" } } },
)
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()
Each entry carries kind: "user" is an app you registered yourself; the
other values ("dynamic_mcp", "first_party_mcp", "community_instance")
are clients that registered themselves. Only "user" entries count toward
the five-apps-per-user cap; create() answers 400 limit_reached past it.
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:
redirectUris: 1-10 entries, eachhttps://...orhttp://localhost...allowedOrigins: 0-5 bare origins (no path/query/hash)scopesRequested: at least 1 scope from theDeveloperAppScopeunion
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 }>
Throws NotFoundError when the id doesn’t exist or isn’t yours.
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 browser — client_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, redirectUri?)
getAppInfo(clientId: string, redirectUri?: 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, kind, redirectUriRegistered: null }
const checked = await client.oauth.getAppInfo(clientId, "https://yourapp.com/oauth/callback")
// checked.redirectUriRegistered is true only for an exactly registered URI
client.voices
ElevenLabs voices: the premade catalog, the community Voice Library, the
signed-in user’s existing voice clones (list / delete), and the voice changer —
one-shot single-voice / multi-speaker recasts plus the interactive Voice
Changer Pro flow (analyze → recast({ output: "stems" }) → exportMix),
voice design/remix, and dubbing.
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:
recommendedProvider— the best TTS provider the voice is verified on (elevenlabs-v3preferred when the voice is verified for it — v3 is the fully-multilingual default and renders any voice unmodified — else the cheapest v2 model:elevenlabs-turbopreferred, elseelevenlabs-multilingual). Apps without a provider picker should send it as theproviderwhen generating speech with this voice, so the voice renders on a model it’s verified for (that’s what keeps generation sounding like the library preview).verifiedProviders— every provider the voice is verified on (v3 first when present, then turbo). Apps WITH a provider picker should only override the user’s choice when it is not in this set.
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) / createCloneFromFile(input) — retired
Voice cloning is no longer offered on Nodaro. Both methods remain on the
client for source compatibility but are @deprecated: the routes answer
410 with error.code = "voice_cloning_retired" and the call rejects with
that error. Clones created before the retirement still appear in listClones()
and still work as voice ids everywhere a voice is accepted. For a new custom
voice, use design(input) below.
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
model?: string // speech-to-speech model override
stability?: number
similarityBoost?: number
style?: number
useSpeakerBoost?: boolean
seed?: number // deterministic STS seed (integer) for reproducible output
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). useSpeakerBoost sharpens fidelity
to the target speaker (small latency cost); seed makes the output reproducible
across runs. 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
engine?: "sts" | "v3" // "sts" (default) = speech-to-speech recast; "v3" = Re-speak —
// the performance is REGENERATED from the transcript with eleven_v3
// ([audio tags] supported; stability 0/0.5/1 only; similarityBoost/
// style/useSpeakerBoost ignored). A v3 speaker needs transcript text:
// pass an analysis whose segments[].text carries it, or omit analysis
// and the engine re-speaks from its own transcription.
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
}
output?: "video" | "stems" // default "video"; "stems" = dry per-track stems for an interactive mix
analysis?: VcpAnalysis // a prior analyze() result — skips re-detection (the fast-path)
}): 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).
output selects the result shape: "video" (default) renders the finished
merged result; "stems" returns the dry, unleveled per-track stems instead,
so you can drive an interactive mix in your own UI and render later with
exportMix(). analysis accepts a prior analyze() result (see below) to
skip re-detection — the recast reuses the already-separated stems and
speaker segments, so re-recasting with different voice assignments doesn’t pay
detection again.
// 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"],
})
analyze(input)
analyze(input: {
audioUrl?: string // exactly one of audioUrl / videoUrl
videoUrl?: string
separationQuality?: "fast" | "best"
suggestTitle?: boolean
}): Promise<{ jobId: string }>
Detect the speakers in a clip without recasting yet
(POST /v1/voice-changer-pro/analyze, Cloud only) — the first step of the
interactive flow. Separates voice from music once and diarizes the vocals.
The completed job’s output_data carries the separated stem urls
(vocalsUrl, backgroundUrl), the detected speakers (each with id,
time segments, firstStartSec, wordCount, snippet), the detected
language (languageCode + languageProbability), and — with suggestTitle —
an LLM-proposed suggestedTitle. That output_data is exactly the
VcpAnalysis shape: pass it back as recast({ …, analysis }) to skip
re-detection on every subsequent recast. Flat-priced; runs async.
exportMix(input)
exportMix(input: VcpExportInput): Promise<{ jobId: string }>
// VcpExportInput = {
// videoUrl: string // stream-copied, never re-encoded
// tracks: VcpExportTrack[] // ≤16; at least one un-muted
// voiceFx?: VoiceChangerProInput["voiceFx"]
// }
// VcpExportTrack = { url: string, gain: number /* 0–200 */, muted: boolean, kind?: "voice" | "background" }
Render the final video from a mixed set of stems
(POST /v1/voice-changer-pro/export, Cloud only) — the last step of the
interactive flow, after recast({ output: "stems" }). Per-lane gain/muted
and the export-time voiceFx (voice lanes only — never a "background" lane)
are applied at render; the video stream is copied, so the export is
bit-identical to your preview and iterating the mix before exporting is free.
All-muted mixes are rejected (400). Flat-priced; poll client.jobs.get(jobId)
for output_data.videoUrl.
// The interactive flow end-to-end: analyze once, recast to stems, render.
const { jobId: aJob } = await client.voices.analyze({ videoUrl, suggestTitle: true })
const analysis = (await pollUntilDone(aJob)).output_data as VcpAnalysis
const { jobId: rJob } = await client.voices.recast({
videoUrl,
orderedVoices: ["Rachel", null, "Aria"], // speaker 2 keeps their voice
output: "stems",
analysis, // skip re-detection
})
const stems = (await pollUntilDone(rJob)).output_data
const { jobId: eJob } = await client.voices.exportMix({
videoUrl,
tracks: [
{ url: stems.tracks[0].url, gain: 100, muted: false },
{ url: stems.tracks[1].url, gain: 90, muted: false },
{ url: stems.backgroundUrl, gain: 70, muted: false, kind: "background" },
],
voiceFx: { preset: "hall", wetDryMix: 25 },
})
design(input)
design(input: {
text: string // preview line, 100–1000 chars
voiceDescription: string
model?: string
loudness?: number // -1..1
guidanceScale?: number // 0–100
seed?: number
quality?: number
shouldEnhance?: boolean
userPrompt?: string
}): Promise<{ jobId: string }>
Design a brand-new synthetic voice from a text description
(POST /v1/voice-design, ElevenLabs text-to-voice). The completed job carries
an audio preview and the reusable generated voice id.
remix(input)
remix(input: {
text: string // 1–5000 chars
voiceDescription: string
userPrompt?: string
}): Promise<{ jobId: string }>
Generate speech in a voice described in natural language, without cloning
(POST /v1/voice-remix).
dub(input)
dub(input: {
audioUrl?: string // exactly ONE source of the three
videoUrl?: string // video in → the dubbed VIDEO out (+ audio track)
sourceUrl?: string // public YouTube/TikTok/direct link — ElevenLabs fetches it
targetLanguage: string // ISO code, e.g. "es", "pt-BR"
sourceLanguage?: string // auto-detected when omitted
numSpeakers?: number // 0 = auto; 1–20 — improves separation when known
disableVoiceCloning?: boolean
dropBackgroundAudio?: boolean
startTime?: number // dub only this window of the source (seconds)
endTime?: number
highestResolution?: boolean // keep the source resolution on video dubs
useProfanityFilter?: boolean
targetAccent?: string // experimental
watermark?: boolean // ElevenLabs' own watermark on video dubs
}): Promise<{ jobId: string }>
Dub audio — or a whole video — into another language while preserving each
speaker’s voice (POST /v1/dubbing). Video mode completes with
output_data.videoUrl (the dubbed clip) plus output_data.audioUrl (the
dubbed track alone). Priced per minute of the dubbed span (minimum 1 minute);
the span is capped at 30 minutes — use startTime/endTime for longer
sources.
textToDialogue(input)
textToDialogue(input: {
dialogue: Array<{ text: string; voice: string }> // in speaking order
stability?: 0 | 0.5 | 1
languageCode?: string // ISO 639-1 hint, auto-detected when omitted
seed?: number // 0–4294967295; omit for random
applyTextNormalization?: "auto" | "on" | "off"
}): Promise<{ jobId: string }>
Voice a multi-speaker script as ONE audio file (POST /v1/text-to-dialogue,
ElevenLabs Dialogue v3). Each line’s voice is a premade voice name or an
ElevenLabs voice UUID — cloned and Voice Library voices work, mixed casts are
fine, and line text may carry [audio tags] like [laughs]. At most 5,000
characters total across lines (under 2,000 recommended for best quality) and
10 unique voices per generation. Poll jobs.get(jobId) for
output_data.audioUrl.
client.media
Media ingestion + trimming — the source-preparation steps a pipeline needs
before it has a clip to work on. Each generation-style op returns a job id to
poll with client.jobs.get(jobId); videoMetadata is a direct read.
downloadVideo(input)
downloadVideo(input: {
url: string
maxHeight?: number
sectionStartSec?: number
sectionEndSec?: number
requireAudio?: boolean
}): Promise<{ downloadId: string }>
Download a social video (YouTube / TikTok / Instagram / X / Facebook) into your
storage (POST /v1/download-video). maxHeight caps the resolution (omit for
best available); sectionStartSec + sectionEndSec (both-or-neither) fetch
only that time range. A download that arrives with no audio stream fails by
default — it is usually a degraded source response, and is retried through
other routes first; pass requireAudio: false to accept a clip that really has
no sound. Returns a downloadId — not a job id — whose progress streams from
downloadVideoProgress(). The finished file lands in your library.
downloadVideoProgress(downloadId, opts?)
downloadVideoProgress(
downloadId: string,
opts?: { signal?: AbortSignal },
): AsyncGenerator<DownloadVideoProgress>
Stream a download’s live progress (GET /v1/download-video/progress/:id,
server-sent events) as an async iterable. Yields
{ phase, percent, videoUrl?, thumbnailUrl?, error? } roughly every 500ms
until the download reaches completed (the event carries the stored
videoUrl) or failed (the event carries error), then ends. The progress
state expires server-side shortly after the download finishes — start
iterating promptly after downloadVideo() returns. No request timeout is
applied (large imports legitimately take minutes); pass an AbortSignal to
cancel.
const { downloadId } = await client.media.downloadVideo({
url: "https://youtu.be/dQw4w9WgXcQ",
maxHeight: 720,
})
for await (const ev of client.media.downloadVideoProgress(downloadId)) {
console.log(`${ev.phase} ${ev.percent}%`)
if (ev.phase === "completed") console.log("stored at", ev.videoUrl)
}
trimVideo(input)
trimVideo(input: {
videoUrl: string
startTime?: number
endTime?: number
trimStartFrames?: number
trimEndFrames?: number
trimStartSeconds?: number
trimEndSeconds?: number
keepFirstSeconds?: number
keepLastSeconds?: number
}): Promise<{ jobId: string }>
Trim a video to a range (POST /v1/trim-video). Give the range in whichever
unit fits: startTime/endTime seconds, trim*Frames, trim*Seconds, or
keepFirstSeconds/keepLastSeconds.
addCaptions(input)
addCaptions(input: {
videoUrl: string
text?: string
captions?: Array<{ text: string; startMs: number; endMs: number; timestampMs?: number | null; confidence?: number | null }>
autoTranscribe?: boolean // transcribe the audio when no text/captions given (default true)
transcribeProvider?: "whisper" | "incredibly-fast-whisper" | "elevenlabs-stt"
// "whisper" has no word timestamps — rejected only when transcription is the render's only caption source
style?: CaptionStyle // "subtitle" (static) | "word-highlight" | "karaoke" | "tiktok-words" | "word-pop" | "bouncy"
position?: "bottom" | "top" | "center"
fontSize?: number
color?: string
backgroundColor?: string
// Look + motion levers. The STYLING levers (look, fontFamily, fontWeight,
// strokeColor/strokeWidth, uppercase, positionY) now apply to the static
// "subtitle" style too — a subtitle carrying any of them renders via Remotion.
// Only highlightColor and animate stay kinetic-only (rejected 400 on subtitle):
look?: "outline" | "clean" // preset; on the kinetic styles an UNSET look renders as "outline"
fontFamily?: SupportedFontName
fontWeight?: number // 100–900 in 100s
strokeColor?: string
strokeWidth?: number
highlightColor?: string // kinetic only — the spoken/active word colour
uppercase?: boolean
positionY?: number // caption CENTER as % of height; overrides position
animate?: boolean // kinetic only; false freezes per-word motion (default true)
// Apply different treatments to non-overlapping time ranges in one call:
segments?: CaptionSegmentInput[]
}): Promise<{ jobId: string }>
Burn captions into a video (POST /v1/add-captions). Give the words as text,
word-timed captions[] (one entry per WORD for the kinetic styles), or let it
transcribe (the default). On the kinetic styles a look preset — outline
(Montserrat 900, UPPERCASE, black outline, yellow spoken word — the TikTok read)
or clean — drives the styling, and an unset look renders as outline; the
explicit levers override individual fields of it. The styling levers (look,
fontFamily, fontWeight, strokeColor/strokeWidth, uppercase,
positionY) now apply to subtitle too — a styled subtitle renders via Remotion
and bills at the kinetic price, while a bare plain-text subtitle stays on the
cheap FFmpeg path. Only highlightColor and animate stay kinetic-only.
animate (default true) freezes the per-word motion when false — grouping,
line-holding and the highlight colour stay; set highlightColor = color for a
fully static line. segments[] applies different treatments to non-overlapping
time ranges; a segment that names its own look starts fresh from that preset
and does not inherit the top-level explicit levers. Poll jobs.get(jobId).
A kinetic style (and any segments[] render) is word-timed, so when the call
auto-transcribes, transcribeProvider must be an engine that returns word
timestamps — incredibly-fast-whisper (the default here) or elevenlabs-stt.
Naming "whisper" is rejected with 400 validation_error on
transcribeProvider only when transcription is the render’s ONLY possible
caption source; with text, captions[], a transcript, autoTranscribe:
false, or self-sourced segments[], the call is accepted and the
word-timing-less engine is simply never called — the render falls back to that
other source (with only text, evenly-spaced synthetic captions). It also stays
valid for the static subtitle style, which never transcribes.
word-highlight shows ONE held line at a time: words are grouped into a line
that fits the frame, the line closes on a sentence end or a pause, and the
highlight walks word to word inside it. A word’s startMs/endMs is its
spoken window — what times the highlight — not how long its text is on
screen.
Transcribe → correct → burn. Because captions[] is exactly the shape
audio.transcribe() returns in output_data.words, the two
compose directly — and supplying captions[] means add-captions runs no STT of
its own:
const { jobId } = await client.audio.transcribe({
audioUrl: "https://…/talk.mp3",
provider: "elevenlabs-stt", // always word-level
})
const { data: job } = await client.jobs.get(jobId) // poll until completed
const { words } = job.output_data as TranscribeJobOutput // ms, one per word
// …correct a misheard word here: words[7].text = "Nodaro"
await client.media.addCaptions({
videoUrl: "https://…/talk.mp4",
captions: words,
autoTranscribe: false, // the words above ARE the caption source
style: "word-highlight",
})
trimAudio(input)
trimAudio(input: {
videoUrl?: string
audioUrl?: string
audioFormat?: "mp3" | "wav" | "aac"
startTime?: number
endTime?: number
}): Promise<{ jobId: string }>
Trim (and extract) audio from a video or audio source (POST /v1/trim-audio)
to [startTime, endTime] seconds, in audioFormat (mp3 default).
stillToVideo(input)
stillToVideo(input: {
imageUrl: string
audioUrl: string
motion?: "none" | "zoom-in" | "zoom-out" | "pan-left" | "pan-right" | "ken-burns"
intensity?: number
resolution?: "720p" | "1080p" | "4K"
aspectRatio?: "16:9" | "9:16" | "1:1" | "4:3"
fps?: 24 | 30
fit?: "cover" | "contain"
padColor?: string
}): Promise<{ jobId: string }>
Turn one still image + one audio track into an MP4
(POST /v1/still-to-video) — locally rendered (FFmpeg), no AI model,
zero credits. The output duration is the audio’s duration; there is no
duration field. motion (default none) animates the still at intensity
1–10; fit: "contain" letterboxes with padColor instead of cropping.
slideshow(input)
slideshow(input: {
imageUrls: string[]
audioUrl?: string
imageDurations?: Array<number | null>
perImageDuration?: number
transition?: string
transitionDuration?: number
motion?: "none" | "zoom-in" | "zoom-out" | "ken-burns" | "alternate"
intensity?: number
resolution?: "720p" | "1080p" | "4K"
aspectRatio?: "16:9" | "9:16" | "1:1" | "4:3"
fps?: 24 | 30
fit?: "cover" | "contain"
padColor?: string
}): Promise<{ jobId: string }>
Turn 2–100 images + one optional audio track into an MP4 slideshow
(POST /v1/slideshow) — locally rendered (FFmpeg), zero credits. With
audio, the output duration is the audio’s duration (equal split unless
imageDurations pins rows — null = auto; mismatched pinned sums scale
proportionally, disclosed in the job output). Without audio: N ×
perImageDuration, silent output. For a single image use stillToVideo.
saveToStorage(input)
saveToStorage(input: {
mediaUrl: string
filename?: string
mediaType?: "image" | "video" | "audio"
}): Promise<{ jobId: string }>
Copy an external media URL into your Nodaro storage
(POST /v1/save-to-storage) — a server-side fetch, so nothing round-trips
through the client.
imageCollage(input)
imageCollage(input: {
imageUrls: string[]
imageSizes?: Array<0 | 1 | 2 | 3>
numbered?: boolean
imageLabels?: Array<string | null>
badgePosition?: "top-left" | "top-right"
layout?: "smart" | "grid"
resolution?: "2K" | "4K"
aspectRatio?: string
gap?: number
backgroundColor?: string
}): Promise<{ jobId: string }>
Composite 2–30 images into ONE large 2K/4K collage (POST /v1/image-collage).
layout is "smart" (default — justified rows at each image’s exact aspect
ratio, no cropping; the output height floats) or "grid" (uniform,
letterboxed cells). imageSizes is index-aligned with imageUrls and gives
per-image relative size hints for the smart layout: 0 auto (“don’t
care”, default), 1 big (~2× linear vs medium), 2 medium, 3 small
(~½ linear). All-equal hints change nothing; grid ignores them. numbered
stamps a 1-based sequence number at each image’s corner in
imageUrls order — storyboard mode (default off). badgePosition picks
that corner for numbers and labels alike: "top-left" (default — the
storyboard convention) or "top-right". imageLabels is
index-aligned with imageUrls and shows an optional caption after the
number as 3 · Close-up (the label alone when numbered is off);
null/"" = no label for that image, each ≤ 80 chars, and a label too
long to fit its image is ellipsized. Badges never change the layout, the
output size, or the credit cost. Poll
jobs.get(jobId) for the finished image.
imageOverlay(input)
imageOverlay(input: {
imageUrl: string
layers: Array<{
imageUrl: string
anchor?: "top-left" | "top" | "top-right" | "left" | "center" | "right" | "bottom-left" | "bottom" | "bottom-right"
x?: number
y?: number
width?: number
height?: number
opacity?: number
rotation?: number
blend?: "over" | "multiply" | "screen"
fit?: "contain" | "cover" | "stretch"
shadow?: { blur: number; offsetX: number; offsetY: number; color: string; opacity: number }
roundedCorners?: number
zIndex?: number
kind?: "image" | "text" | "qr" | "shape"
text?: OverlayTextStyle
qr?: OverlayQrStyle
shape?: OverlayShapeStyle
effects?: OverlayImageEffects
}>
canvas?: { width: number; height: number; backgroundColor?: string }
baseFit?: "contain" | "cover"
outputFormat?: "png" | "jpg" | "webp"
variants?: string[]
maskMode?: "none" | "layers" | "around" | "outside"
maskSpread?: number
qrText?: string
}): Promise<{ jobId: string }>
Place 1–12 layers on a base image, pixel-exactly (POST /v1/image-overlay) —
a local, deterministic composite, no AI model. A layer is a picture
(kind: "image", default — imageUrl), real text (kind: "text" with a
text object: content, fontId, fontWeight, fontSize as % of the base
height, colour, align, outline, background box), a QR code (kind: "qr",
qr: { text }) or a flat shape (kind: "shape", shape: { shape, color }). Every layer position and size is a percentage of the base
image, so the same call works on a 1K preview and a 4K render: anchor
(nine positions, default "center"), x/y (offset from the anchor in %
of the base width/height — on a right/bottom anchor a negative value moves
the layer inward), width (% of the base width, default 25; the height
follows the layer’s aspect ratio unless height is set, in which case
fit decides how the source fills the box). opacity is 0..1,
rotation is in degrees around the layer’s centre, blend is "over"
(default), "multiply" or "screen"; shadow adds a blurred silhouette
under the layer and roundedCorners a px radius. SVG layers are rasterised
crisp at the target size. The output keeps the base image’s pixel size
unless canvas is set (the base is then placed into it with baseFit).
outputFormat is "png" (default, keeps transparency), "jpg" or
"webp". Watermark example: { anchor: "bottom-right", x: -4, y: -6,
width: 12, opacity: 0.95 }. Poll jobs.get(jobId) for the finished image;
its output.imageUrl is the composite (output.width / output.height its
pixel size), output.maskUrl the mask and output.variants[] the extra
platform renders ({ id, label, width, height, url } — any of the 12 platform
ids in variants, all at once if you like; 2 credits per platform on top of
the 10-credit base). A QR layer may set
qr.fromInput: true and take its payload from the top-level qrText (in a
workflow that is the node’s QR link handle — a List column yields one code per
row); a run with the flag set and no qrText is refused with a 400 naming it.
suggestOverlayPlacement(input)
suggestOverlayPlacement(input: {
imageUrl: string
layerAspect?: number
intent?: string
safeArea?: { x: number; y: number; w: number; h: number }
llmModel?: string
}): Promise<{ jobId: string; placement: OverlayPlacement }>
Ask a vision model WHERE one overlay layer should sit on a base image (POST
/v1/image-overlay/suggest-placement) — it reads the picture and keeps the
element off the faces, the subject and the busiest texture. The answer comes
back in imageOverlay’s own percent units — anchor, x/y, width
— so it drops straight onto a layer, plus a one-sentence reason you can show
a user. Nothing is composited: you apply the placement.
layerAspect is the element’s width / height (1 = square, the default) so the
proposed box stays in proportion; intent says what the element is (“a logo”,
“a price badge”); safeArea is the always-visible region as fractions of the
canvas (a platform preset’s safe area), which the placement is kept inside.
Unlike the other media calls this one answers synchronously — there is
nothing to poll, and jobId is the billing record (one Describe Image call).
const { placement } = await nodaro.media.suggestOverlayPlacement({
imageUrl: base,
intent: "a logo",
layerAspect: 2.5,
})
const { reason, ...box } = placement // anchor + x + y + width, ready to use
await nodaro.media.imageOverlay({
imageUrl: base,
layers: [{ imageUrl: logo, ...box }],
})
videoMetadata(input)
videoMetadata(input: { url: string }): Promise<VideoMetadata>
Probe a social video’s metadata (POST /v1/video-metadata) — duration,
dimensions, title, live status — without downloading it. A direct read,
not a job. Use it to decide whether to trim before importing.
process(input)
process(input: {
sourceUrl: string
type: "video" | "audio"
trim?: { startTime: number; endTime: number }
crop?: { x: number; y: number; width: number; height: number }
format?: "mp4" | "webm" | "mp3" | "wav" | "m4a" | "aac"
deleteSource?: boolean
}): Promise<{ data: { url: string; thumbnailUrl: string | null; assetId: string | null; sizeBytes: number; mimeType: string; metadata: Record<string, unknown> } }>
Cut or crop a stored file (POST /v1/media/process) — synchronous and free,
the source-preparation sibling of the priced trimVideo node. deleteSource:
true removes the source afterwards when it is yours and nothing else
references it.
client.audio
Audio primitives — the building blocks Voice Changer Pro composes internally
(separation, isolation, effect, mix, level) plus speech-to-text, exposed
standalone so a consumer can run any single step or assemble its own pipeline.
Every method returns a job id to poll with client.jobs.get(jobId).
separate(input)
separate(input: {
audioUrl: string
mode?: "vocal_instrumental" | "stems"
quality?: "auto" | "fast" | "best"
}): Promise<{ jobId: string }>
Separate an audio track into stems (POST /v1/audio-separation, Demucs).
"vocal_instrumental" (default) splits voice from music/SFX; "stems"
returns the full drums/bass/other/… breakdown.
isolate(input)
isolate(input: { audioUrl: string }): Promise<{ jobId: string }>
Isolate the primary voice and strip background noise
(POST /v1/audio-isolation).
applyFx(input)
applyFx(input: {
audioUrl: string
preset?: AudioFxPreset
mix?: number
delayMs?: number
decay?: number
eqLow?: number
eqHigh?: number
}): Promise<{ jobId: string }>
Apply a reverb / echo / telephone / megaphone effect (POST /v1/audio-fx) —
the same presets the voice changer’s voiceFx uses, standalone. mix (0–100)
is the reverb wet/dry; delayMs + decay drive echo/custom;
eqLow/eqHigh (dB) shape telephone/megaphone.
mix(input)
mix(input: { audioUrls: string[]; trackVolumes?: number[] }): Promise<{ jobId: string }>
Layer multiple audio tracks into one (POST /v1/mix-audio). audioUrls
(2–20) are summed; optional trackVolumes (0–200% each, positionally) set
per-track level.
adjustVolume(input)
adjustVolume(input: {
audioUrl?: string
videoUrl?: string
volume?: number
normalize?: boolean
fadeIn?: number
fadeOut?: number
}): Promise<{ jobId: string }>
Adjust an audio (or a video’s audio) level (POST /v1/adjust-volume):
volume % (default 100), normalize to loudnorm, fadeIn/fadeOut seconds.
combine(input)
combine(input: {
segments: Array<{ url: string; startTime?: number; endTime?: number }>
}): Promise<{ jobId: string }>
Concatenate audio segments end-to-end (POST /v1/combine-audio). Each segment
is a url with an optional [startTime, endTime] sub-range.
transcribe(input)
transcribe(input: {
audioUrl: string
provider?: TranscribeProvider // "elevenlabs-stt" — the enabled enum
language?: string // force a language; omit to auto-detect
diarize?: boolean // label who spoke each word (elevenlabs-stt)
tagAudioEvents?: boolean // tag laughter / applause / … (elevenlabs-stt)
wordTimestamps?: boolean // ask for per-word timings
}): Promise<{ jobId: string }>
Transcribe an audio (or video) track to text (POST /v1/transcribe).
Pass provider: "elevenlabs-stt" whenever you want WORD TIMINGS: Scribe is
always word-level (flag or not) and is the lane that honours diarize and
tagAudioEvents. Omitting provider falls back to the legacy whisper lane,
which cannot produce word timings — asking it for them (wordTimestamps: true)
is rejected with 400 validation_error at ingress, before any credit is spent.
Poll jobs.get(jobId). The completed job’s output_data is a
TranscribeJobOutput:
| Field | Units | What it is |
|---|---|---|
text |
— | the whole transcript as one string |
language |
— | the detected (or requested) language code |
words |
ms | one entry per word: { text, startMs, endMs, speaker? } |
json |
ms | the normalized Transcript ({ version, language, words[], segments? }) — what client.edit.* consumes |
segments |
seconds | the raw per-utterance ranges — not ms, unlike everything above. Legacy lanes only: elevenlabs-stt returns none, so read words |
words is caption-shaped, so it drops straight into
media.addCaptions() as captions — see the composition
example there.
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"). |
effectiveTier |
string |
Entitlement tier actually enforced. "payg" = pay-as-you-go: no subscription, but purchased credits — all models unlocked, no watermark, no daily cap. |
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 — the CANONICAL type, which may differ from the one the browser declared (audio/vnd.dlna.adts → audio/aac, application/octet-stream + clip.mp4 → video/mp4). See Direct uploads. |
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.library
list(params?)
list(params?: ListLibraryParams): Promise<ListLibraryResult>
GET /v1/library → the user’s stored media with a storage summary. Filter by
type (image / video / audio), paginate with cursor/limit.
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
importfrom@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 MCPget_picker_catalogtool).
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); a single-dim picker with secondary
parameter fields beside its main picker (transition, character-fx:
position / duration / intensity; character-motion: position /
pace) carries both. 404 not_found for an unknown type.
GetPickerCatalogOptions:
| Field | Type | Description |
|---|---|---|
detail |
"compact" | "full" |
"compact" (default): id, label, category, term, 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 |
Return only this dimension’s field — multi-dim pickers (person / styling / framing), and the secondary parameters of a single-dim picker (transition / character-fx: position / duration / intensity; character-motion: position / pace). |
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
console.log(serene?.term) // the compact form — "serene mood"; `label` stays display-only
Every option also carries term at both detail levels — the short
professional phrase to inject when you want a compact instruction instead of the
full promptHint sentence. Render label, inject term; never derive one from
the other. A no-op option (auto / none) has term: "" because it injects
nothing.
analyzeText
analyzeText(params: TextToPickerParams): Promise<TextToPickerResult>
POST /v1/text-to-picker → fill picker selections from a free-text scene
description (“AI Fill”): the text twin of describe-to-picker. Returns
{ jobId, pickerJson, gaps } where pickerJson is keyed
pickerType → dimension → catalog id(s) — hydrate pickers from it verbatim,
then let the user tweak. Dimensions the text says nothing about are omitted
(the analyzer is instructed not to guess).
| Field | Type | Description |
|---|---|---|
text |
string |
The scene/shot description (up to the prompt ceiling). |
targetPickers |
string[]? |
Picker node types to fill. Omit for all analyzable pickers — the server batches the analysis per catalog family and merges. |
instructions |
string? |
Extra guidance for the analyzer. |
llmModel / reasoningEffort |
string? |
Standard LLM selection; billed as the describe-to-picker feature. |
gaps reports described attributes no catalog id represents well
(missingItems / missingCategories) — surface as “we couldn’t infer X —
pick one”.
const { pickerJson, gaps } = await client.pickerCatalogs.analyzeText({
text: "Neon-soaked Tokyo alley at night, rain, handheld tracking shot, moody synthwave",
})
console.log(pickerJson["setting"], pickerJson["camera-motion"], gaps)
client.catalogs
The server-driven catalog projection: every picker catalog in one call,
reflecting the deployment’s registered vendored packs (a deployment can
replace / extend / deny a catalog’s options). Where client.pickerCatalogs
fetches one picker at a time, client.catalogs.list() returns the whole set at
once — so a thin client that renders its own pickers honors the deployment’s
curation. Public (no auth), publicly cacheable for 5 minutes.
list(opts?)
list(opts?: { detail?: "compact" | "full" }): Promise<{ data: ProjectedCatalog[] }>
GET /v1/catalogs → every registered catalog projected to one flat shape.
detail: "compact" (default) carries id, label, category, term, icon;
detail: "full" additionally carries each option’s description and
promptHint. A single-dim catalog carries options; a multi-dim catalog
carries dimensions (one { field, label, options } per field). The shape is
tag/policy-free.
term rides at both detail levels on purpose: a thin client that renders
its own pickers can display label and inject the short professional term
without a second, heavier detail: "full" fetch.
const { data } = await client.catalogs.list({ detail: "full" })
const setting = data.find((c) => c.catalogId === "setting")
console.log(setting?.options?.[0]?.promptHint) // full mechanism sentence
console.log(setting?.options?.[0]?.term) // compact professional term
client.shots
Cine shots — the Share → Remix record behind /s/:id share links. A shot
stores builder state (picker selectionState, prompts, target models,
@-mention entityRefs, resultUrls) under an unguessable 12-char id that
doubles as the share capability. Rows default to private; sharing is an
explicit visibility toggle. resultUrls must be plain public http(s) URLs —
signed URLs are rejected so a token can never leak into a share record.
create(input?: CreateShotInput): Promise<{ id: string }> // POST /v1/shots
get(id: string): Promise<{ shot: Shot }> // GET /v1/shots/:id — public shots readable by anyone with the id; private = owner only (404 otherwise)
update(id: string, input: UpdateShotInput): Promise<{ shot: Shot }> // PATCH — owner only, any subset (e.g. { visibility: "public" })
delete(id: string): Promise<void> // DELETE — owner only
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 asNotFoundError).
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")
client.templates
The workflow-template marketplace — the public-by-design surfaces only: browse, a single template with its full snapshot, and the free clone-into-my-project action. Creator and admin surfaces (publish, mine, favorites, metadata patch, tutorial flags) are deliberately not part of the public SDK contract.
browse(params?)
browse(params?: BrowseTemplatesParams): Promise<BrowseTemplatesResult>
GET /v1/templates/browse → cursor-paginated marketplace cards (no auth
required). params: cursor, limit, category, outputType, tag,
search (full-text), sort ("newest" default | "popular" |
"most-favorited"), nodeType, provider, complexity. Returns
{ data: TemplateBrowseCard[], nextCursor: string | null } — pass
nextCursor back as cursor for the next page.
const page = await client.templates.browse({ sort: "popular", search: "trailer" })
get(slug)
get(slug: string): Promise<Template>
GET /v1/templates/:slug → one public template including its full workflow
snapshot (snapshotNodes / snapshotEdges / snapshotSettings) for
read-only viewers. 404 when the slug is unknown, unlisted, or inactive.
clone(slug, params)
clone(slug: string, params: CloneTemplateParams): Promise<CloneTemplateResult>
POST /v1/templates/:slug/clone → clone the template into one of the
caller’s projects. Free — no credits charged. params:
{ projectId, name? } (name defaults to the template’s). Returns
{ workflowId, projectId }.
const { workflowId } = await client.templates.clone("noir-trailer", { projectId })
client.tutorials
list()
list(): Promise<{ categories: TutorialCategory[] }>
GET /v1/tutorials → every enabled tutorial category with its video
tutorials (videos) and flow tutorials (flows — workflow templates flagged
as tutorials; each flow’s slug feeds client.templates.get/clone).
Public, read-only; curation is an admin surface outside the public SDK.
client.organizations
Organizations — a school or a team, the people in it, and the invitations that fill it. Only on instances that have them; elsewhere every call answers 404.
Nothing here decides anything. Whether a caller may invite, remove or rename is the server’s answer, delivered as a typed error code (the table) — an SDK that guessed would be wrong the first time a setting changed.
| Method | Endpoint | Notes |
|---|---|---|
list() |
GET /v1/orgs |
What this account belongs to. |
get(id) |
GET /v1/orgs/:id |
|
create(input) |
POST /v1/orgs |
{ name, kind: "school" | "team", slug?, acceptTerms?, settings? }. May return status: "pending" — see below. |
update(id, input) |
PATCH /v1/orgs/:id |
{ name?, settings? } |
delete(id) |
DELETE /v1/orgs/:id |
Soft-delete. Nothing is destroyed. |
transferOwnership(id, userId) |
POST /v1/orgs/:id/transfer-ownership |
The caller becomes an admin. |
leave(id) |
POST /v1/orgs/:id/leave |
An owner cannot — transfer first (owner_cannot_leave). |
listMembers(orgId, { cursor?, limit? }) |
GET /v1/orgs/:id/members |
Returns { data, nextCursor }. |
updateMember(orgId, userId, input) |
PATCH /v1/orgs/:id/members/:userId |
{ role?, status? } |
removeMember(orgId, userId) |
DELETE /v1/orgs/:id/members/:userId |
|
invite(orgId, input) |
POST /v1/orgs/:id/invitations |
{ emails, orgRole?, workspaceId?, workspaceRole? }. Read the note below. |
listInvitations(orgId, opts) |
GET /v1/orgs/:id/invitations |
{ status?, workspaceId?, cursor?, limit? } |
revokeInvitation(id) |
DELETE /v1/invitations/:id |
|
resendInvitation(id) |
POST /v1/invitations/:id/resend |
|
previewInvitation(token) |
GET /v1/invitations/by-token/:token |
Public — works while the invitee is still signed out. The address comes back masked. |
acceptInvitation(token) |
POST /v1/invitations/:token/accept |
Requires a signed-in caller whose email matches. |
audit(orgId, { cursor?, limit? }) |
GET /v1/orgs/:id/audit |
Newest first. Readable while the organization is suspended. |
usage(orgId, opts) |
GET /v1/orgs/:id/usage |
Credits by workspace, member, model or day. Owner and org admins. { from?, to?, tz?, groupBy?, workspaceId?, userId? }, inclusive dates, IANA tz, ≤ 366 days. |
usageRows(orgId, opts) |
GET /v1/orgs/:id/usage?groupBy=none |
The individual runs behind a report, newest first, cursor-paged. |
usageCsv(orgId, opts) |
GET /v1/orgs/:id/usage?format=csv |
The same report (or the rows) as CSV text. |
invite returns one row per address, and a row whose status is not
sent carries a link instead — an install with no mail provider, or a
delivery that failed. Surface it: the invitation exists either way, and
without the link nobody can reach it.
audit entries carry an action from an open vocabulary. Render the
ones you recognise and fall back to the raw string; a client that switched
exhaustively over it would break on the first new action.
usage reports three credit figures per row. credits is what a run has cost
so far — settled where the run finished, the held reservation otherwise — and
settledCredits and inFlightCredits split it. A metered run that overran the
workspace’s headroom has the excess absorbed by the platform; the totals report
it as platformAbsorbedCredits, and chargedToBudget = settledCredits −
platformAbsorbedCredits is the metered settlement that reached the budget. An
approved-app markup the budget could not cover is absorbed separately as
appMarkupAbsorbedCredits; it has no run in the report, so it is not in
chargedToBudget (which is therefore not, by itself, the workspace’s spent
figure). Totals cover the whole window even when a grouping is truncated.
client.workspaces
Workspaces — the inner tenancy axis, where work lives. Belonging to a
workspace and acting in one are different things: this resource is the
first, withWorkspace is the second.
| Method | Endpoint | Notes | |
|---|---|---|---|
list() |
GET /v1/workspaces |
{ data, lastWorkspaceId }. Byte-for-byte the list GET /v1/me carries, so there is one truth to reconcile against — these are summaries, not full views. |
|
listForOrg(orgId, { includeArchived? }) |
GET /v1/orgs/:id/workspaces |
||
get(id) |
GET /v1/workspaces/:id |
The full view. | |
create(orgId, input) |
POST /v1/orgs/:id/workspaces |
{ name, slug?, description?, settings? } |
|
update(id, input) |
PATCH /v1/workspaces/:id |
{ name?, description?, settings? } |
|
setArchived(id, archived) |
POST /v1/workspaces/:id/archive |
/unarchive |
Reversible, destroys nothing: the workspace stops accepting new work and stays fully readable. |
listMembers(id, { cursor?, limit? }) |
GET /v1/workspaces/:id/members |
||
addMember(id, input) |
POST /v1/workspaces/:id/members |
{ userId, role }. The person must already be in the organization; to bring in a new one, invite them. |
|
updateMember(id, userId, input) |
PATCH /v1/workspaces/:id/members/:userId |
{ role?, status?, creditCap? } |
|
removeMember(id, userId) |
DELETE /v1/workspaces/:id/members/:userId |
||
getJoinCode(id) |
GET /v1/workspaces/:id/join-code |
null when none has been minted. Admins only. |
|
actOnJoinCode(id, action) |
POST /v1/workspaces/:id/join-code |
"rotate" | "enable" | "disable". Rotating invalidates the old code immediately. |
|
join(code) |
POST /v1/workspaces/join |
Another way IN, so a stale workspace selection never blocks it. | |
usage(id, opts) |
GET /v1/workspaces/:id/usage |
By member, model or day. A member sees their own runs; an admin sees everyone and may filter userId. |
|
usageRows(id, opts) |
GET /v1/workspaces/:id/usage?groupBy=none |
The runs behind a report, newest first, cursor-paged. | |
usageCsv(id, opts) |
GET /v1/workspaces/:id/usage?format=csv |
The same report (or the rows) as CSV text. |
client.edit
Phase-1 editorial primitives for podcast / long-form video editing. Three
request methods return { jobId } (EditJobResult) — poll with
client.jobs.getStatus(jobId) — plus one pure local helper.
| Method | Endpoint | Notes |
|---|---|---|
silenceDetect(input) |
POST /v1/silence-detect |
Keyless ffmpeg silence pass over an audio or video source. |
applyEdl(input) |
POST /v1/apply-edl |
Render an edit decision list (EDL) into a video or audio cut. |
editPlan(input) |
POST /v1/edit-plan |
Transcript-driven planner (tighten / clips / chapters). On a self-hosted install it relays to nodaro.ai (503 nodaro_connection_required when not connected). |
remapTranscript(edl, transcript) |
— (local) | PURE client-side transform — remaps a transcript through an EDL. No request. |
silenceDetect(input)
silenceDetect(input: SilenceDetectInput): Promise<EditJobResult>
SilenceDetectInput:
| Field | Type | Required | Description |
|---|---|---|---|
audioUrl |
string |
yes | Audio or video source (the pass reads the shared audio proxy either way). |
thresholdDb |
number |
no | dBFS threshold, ≤ 0. Default -35. |
minSilenceMs |
number |
no | Minimum silence length to report. Default 700. |
padMs |
number |
no | Padding kept around speech (shrinks each range inward). Default 120. |
workflowId |
string |
no | Associates the run with a workflow (execution-history display). |
The finished job’s output_data.json is a SilenceRanges object
({ version, ranges: [{ startMs, endMs }], durationMs }) — pass that whole
object as editPlan’s silence.
applyEdl(input)
applyEdl(input: ApplyEdlInput): Promise<EditJobResult>
ApplyEdlInput:
| Field | Type | Required | Description |
|---|---|---|---|
edl |
Edl |
yes | The edit decision list to render. Media resolves from each edl.sources[i].url. |
sources |
string[] |
no | Positional media-URL overrides for edl.sources[i].url. |
transcript |
Transcript |
no | Transcript remapped through the cut and returned on the job’s json output. For a large transcript where you only need the re-timed result, use remapTranscript locally instead. |
output |
"video" \| "audio" |
no | Default "video". |
quality |
"proxy" \| "final" |
no | Default "final". |
crossfadeMs |
number |
no | Default crossfade on boundaries without an explicit transition; 0 = hard cuts. Default 0. |
workflowId |
string |
no | Execution-history display. |
The EDL is validated at ingress — an unresolvable source or a picture-less
segment on a video edit throws a NodaroError (status 400,
code: "invalid_edl") before any credits are reserved.
editPlan(input)
editPlan(input: EditPlanInput): Promise<EditJobResult>
EditPlanInput:
| Field | Type | Required | Description |
|---|---|---|---|
mode |
EditPlanMode |
yes | "tighten" | "clips" | "chapters". |
planTier |
EditPlanTier |
yes | "economy" | "standard" | "premium" — affects quality and the credit bucket. |
transcript |
Transcript |
yes | The timed transcript driving the plan. |
sources |
EditPlanSource[] |
yes | 1–6 media sources. Each: { id, url, kind: "video" \| "audio", role?, speakers?, offsetMs? }. |
silence |
SilenceRanges |
no | The silence-detect job’s output_data.json — pass the whole object (an input without ranges is silently ignored). |
instructions |
string |
no | Free-text editing steer. |
styleGuide |
string |
no | Style-guide text. |
count |
number |
no | "clips" mode: how many clips to cut. |
targetDurationSec |
number |
no | "clips" mode: target duration per clip. |
targetAspect |
"16:9" \| "9:16" \| "1:1" \| "4:5" |
no | Clip aspect. |
platform |
string |
no | Target platform hint. |
workflowId |
string |
no | Execution-history display. |
Read the finished job’s output_data with unwrapEditPlanOutput — it returns
an Edl (tighten), a bare Edl[] (clips, unwrapped from EdlClipSet), or a
ChapterSet (chapters), stripping the relay’s viaNodaroCloud marker.
const { jobId } = await client.edit.editPlan({
mode: "clips",
planTier: "standard",
transcript, // from a transcribe job's output_data.json
sources: [{ id: "ep", url: masterUrl, kind: "video", role: "master-audio" }],
silence, // a silence-detect job's output_data.json
count: 5,
targetAspect: "9:16",
})
const done = await client.jobs.getStatus(jobId)
const plan = unwrapEditPlanOutput(done.data.output_data) // Edl[] for clips
remapTranscript(edl, transcript)
remapTranscript(edl: Edl, transcript: Transcript): Transcript
A PURE local transform (no request): returns a new transcript whose word (and
segment) timings are on the EDL’s rendered output clock, dropping words in cut
regions and clipping straddlers. The same remap applyEdl performs server-side.
Type re-exports
Every type used in a public method signature is re-exported from
@nodaro/sdk. Import them with import type { ... }.
Client identity
UserIdentity— return type ofclient.me():{ id, email, displayName: string | null, avatarUrl: string | null, tier, isAdmin }ClientOptions—createClientoptions:{ baseUrl, auth, fetch?, timeoutMs?, clientLabel?, workspaceId? }
Organizations
Re-exported from @nodaro/shared, so an integration needs one dependency and
not two.
OrganizationView/OrgMemberView/WorkspaceView/WorkspaceMemberView— the full recordsOrganizationSummary/WorkspaceSummary/MeOrganizations— whatGET /v1/meandGET /v1/workspacescarryInvitationView/InvitationDelivery/InvitationPreview/InvitationState— invitations;InvitationDelivery.linkis the one to surfaceJoinCodeView,OrgAuditEntry,OrgPage<T>OrgKind/OrgRole/WorkspaceRole/MemberStatus/OrgStatus/OrgSettings/WorkspaceSettingsUsageReport/UsageReportRow/UsageVarianceRow/UsageReportTotals— the grouped report;UsageLogEntry— a row ofusageRows;UsageQuery/UsageGroupBy/USAGE_GROUP_BYS— the query and itsgroupByvaluesOrgErrorCode— the codes to dispatch on;WORKSPACE_HEADER— the header name;USAGE_GROUP_BYS— the report groupings
Templates & tutorials
TemplateBrowseCard/BrowseTemplatesParams/BrowseTemplatesResult/TemplateSort— marketplace browseTemplate— full public template incl.snapshotNodes/snapshotEdges/snapshotSettingsCloneTemplateParams/CloneTemplateResult—{ projectId, name? }→{ workflowId, projectId }TutorialCategory/TutorialVideoItem/TutorialFlowItem— groupedclient.tutorials.list()response
Workflows
Workflow— workflow record (full record onget/create/update, metadata only onlist)ListWorkflowsParams—{ projectId }CreateWorkflowInput—{ projectId, name, ... }UpdateWorkflowInput— partial workflow fieldsRunWorkflowParams—{ nodeIds? }RunWorkflowResult—{ executionId, status }
Projects
Project— project recordCreateProjectInput,UpdateProjectInput
Jobs
Job— snake_case wire shape; includes provenance:source("internal" | "mcp" | "app" | "cli" | "sdk" | "extension" | "web" | "api") +source_detail(origin host /extension/<name>label /sdk/<version>/ MCP client / app id) so a library view can label or filter media by originJobStatus—"pending" | "queued" | "processing" | "pending_review" | "completed" | "failed" | "cancelled".pending_reviewis in-flight, not terminal: a job policy registered by the deployment held the output for human review. Keep waiting — it resolves tocompleted(approved),failed(rejected, witherror_hint.kind === "policy-block") orcancelled(the owner cancelled it; a held job is cancellable like any in-flight job), andcredit_statusreads"reserved"for the whole hold.runAndWaitthrowsJobHeldErroron the first held tick rather than waiting it out. Only appears on deployments that register a job policy.JobStatusResult— lean poll shape:{ id, status, progress?, output_data?, error_message? }CancelJobResult—{ success: true, cancelled: number }DeleteJobResult—{ success: true }ListJobsParams/ListJobsPage—jobs.listfilters and page
LLM
LlmStructuredInput,LlmStructuredResult<T>,LlmStructuredJobInput,LlmStructuredJobOutput<T>
Video Pro run control
StopVideoProResult—{ jobId, stopping? } | { jobId, success?, cancelled? }(pending jobs forward the generic cancel result)ContinueVideoProResult—{ jobId, continuedFromJobId?, fromSegment?, segmentCount?, deduped? }
Executions
WorkflowExecution— full execution record with per-node state mapWorkflowExecutionSummary— list-row shapeNodeExecutionState— per-node entry insidenodeStates;outputis present for acompletednode AND for afailedone whose run retained a resultNodeExecutionStatus— per-NODE status:"pending" | "running" | "completed" | "failed" | "skipped"OUTPUT_BEARING_NODE_STATUSES/nodeStateMayCarryOutput(status)— the two statuses whose node state may carryoutputExecutionStatus— the EXECUTION’s own status:"pending" | "running" | "completed" | "failed" | "cancelled" | "stopping" | "timed_out" | "discarded"ExecutionTriggerType—"manual" | "webhook" | "schedule" | "app_run" | "single-node"ListExecutionsForWorkflowParams— pagination + filtersListExecutionsPage<T>—{ data: T[], nextCursor? }CancelExecutionParams—{ mode?: "after_current" | "discard" }
Nodes
NodeDescriptor— public metadata for one node typeNodeCategory— union of category slugsOutputType—"text" | "image" | "video" | "audio" | "data" | "none"NodeInputField,NodeInputSchema— input-schema shapesPromptAffixFields— the{ promptPrefix?, promptSuffix? }node-data contract for pre & post text;PROMPT_PREFIX_KEY/PROMPT_SUFFIX_KEYare the matching key constants (value exports, not types)RunNodeResult—{ jobId: string; ... } | Record<string, unknown>(discriminated on presence ofjobId)RunNodeAdjustment— one parameter correction on the image node types:{ field: "aspectRatio" | "resolution" | "quality" | "duration", from, to?, reason }(re-exported from@nodaro/shared’sModelInputAdjustment); see Parameter correctionsNodeJobOutput— typedoutput_datashape:{ audioUrl?, videoUrl?, imageUrl?, thumbnailUrl?, [k]: unknown }RunAndWaitOptions—{ signal?, onProgress?, pollMs?, maxMs? }RunManyResult—{ jobId: string; output: NodeJobOutput }
Characters
Character— full character record (camelCase)CharacterDetail—Characterplus in-flightpendingJobs/portraitCandidates/previousCandidatesbucketsCharacterUsage—{ workflowCount, workflows: { id, name }[] }ReferencePhoto,ReferencePhotoKind— identity reference photo shapesUpsertCharacterInput— body forupsert()/create()/update()UpsertCharacterResult—{ id, name? }ListCharactersParams—{ projectId?, archived?, limit? }DuplicateCharacterInput—{ nodeId?, projectId? }GenerateCharacterInput— body forgenerate()GenerateCharacterResult—{ jobId, jobIds[] }GenerateAssetInput,GenerateMotionInput— bodies for asset / motion generationApprovePortraitResult—{ portraitUrl, canonicalDescription: string | null }RecaptionResult—{ canonicalDescription }
Locations
Location— full location record (camelCase)LocationDetail—Locationplus in-flightpendingJobs+ completedpreviousCandidatesbucketsLocationReferencePhoto,LocationReferencePhotoKind— mood-board reference shapesCreateLocationInput,UpdateLocationInput— bodies forcreate()/update()UpdateLocationResult—{ id, updatedAt }ListLocationsParams— query params forlist()GenerateLocationInput,GenerateLocationResult— body + response forgenerate()GenerateLocationAssetInput— body forgenerateAsset()GenerateSurroundContinuationInput— body forgenerateSurroundContinuation()ApproveMainImageResult—{ ..., canonicalDescription: string | null }RecaptionLocationResult—{ canonicalDescription }LocationAssetType— asset-bucket enum (re-exported alongsideLOCATION_ASSET_TYPESruntime tuple)LocationAttachColumn— attach-column enum (re-exported alongsideLOCATION_ATTACH_COLUMNSruntime tuple)
Objects
Object— full object record (camelCase). Re-exportable asNodaroObjectto avoid shadowing the JS global.ObjectDetail—Objectplus in-flightpendingJobsbucket.ObjectCategory— 10-value enum:"furniture" | "vehicle" | "weapon" | "food" | "clothing" | "electronics" | "nature" | "tool" | "animal" | "other". Distinct from location’s geography-based set.ObjectReferencePhoto,ObjectReferencePhotoKind—kindis one of"front" | "side" | "detail" | "context" | "moodBoard" | "other"(6 values; no PII consent unlike locations).ObjectAssetType— 5-value enum from@nodaro/shared:"angles" | "materials" | "variations" | "motion" | "custom". Re-exported from@nodaro/sdkso consumers don’t need a second dep.ObjectAttachColumn— 4-value enum from@nodaro/shared:"angles" | "materials" | "variations" | "motion_clips". Re-exported alongsideOBJECT_ATTACH_COLUMNSruntime tuple.ObjectAspectRatio— 5-value enum:"1:1" | "3:4" | "16:9" | "9:16" | "4:3". Re-exported alongsideOBJECT_ASPECT_OPTIONS/OBJECT_ASPECT_DEFAULTSruntime tuples. Distinct fromCharacterAspectRatiobecause objects support an extra4:3framing for product-showcase shots.CreateObjectInput,UpdateObjectInput,UpsertObjectInput— bodies forcreate()/update()/upsert().expectedUpdatedAtlives onUpdateObjectInput+UpsertObjectInput.UpdateObjectResult,UpsertObjectResult—{ id }(create) or{ id, updatedAt }(update).ListObjectsParams—{ archived?, projectId? }.GenerateObjectInput,GenerateObjectResult— body + response (always{ jobIds: string[] };jobId?is a deprecatedcount === 1alias).GenerateObjectAssetInput,GenerateObjectAssetResult—{ jobId }.GenerateObjectMotionInput,GenerateObjectMotionResult—{ jobId }.aspectRatiofield isObjectAspectRatio(5-value union).ApproveObjectMainImageResult—{ sourceImageUrl, canonicalDescription: string | null }(the wire sends""on LLM sub-failure but the SDK normalizes""→nullbefore returning).RecaptionObjectResult—{ canonicalDescription }.
Pipelines
PipelineRecord— pipeline state:{ id, status, current_stage, spent_credits, reserved_credits, upfront_credit_estimate, branched_from_pipeline_id, branched_from_stage, mode, failure_reason, current_progress_message }PipelineStatus,PipelineMode,PipelineStageName,SubGateName,ChatEnabledStage— re-exported from@nodaro/sharedPipelineInput— body forcreate(), re-exported from@nodaro/sharedPendingApproval—{ stage_name: PipelineStageName; output: unknown }PipelineTimeline—{ fps, width, height, scenes, musicUrl?, narrationUrl?, animateProgress? }BranchPipelineInput—{ fromStage: PipelineStageName }BranchPipelineResult—{ pipelineId, clonedStages, clonedEntities }ChatTurn— one persisted turn:{ id, turn_n, role, content, proposed_change, llm_call_id, applied_to_attempt_id, created_at }ChatStageResult— assistant reply:{ turnId, role: "assistant", content, proposed_change }ApplyChatProposalResult—{ applied: true; attemptId; newOutput } | { applied: false; error: { code, detail? } }ProposedChange— discriminated union re-exported from@nodaro/shared
Reduce
ReduceStrategyId— union of reduction-strategy slugsReduceMeta— per-reduction metadataReduceInput— body forreduce()ReduceResult— reduction response
Prompt helper
AnalyzeInput,AnalyzeResult— body + response foranalyze()GenerateInput— body forgenerate()EnhanceInput— body forenhance()PromptResult— shared prompt response shapeWizardQuestion,WizardOption,WizardSelection— prompt-wizard Q&A shapes (re-exported from@nodaro/shared)RecommendedModel— wizard model recommendation (re-exported from@nodaro/shared)WizardNodeContext— node context passed into the wizard (re-exported from@nodaro/shared)
Voices
Voice— premade ElevenLabs voice record, re-exported from@nodaro/sharedVoiceClone— user clone record (elevenlabsVoiceIdis the TTS-time id), re-exported from@nodaro/sharedVoiceLibraryParams— query params forsearchLibrary(), re-exported from@nodaro/sharedVoiceLibraryResponse—{ voices: Voice[]; hasMore: boolean; ... }, re-exported from@nodaro/sharedVoiceChangerProInput— fullrecast()input (see its section above)VoiceChangerProVoice— oneorderedVoicesentry: voice id string or per-voice settings objectVcpAnalyzeInput—analyze()inputVcpAnalysis/VcpAnalysisSpeaker— an analyze result, reshaped forrecast({ analysis })VcpExportInput/VcpExportTrack—exportMix()input / one mix laneVoiceDesignInput,VoiceRemixInput,DubbingInput— inputs fordesign()/remix()/dub()AudioFxPreset— the fx preset union used byvoiceFx.presetandaudio.applyFx, re-exported from@nodaro/shared
Media & audio
VideoMetadata—media.videoMetadata()result (best-effort probe fields)DownloadVideoProgress— onemedia.downloadVideoProgress()event:{ phase, percent, videoUrl?, thumbnailUrl?, error? }MediaProcessInput,MediaProcessResult—media.process()input / stored-file resultTranscribeProvider— the enabledaudio.transcribe()provider enum ("elevenlabs-stt"), re-exported from@nodaro/sharedTranscribeWord— one word of anaudio.transcribe()result:{ text, startMs, endMs, speaker? }in MILLISECONDS, structurally thecaptions[]entrymedia.addCaptions()takesTranscribeJobOutput— a completed transcribe job’soutput_data:{ text, language?, words?, json?, segments? }(words/jsonin ms,segmentsin SECONDS)
Credits
UserBalance— full balance + tier record (seebalance()table above)ModelCostsResult—{ data: Record<string, number>; missing: string[]; errors: string[] }
Uploads
UploadResult—{ url, assetId, thumbnailUrl, category, filename, mimeType, sizeBytes, r2Key }(seeupload()table above)
Developer apps
DeveloperApp— app record (without secret)DeveloperAppScope— union of valid scope stringsDeveloperAppStatus—"active" | "suspended" | "pending_review"CreateDeveloperAppInput,UpdateDeveloperAppInputCreateDeveloperAppResult—DeveloperApp & { clientSecret }RotateSecretResult—{ clientSecret }
OAuth
ExchangeCodeInput—{ client_id, client_secret, code, redirect_uri }AccessTokenResponse—{ access_token, token_type, scope, expires_in }OAuthAppInfo— public app metadata for consent screens
Apps
PublishedApp— published-app list/summary recordPublishedAppDetail— full published-app record with input schemaListAppsParams— query params forlist()ListAppsResult— paginatedlist()responseAppRunResult— response from running an appRunAppOptions— third argument torun():{ inputOverrides?: Record<string, Record<string, unknown>> }AppRun— a single app-run recordListAppRunsParams— query params forlistRuns()DeleteAppRunResult—{ success }-style delete response
Community
CommunityCard— a public community listing (shared character/location/object)CommunityEntityType—"character" | "location" | "object"CommunitySort—"newest" | "popular"CommunityReportReason— acceptedreport()reasonsBrowseCommunityParams— query params forbrowse()BrowseCommunityResult— paginatedbrowse()responseCloneListingResult—{ entityType, id }fromclone()FavoriteListingResult—{ favorited }fromfavorite()ReportListingResult—{ ok }fromreport()
Picker catalogs
PickerCatalogSummary— one row ofpickerCatalogs.list():{ nodeType, label, catalogId, kind, valueField?, fields?, optionCount }PickerCatalog— full catalog frompickerCatalogs.get(): single-dim carriesoptions, multi-dim carriesdimensionsPickerOption— one selectable id:{ id, label, description?, category?, promptHint?, term?, icon? }(promptHintpresent only withdetail: "full";term— the short professional term to inject,labelbeing display-only — present at both detail levels)PickerDimension— a multi-dim field:{ field, label, options }GetPickerCatalogOptions—get()options:{ detail?, category?, field? }
Generic node/edge
Re-exported from @nodaro/shared for convenience:
GenericNode— React Flow-compatible node shape used byWorkflow.nodesGenericEdge— React Flow-compatible edge shape used byWorkflow.edges
See also
- SDK Quickstart — task-oriented walkthrough
- OAuth Flow — third-party app authorization-code flow
- API Integration — direct REST patterns
Editable 3D scenes
client.scene3d provides named generate, edit, and render methods, plus
generateAndWait, editAndWait, and renderAndWait. They submit the same nodes
shown below. render exports the supplied revision without an authoring call.
Use await client.scene3d.capabilities() to discover optional Advanced engines;
advanced: null means they are unavailable. Explicit engines that are unavailable
are rejected before a Basic generation or its credit checks.
nodes.run and nodes.runAndWait accept typed GenerateScene3DParams, EditScene3DParams and RenderScene3DParams. Generation/edit completion returns Scene3DJobOutput with scenePlan and an optional changeSummary.
GenerateScene3DParams.inputAssets and a Pro prompt source’s inputAssets
accept up to eight Scene3DInputAsset selectors:
{id, revisionId, assetId, label?}. They select authorized existing GLBs while
references continues to carry appearance images and motion videos. This
requires an advanced engine with import support; Basic and unsupported imports
are refused before charging. The server supplies byte receipts. Do not send
asset URLs or hashes. Reuse identical selectors when submitting a Pro quote.
const created = await client.nodes.runAndWait("generate-3d-scene", {
prompt: "Orbit a single box on a floor over four seconds",
durationSeconds: 4, fps: 24, aspectRatio: "16:9",
});
const edited = await client.nodes.runAndWait("edit-3d-scene", {
scenePlan: created.scenePlan,
expectedRevisionId: created.scenePlan.revisionId,
operations: [{ op: "set-camera", changes: { focalLengthMm: 50 } }],
});
const clip = await client.nodes.runAndWait("render-video", {
planType: "3d-scene", plan: edited.scenePlan,
});
Scene authoring uses /v1/3d-scene/generate and /v1/3d-scene/edit. Typed composition rendering uses /v1/render-video/plan; legacy template renders retain /v1/render-video. For image/video conditioning, pass references with explicit appearance/layout/motion roles. See Generate 3D Scene.
A 3D scene render is priced by the plan’s own width/height: 50 credits
up to 1920 px on the longest side, 75 above that up to 5.12 megapixels, and
125 for a larger frame (identifiers render-video,
render-video:3d-large, render-video:3d-xlarge — read the instance’s current
numbers from the model-cost API). See
what a 3D scene render costs.
SDK versions with the generic nodes.run(type, params) overload can use the same node names without typed Scene3D overloads. The server accepts their node-slug generate/edit paths and dispatches render-video requests carrying planType to the composition renderer. For an interactive preview, check the generate node’s scene3d-embed-v1 capability and use the 3D preview embed; it does not require a copy of the renderer or any authentication tokens in its messages.
3D Render Pro
client.scene3d.quotePro(params), client.scene3d.runPro(params, options?) and
client.scene3d.renderProAndWait(params, options?) drive the one-operation
node: a source goes in, and a single job settles with BOTH scenePlan (the
exact composition) and videoUrl (the exported MP4), plus the revision, poster,
shotStills, validation and renderer metadata. A run that AUTHORED also reports
its own account of the answer — metadata.summary, repairPasses,
admissionRetries, mechanicalPasses, restoredAssertions and any
SCENE_AUTHORING_ASSUMPTION warnings (see below), plus
metadata.review when the scene was delivered without the visual reviewer’s
approval — because it refused, or because it gave no usable verdict at all.
nodes.run("pro-3d-render", …) and
nodes.runAndWait("pro-3d-render", …) reach the same routes with the same
typed Pro3DRenderRunParams / Pro3DRenderJobOutput.
source is a strict discriminated union: {kind:"prompt", prompt, references?}
authors a new scene; {kind:"scene", revisionId, sourceJobId} with no
editPrompt is a render-only export that costs no authoring (adding one revises
first); {kind:"local-export", exportId, connectionId} uses a paired desktop.
sourceJobId is required for Basic scenes retained only in job history and
optional for retained revisions, which use current scene permissions.
const caps = await client.scene3d.capabilities();
if (caps.pro?.available) {
const shot = await client.scene3d.renderProAndWait({
source: {
kind: "prompt",
prompt: "A red suitcase rolls behind a central pillar and reappears",
references: [{ id: "look", kind: "image", role: "appearance", url: appearanceImageUrl }],
},
durationSeconds: 30, fps: 24, aspectRatio: "21:9", maxRepairPasses: 2,
acceptedSceneSchemaVersions: [2],
});
shot.videoUrl; // the MP4
shot.sceneRevisionId; // re-export it later, render-only, for no authoring charge
shot.shotStills; // one still per shot, ordered by shotIndex — see below
}
shotStills is { shotIndex, frame, assetId, url }[], ordered by shotIndex
and produced by the same run at no extra credit cost: one still per shot of the
composition, each at the frame that shot opens on (shotIndex is 0-based in the
composition’s shot order; frame is zero-based in the composition’s frame
space). A v1 single-shot scene yields exactly one, at frame 0. The field is
optional — a result rendered before it existed simply has none, so read it as
shot.shotStills ?? [] rather than assuming it is there:
for (const still of shot.shotStills ?? []) {
const bytes = await fetch(still.url, { headers: { Authorization: `Bearer ${token}` } });
// …then use the bytes, or re-host them somewhere your own pipeline can read.
}
Each url is an authenticated endpoint on this install
(GET /v1/3d-scene/deliveries/{jobId}/assets/{assetId}), not a public CDN
link: a delivery’s artifacts stay in the private scene bucket, and reads are
re-authorized against the delivery’s workflow on every request. Fetch it with
the same credentials you used to run the job — dropping the URL into a plain
<img> tag, or handing it to a third-party service to fetch yourself, gets a
401.
You can still USE a still as a model reference: pass the URL as you read it (in
referenceImageUrls, or by wiring the node’s stills handle in a workflow) and
the platform grants that run its own short-lived read of that one artifact, in
your name, at dispatch time. The grant expires minutes later and nothing durable
is created — so keep the authenticated URL, not the grant, in anything you store.
renderProAndWait quotes the identical body first when params carries no
quoteId, so the run is admitted against a hash of exactly what was priced.
To show the ceiling before committing, quote explicitly:
const quote = await client.scene3d.quotePro(params); // reserves nothing
// ...show quote.maxCredits and quote.breakdown...
await client.scene3d.runPro({ ...params, quoteId: quote.quoteId });
Both run methods send an Idempotency-Key — a fresh one per call, or your own
via options.idempotencyKey, which you should reuse when retrying a submit that
timed out. nodes.run / nodes.runAndWait accept the same option for any node.
What the run says about its own answer
These fields report what the authoring run assumed and did. Read every one as optional: an install without an advanced engine, and a result produced before these existed, simply has none.
const summary = shot.metadata?.summary; // what the planner says it authored
const repairs = shot.repairPasses; // 0 when accepted first time
const retries = shot.admissionRetries; // pre-build planner retries
const mechanical = shot.mechanicalPasses; // own allowance, apart from repairPasses
const restored = shot.restoredAssertions; // assertions put back after an answer re-shaped them
const assumptions = (shot.validation?.warnings ?? [])
.filter((w) => w.code === "SCENE_AUTHORING_ASSUMPTION")
.map((w) => w.message);
SCENE_AUTHORING_ASSUMPTION is an authoring caveat — the brief did not say, so
the run decided — carrying the planner’s assumption with any normalization the
engine applied to it. Codes are open-ended: treat an unrecognized one as
informational rather than an error.
repairPasses counts repairs, never authoring passes, so 0 means “accepted
first time”, not “never authored”. admissionRetries counts something else and
is never folded into it: a recipe the compiler would not admit, re-asked of the
planner with no build and no repair pass spent.
mechanicalPasses is counted apart from repairPasses too, for the same
kind of reason: when a mandatory finding that refused a build carries the
compiler’s own structured remedy, the engine applies that remedy and rebuilds
with no planner call — and those passes buy their own quoted allowance (a
mechanical line, up to 2, released when unspent) instead of spending one of
your repairs. So the two counts are independent, and a run may legitimately
report more mechanical passes than repairs. Each adds a REMEDY_AUTO_APPLIED
warning naming the assertion that refused the build, the change applied, and the
measurement before it.
There is one exception, and the quote is what discriminates it: a run quoted
before that allowance existed carries no mechanical quote line, and there the
pass charged a repair, making the count a subset of repairPasses. The result
reports the same field either way, so read the quote you were given rather than
deriving the accounting from the two numbers.
restoredAssertions lists mandatory assertions the engine put back after a
planner answer re-shaped one the feedback had not named — each {op, path,
value?, assertionId, reason}, and each also an ASSERTION_RESTORED warning. A
repair is invited to change what the feedback names; an assertion outside that
invitation is restored to its last admitted form and the run carries on, rather
than being refused over a value the engine already held.
A render-only export authored nothing and omits the counts entirely rather than
reporting 0; it also has no summary. Absent is not 0 for mechanicalPasses
in particular: an engine that does not report it looks the same as a run that
took none, which is not a claim that the planner authored every repair.
The same fields appear on a generateAndWait result when an advanced engine
authored the scene. The deterministic Basic lane asks no model and carries none
of them.
A delivery the visual reviewer did not approve
A completed result may reach you without the visual reviewer’s approval, in
two ways. The video is real and the credits committed in both; verdict says
which, and it is a discriminated union, so a switch gets a compile-time answer.
"refused"— the repair budget was spent, every mandatory check passed, and the reviewer still objected, so the scene was delivered with the refusal attached."unavailable"— the review produced no usable verdict.reasonsays why:"provider"when it never reached its provider,"unusable"when the provider answered with nothing usable (the full list isSCENE3D_REVIEW_UNAVAILABLE_REASONS). A repair cannot answer either, so the run asks the review once more and, if there is still no usable verdict, delivers the assertion-passing scene unreviewed.attemptssays how many times it was asked. Nobody judged this scene.
import { scene3DReviewNote, scene3DReviewVerdictOf } from "@nodaro/shared";
const review = scene3DReviewVerdictOf(shot);
if (review) {
// One user-safe sentence for either verdict. Prefer it over writing your own:
// "the reviewer refused this scene" is an invented opinion when nobody looked.
console.log(scene3DReviewNote(review));
if (review.verdict === "unavailable") {
// review.reason: "provider" (never reached) | "unusable" (answered with nothing usable)
console.log(`unreviewed (${review.reason}) after ${review.attempts} attempts`);
}
for (const objection of review.objections) {
// category, what, correction?, frames[]
console.log(objection.category, objection.what, objection.correction);
}
}
Each objection is { category, what, correction?, frames } — what is the
finding, correction the recipe-level change it asked for where it named one.
There is no severity: only blocking findings become objections. observed is
the reviewer’s account of what it found correct, never a substitute for an
objection. Every objection also arrives as a SCENE_REVIEW_REFUSED entry in
validation.warnings[], tagged with a shotId where its cited frames fall
inside one shot; on the "unavailable" arm a SCENE_REVIEW_UNAVAILABLE entry
leads that array.
Three readings that look right and are not, which is why the helpers exist:
validation.statusis still"passed". The mandatory checks did pass — that is why the scene was delivered rather than withheld.objectionsmay be empty. A refusal that named nothing actionable is still a refusal, so countingSCENE_REVIEW_REFUSEDwarnings misses it.- objections under an
"unavailable"verdict are not the verdict. A review is batched, and those are whichever batches answered usably before one did not, or before the provider went away. An empty list there is silence, not approval.
A visual refusal on its own no longer fails the job. SCENE_QUALITY_FAILED now
means a mandatory check failed or the compiler refused the recipe; the
SCENE_QUALITY_* warning codes belong to that failed result, and its retained
draft is described in
3D Render Pro.
capabilities().pro reports which engines, quality profiles, styles and aspect
ratios this install can serve — offer controls from that, not from the full
vocabulary. An install without the engine answers
503 SCENE_CAPABILITY_UNAVAILABLE and never falls back to Basic authoring; one
with no configured price answers 503 price_not_configured before reserving
anything. There is no model, reasoning-effort or planner field: the planner is
fixed and server-owned. See
3D Render Pro.
Scene asset reads
client.scene3d.getDelivery(jobId) reads retained export metadata, including its
sourceKind, its exact source revision and the descriptors it pinned. Four kinds
appear: poster and validation-report on every delivery, shot-still once per
shot where the render produced a contact sheet (each carrying its own
shotIndex, frame, width and height), and — on a refused-authoring
delivery alone — source-json. For each descriptor, call
client.scene3d.deliveryAssetBytes(jobId, asset, { signal }) to read bounded
bytes through fresh authentication. Access requires both delivery and source
permissions, including after the source revision has been deleted. These methods
read already published delivery evidence; they do not start a render.
On a refused-authoring delivery — a 3D Render Pro run whose recipe the compiler
refused on every pass — sceneRevisionId and sourcePlanSha256 are null and
there is no poster, because nothing compiled and no frame was rendered. What that
delivery does carry is the refusal report and, where the run had one to keep, the
planner’s last admitted recipe:
const recipe = await client.scene3d.retainedRecipe(jobId);
// null when nothing was retained, or when you hold less than edit access.
retainedRecipe reads the delivery, finds the source-json descriptor and
parses its bytes. It answers null rather than throwing when there is nothing to
fetch. Three things to know:
- It needs edit access to the job’s workflow — the same access
sourceBytesneeds for a native.blend. A reader with less does not see the descriptor at all, which is why the answer isnullrather than an error. - The failed job says whether to ask.
validation.sourceRetainedon the failed row istruewhen a recipe was kept andfalsewhen no pass ever cleared admission. - It is evidence, not an input. A refused run published no revision, so there
is no
{ kind: "scene" }source to re-run it from; read it to see what was attempted and let it inform the next prompt. Reading costs no credits.
client.scene3d.assetBytes(revisionId, asset, { signal }) fetches a GLB, camera
track, poster or validation report through the authenticated API. Pass the exact
asset descriptor from that retained revision; the SDK caps decoded response bytes
at its declared length. The scene renderer additionally verifies the SHA-256
digest before parsing.
client.scene3d.sourceBytes(revisionId, { signal }) uses the separate native
source authorization endpoint. Both return an ArrayBuffer, use fresh credentials,
respect cancellation, and preserve typed API errors. A native source file is
available only when it represents that exact accepted revision.
Use client.scene3d.applyEdits(revisionId, { newRevisionId,
expectedContentHash, operations, lockedObjectIds? }) to persist deterministic
v2 edits without authoring. It returns { scenePlan, changeSummary }. Keep the
same newRevisionId for retries of the same edit. Adopt the returned scene only
if the user is still editing the request’s base revision. Geometry and camera
assets are reused; posters, validation and native downloads are attached again
only after being regenerated for the new revision.
Character Motion metadata
Character Motion catalog options include optional motion metadata at both compact and full detail. It carries authored requires, startPose/endPose, endVisibility, handsAfter, needsFreeHands, kind, fixedPace, counterpart, search aliases, and deprecated/replacementId. Missing fields mean unknown. Preserve retired IDs when loading saved workflows; hide them from new choices. See Character Motion for composition, naming, review and advisory-diagnostic behavior. client.pickerCatalogs.get("character-motion") exposes this as PickerOption.motion; the structural type is CharacterMotionMetadata from @nodaro/shared.