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:

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:

Specific subclasses add fields:

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:

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:

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

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