SDK Quickstart
@nodaro/sdk is a typed REST client for Nodaro. It works in Node, browsers,
and React Native — anywhere fetch exists. This walkthrough goes from “install”
to running a workflow end-to-end.
If you just want a method-by-method index, see the SDK Reference. For the OAuth consent flow, see OAuth Flow.
1. Install + first request
npm install @nodaro/sdk
The simplest possible call uses client.nodes.list() — it requires no scopes
and works on any reachable Nodaro instance:
import { createClient, StaticTokenAuth } from "@nodaro/sdk"
const client = createClient({
baseUrl: "https://nodaro.example.com",
auth: new StaticTokenAuth(process.env.NODARO_ACCESS_TOKEN!),
})
const { data: nodes } = await client.nodes.list()
console.log(`${nodes.length} node types available`)
baseUrl is your Nodaro server (use "" for same-origin in a browser app).
auth is one of three providers, covered next.
2. Three auth modes
The client treats auth as a pluggable strategy. Every request calls
auth.getToken() and sends Authorization: Bearer <token> if a token is
returned.
StaticTokenAuth — server-side, fixed token
Use when you have a token that doesn’t change for the lifetime of the process:
- An OAuth access token your server obtained via the authorization-code flow
- A user API token (prefixed
ndr_...) - A developer-app token (prefixed
ndr_app_...)
import { createClient, StaticTokenAuth } from "@nodaro/sdk"
const client = createClient({
baseUrl: "https://nodaro.example.com",
auth: new StaticTokenAuth(process.env.NODARO_ACCESS_TOKEN!),
})
Header sent: Authorization: Bearer <your token>.
supabaseAuth — browser app, live Supabase JWT
Use when your frontend talks to a Nodaro instance that shares the same Supabase project (the included editor uses this mode). The JWT is pulled live from the Supabase v2 client on every request, so token refresh is automatic.
import { createClient, supabaseAuth } from "@nodaro/sdk"
import { createClient as createSupabase } from "@supabase/supabase-js"
const supabase = createSupabase(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_ANON_KEY,
)
const client = createClient({
baseUrl: import.meta.env.VITE_API_URL ?? "",
auth: supabaseAuth(supabase),
})
Header sent: Authorization: Bearer <Supabase access_token>. Falls back to no
header (anonymous) when no session exists.
CallbackAuth — bring-your-own logic
Use for refresh-token rotation, custom session stores, or anything else that needs to compute the token on demand:
import { createClient, CallbackAuth } from "@nodaro/sdk"
const client = createClient({
baseUrl: "https://nodaro.example.com",
auth: new CallbackAuth(async () => {
const session = await mySessionStore.read()
if (Date.now() > session.expiresAt - 60_000) {
await refresh(session)
}
return session.accessToken
}),
})
The callback can be sync or async, and may return null to skip the header
(anonymous request).
3. Error handling
Every resource method throws a typed error subclass on a non-2xx response. Catch
the most specific class first and NodaroError last as a catch-all:
import {
NodaroError,
UnauthorizedError,
ForbiddenError,
NotFoundError,
RateLimitedError,
InsufficientCreditsError,
StorageExceededError,
} from "@nodaro/sdk"
try {
await client.workflows.run(workflowId)
} catch (err) {
if (err instanceof UnauthorizedError) {
// 401 — token expired or invalid. Re-auth and retry.
redirectToLogin()
} else if (err instanceof ForbiddenError) {
if (err.missingScope === "workflows:execute") {
// 403 with insufficient_scope — request consent for additional scopes.
requestAdditionalScopes(["workflows:execute"])
} else {
// 403 — permission denied for some other reason (RLS, edition gate, etc.)
showError("Permission denied.")
}
} else if (err instanceof InsufficientCreditsError) {
// 402 — show paywall with required vs available.
showCreditPaywall({ required: err.required, available: err.available })
} else if (err instanceof RateLimitedError) {
// 429 — apply exponential backoff and retry.
await new Promise(r => setTimeout(r, 2_000))
return retry()
} else if (err instanceof StorageExceededError) {
// 413 — user is over quota. err.limitBytes contains the cap.
showError(`Storage limit (${err.limitBytes} bytes) exceeded.`)
} else if (err instanceof NotFoundError) {
// 404 — resource doesn't exist or isn't visible to this caller.
showError("Not found.")
} else if (err instanceof NodaroError) {
// Any other API error. Log .code and .status for debugging.
console.error(`API error ${err.status} (${err.code}): ${err.message}`)
} else {
// Network failure, AbortError, etc. — not a Nodaro response error.
throw err
}
}
All Nodaro errors expose:
err.message: stringerr.code: string— stable error slug ("unauthorized","insufficient_credits", etc.)err.status: number— HTTP status
Specific subclasses add fields:
ForbiddenError.missingScope?: string— set whencode === "insufficient_scope"InsufficientCreditsError.required?: number/.available?: numberStorageExceededError.limitBytes?: number
4. Common workflows
Run a workflow and poll for completion
workflows.run() returns immediately with an executionId. Poll
executions.get() until the status is terminal:
import { createClient, StaticTokenAuth } from "@nodaro/sdk"
const client = createClient({
baseUrl: "https://nodaro.example.com",
auth: new StaticTokenAuth(process.env.NODARO_ACCESS_TOKEN!),
})
const exec = await client.workflows.run(workflowId)
console.log(`Started execution ${exec.executionId}`)
while (true) {
const { data } = await client.executions.get(exec.executionId)
console.log(`${data.completedNodes}/${data.totalNodes} nodes done`)
if (
data.status === "completed" ||
data.status === "failed" ||
data.status === "cancelled" ||
data.status === "timed_out"
) {
if (data.status !== "completed") {
throw new Error(`Execution ${data.status}: ${data.errorMessage ?? "no error message"}`)
}
console.log(`Done. Used ${data.totalCreditsUsed} credits.`)
break
}
await new Promise(r => setTimeout(r, 2_000))
}
You can also run only a subset of nodes by passing nodeIds:
const exec = await client.workflows.run(workflowId, {
nodeIds: ["text-prompt-1", "image-gen-2"],
})
To stop a long-running execution, use executions.cancel(). The optional
mode: "after_current" lets in-flight nodes finish before stopping; the default
cancels immediately:
await client.executions.cancel(exec.executionId, { mode: "after_current" })
Run a workflow as a third-party app (OAuth)
The OAuth code exchange and consent UI are out of scope here — they’re
documented in OAuth Flow. Once your server has an
access_token, the call site is identical to the example above:
new StaticTokenAuth(accessToken).
Create a character end-to-end
Character Studio’s full pipeline is scriptable through client.characters.
The typical flow: create the row, generate portrait candidates, pick one,
then layer expression / pose / motion assets on top.
import { createClient, StaticTokenAuth } from "@nodaro/sdk"
const client = createClient({
baseUrl: "https://nodaro.example.com",
auth: new StaticTokenAuth(process.env.NODARO_ACCESS_TOKEN!),
})
// 1. Create the character row.
const { id: characterId } = await client.characters.create({
nodeId: "scripted",
name: "Kira",
description: "young protagonist with auburn hair",
style: "realistic",
seedPrompt: "kira portrait, warm natural lighting",
})
// 2. Generate 4 portrait candidates, auto-attaching to the row.
const { jobIds } = await client.characters.generate({
name: "Kira",
seedPrompt: "kira portrait, warm natural lighting",
count: 4,
attachToCharacterId: characterId,
})
// 3. Wait for all 4 to complete, then pick a candidate.
for (const jobId of jobIds) {
while (true) {
const { data: job } = await client.jobs.get(jobId)
if (job.status === "completed" || job.status === "failed") break
await new Promise(r => setTimeout(r, 3_000))
}
}
// 4. Approve the first candidate (the LLM caption fires inline).
const { portraitUrl, canonicalDescription } =
await client.characters.approvePortrait(characterId, jobIds[0])
// 5. Layer a smile expression on top.
await client.characters.generateAsset({
name: "Kira",
assetType: "expressions",
variant: "smile",
attachToCharacterId: characterId,
attachToColumn: "expressions",
attachName: "smile",
})
// 6. Animate the portrait into a motion clip.
await client.characters.generateMotion({
name: "Kira",
motionPrompt: "slow head turn left, soft smile",
provider: "kling",
attachToCharacterId: characterId,
attachName: "head turn",
})
// 7. Re-fetch — the character now has a portrait, a smile expression,
// and a motion clip ready to use as a reference in any subsequent
// generate-image / generate-video call.
const character = await client.characters.get(characterId)
console.log({
portrait: character.sourceImageUrl,
canonicalDescription: character.canonicalDescription,
expressions: character.expressions,
motions: character.motions,
})
For the full surface (including the soft-delete + archive flow, character duplication, and how to wire character assets into downstream prompts) see the dedicated Character Platform guide.
Create an object end-to-end
Object Studio’s full pipeline is scriptable through client.objects. The
typical flow: create the row, generate main-image candidates, pick one,
then layer angle / material / variation / motion assets on top.
import { createClient, StaticTokenAuth } from "@nodaro/sdk"
const client = createClient({
baseUrl: "https://nodaro.example.com",
auth: new StaticTokenAuth(process.env.NODARO_ACCESS_TOKEN!),
})
// 1. Create the object row.
const { id: objectId } = await client.objects.create({
nodeId: "scripted",
name: "Antique Lantern",
description: "Weathered brass lantern with hand-engraved filigree",
category: "tool",
style: "realistic",
})
// 2. Generate 4 candidate main images (deferred attach — we pick after).
const result = await client.objects.generate({
name: "Antique Lantern",
count: 4,
})
// `jobIds` is always present — one entry per candidate (synthesized even from
// legacy single-job responses). `jobId` is a deprecated count===1 alias.
const { jobIds } = result
// 3. Wait for all 4 to complete, then pick a candidate.
for (const jobId of jobIds) {
while (true) {
const { data: job } = await client.jobs.get(jobId)
if (job.status === "completed" || job.status === "failed") break
await new Promise(r => setTimeout(r, 3_000))
}
}
// 4. Approve the first candidate (the LLM caption fires inline).
const { sourceImageUrl, canonicalDescription } =
await client.objects.approveMainImage(objectId, jobIds[0])
// 5. Layer a "gold" materials variant on top — auto-attaches on completion.
await client.objects.generateAsset({
name: "Antique Lantern",
assetType: "materials",
variant: "gold",
attachToObjectId: objectId,
attachToColumn: "materials",
attachName: "gold",
})
// 6. Animate the main image into a motion clip.
// Defaults: provider="kling-turbo", aspectRatio="1:1" (product-showcase).
await client.objects.generateMotion({
name: "Antique Lantern",
motionPrompt: "slow 360 rotation, soft golden rim light",
sourceImageUrl,
attachToObjectId: objectId,
attachName: "rotate-360",
})
// 7. Re-fetch — the object now has a main image, a gold materials
// variant, and a motion clip ready to use as a reference in any
// subsequent generate-image / generate-video call.
const object = await client.objects.get(objectId)
console.log({
mainImage: object.sourceImageUrl,
canonicalDescription: object.canonicalDescription,
materials: object.materials,
motionClips: object.motionClips,
})
For the full surface (including the 13-method SDK, the soft-delete + archive flow, the upstream picker integration, and how to wire object assets into downstream prompts) see the dedicated Object Platform guide.
Pick the best of N generations (Reduce / fan-in)
client.reduce.run() runs the Reduce node
programmatically. Useful when you’ve generated several candidates and want
to pick the best, concatenate them, vote, or merge as JSON — without
building a workflow on the canvas.
// Pick the best of 5 generated images
const result = await client.reduce.run({
strategyId: "pick-best-llm",
strategyConfig: {
criteria: "sharpest image with no artifacts",
inputKind: "image-url",
},
inputs: [
"https://r2.nodaro.ai/.../1.jpg",
"https://r2.nodaro.ai/.../2.jpg",
"https://r2.nodaro.ai/.../3.jpg",
"https://r2.nodaro.ai/.../4.jpg",
"https://r2.nodaro.ai/.../5.jpg",
],
})
console.log(result.output) // chosen URL
console.log(result.meta.selectedIndex) // 0-4
console.log(result.meta.reasoning) // LLM rationale
Other strategies don’t need an LLM:
// Concatenate survivors with a custom separator
const joined = await client.reduce.run({
strategyId: "concat",
strategyConfig: { separator: "\n---\n" },
inputs: ["A", "B", "C"],
})
// Majority vote (ties → first)
const winner = await client.reduce.run({
strategyId: "vote",
inputs: ["red", "blue", "red", "red", "blue"],
})
// Deep-merge JSON fragments into one object
const merged = await client.reduce.run({
strategyId: "merge-json",
strategyConfig: { strategy: "deep" },
inputs: [
JSON.stringify({ a: 1, nested: { x: 1 } }),
JSON.stringify({ b: 2, nested: { y: 2 } }),
],
})
console.log(JSON.parse(merged.output))
// { a: 1, b: 2, nested: { x: 1, y: 2 } }
The full set of strategies is: pick-best-llm, concat, first-non-empty,
count, vote, merge-json. If every input is empty / whitespace the
server returns a 400 (code: "no_valid_inputs") which surfaces as a
NodaroError subclass.
Improve a prompt with the Prompt Wizard
client.promptHelper is AI assistance for writing prompts for generation
nodes. The quickest path is enhance() — a one-shot “improve this prompt”:
const { prompt } = await client.promptHelper.enhance({ nodeType: "generate-image", prompt: "snow leopard on a rock" })
console.log(prompt)
For a guided flow, analyze() returns questions you answer, then generate()
builds the final prompt from those selections. See
SDK Reference → client.promptHelper.
Discover available nodes
nodes.list() enumerates every node type the server supports, with category,
output type, credit cost, and supported providers. Useful for building a
node palette UI or filtering by capability:
const { data: nodes } = await client.nodes.list()
const imageGenerators = nodes.filter(n => n.category === "ai-image")
const veoCapable = nodes.filter(n => n.providers?.includes("veo"))
const referenceImageNodes = nodes.filter(n =>
n.capabilities?.includes("supports-reference-image"),
)
// Get a single descriptor by type slug:
const { data: nanoBanana } = await client.nodes.get("generate-image")
console.log(`Cost: ${nanoBanana.creditCost}`)
5. Type safety
@nodaro/sdk re-exports types for every response shape and input. Common
imports:
import type {
Workflow,
Project,
Job,
WorkflowExecution,
NodeDescriptor,
DeveloperApp,
AccessTokenResponse,
} from "@nodaro/sdk"
const { data }: { data: Workflow } = await client.workflows.get(id)
// ^? Workflow with full nodes/edges/settings
The client uses generic request<T>() internally and casts the JSON response
to the resource type, so TypeScript autocomplete works on every field of
data. Snake_case vs camelCase follows the wire format — Job is snake_case
(created_at, output_data) because that’s what the server returns;
Workflow and WorkflowExecution are camelCase.
6. Browser vs Node
@nodaro/sdk has zero dependencies and uses only fetch and URL. Both
are global in:
- Node 18 or newer
- All modern browsers
- React Native (built-in fetch polyfill)
- Cloudflare Workers, Deno, Bun
No fetch polyfill needed. If you’re on Node 16 or older, install
undici and pass fetch: undici.fetch (see section 7).
CORS for browser apps: when calling Nodaro from a browser using an OAuth
access token, your origin must be on the developer app’s allowedOrigins
allowlist. See OAuth Flow for the consent + allowlist
mechanics. Browser apps using supabaseAuth are unaffected — those JWTs are
checked against Supabase, not the developer-app allowlist.
7. Custom fetch and timeout
Two optional createClient options cover most edge cases:
import { createClient, StaticTokenAuth } from "@nodaro/sdk"
const client = createClient({
baseUrl: "https://nodaro.example.com",
auth: new StaticTokenAuth(process.env.NODARO_ACCESS_TOKEN!),
fetch: customFetch, // any fetch-compatible function
timeoutMs: 120_000, // default is 60_000
})
fetch is useful for:
- Tests — pass a mock that returns canned
Responseobjects - Retries — wrap the global fetch in a retry-on-5xx helper
- Telemetry — wrap with OpenTelemetry / Datadog tracing
- Older Node — pass
undici.fetchto use undici on Node 16
timeoutMs aborts the request via AbortController after the given milliseconds.
Long-running operations like video generation should use workflows.run() plus
polling rather than waiting for a single HTTP response — most generation
workflows take longer than any reasonable HTTP timeout.
Next steps
- SDK Reference — every public export with signature + example
- OAuth Flow — third-party app consent + token exchange
- API Integration — direct REST patterns without the SDK
- Architecture — how Nodaro executes workflows server-side
Claude Code plugin
Developing with Claude Code? One install adds the Nodaro SDK skill (client patterns, models, credits, OAuth) and the hosted MCP connection:
/plugin marketplace add nodaroai/app.nodaro.ai
/plugin install nodaro