NodaroAccount
Nodaro Docs
DocumentationNode ReferenceModelsTemplatesAI Agents (MCP)DevelopersSelf-hostingResearchCompare
TypeScript SDK

Workflows and projects

Create, update, share, export, import and run Nodaro workflows from TypeScript with client.workflows, and organize them in projects with client.projects.

client.workflows reads, writes, shares and runs Nodaro workflows, and client.projects manages the projects that hold them. client.sessions groups single generations into sessions inside a project. A workflow is a canvas of connected nodes, and every workflow belongs to one project. The methods call the same endpoints as the Workflows REST API. See Workflows and projects for the concepts.

Methods

MethodWhat it does
workflows.list(params)List the workflows of a project, without their graphs
workflows.get(id)Read one workflow with its nodes, connections and settings
workflows.getPublic(id)Read a workflow shared by link, without a token
workflows.create(input)Create a workflow in a project
workflows.update(id, input)Change any fields of a workflow
workflows.delete(id)Delete a workflow
workflows.run(id, params?)Start a run of the workflow
workflows.renderFinal(id, params)Render the final of an Apply EDL render after its Preview was reviewed
workflows.estimateRenderFinal(id, params)Quote that Render final before you start it
workflows.export(workflowId, opts?)Export a workflow as a portable JSON bundle
workflows.import(input)Import a bundle into a project
workflows.setVisibility(id, visibility)Make a workflow private or visible to its workspace
workflows.move(id, params)Move a workflow to another project
workflows.sharedWithMe()List workflows other people shared with you
workflows.listMcpConversations(params?)List your MCP conversations of one month
workflows.collaborators.*List, add, change and remove the people a workflow is shared with
projects.list() and moreList, read, create, update and delete projects
sessions.create(input) and moreGeneration sessions inside a project, and the jobs they hold

client.workflows

list(params)

Lists the workflows in a project. The list returns metadata only: nodes, edges, settings and sourcePrompt are left out. Read one workflow with get() for its full graph.

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

Prop

Type

const { data: workflows } = await client.workflows.list({ projectId })
for (const wf of workflows) console.log(wf.id, wf.name, wf.updatedAt)

Throws NotFoundError when the project is not visible to you.

get(id)

Reads one workflow, including its full nodes, edges and settings.

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

Prop

Type

const { data: wf } = await client.workflows.get(workflowId)
console.log(`${wf.name}: ${wf.nodes?.length ?? 0} nodes, version ${wf.version}`)

Keep version if you plan to update the workflow: it makes the update safe against other writers.

getPublic(id)

Reads a workflow that its owner shared by link (GET /v1/public/workflows/:id). No token is needed. The workflow is returned only while its sharing is switched on; otherwise the method throws NotFoundError. A UGC video comes back to anyone but the owner as a published app ships it: its UGC nodes, and every node made from them, without their results, and never the owner's photo. On a deployment that does not allow public work (outputs.allowPublic: false), the method throws NotFoundError for everyone except the workflow's signed-in owner, as for a workflow that is not shared.

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

Prop

Type

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

When the auth provider returns no token, the request is sent without one.

A shared studio production comes back without its owner's working state. The recycle bin, the runs in progress, the empty media slots and the unsaved editor draft are left out of settings.studio.

create(input)

Creates a workflow in a project and returns the full record. Only projectId and name are needed; the other fields take server defaults.

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

Prop

Type

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

To get a ready-made graph, export a workflow you built in the editor and read its nodes and edges, or clone a template.

update(id, input)

Changes any subset of a workflow's fields and returns the full updated record (PATCH /v1/workflows/:id). Fields you leave out stay as they are.

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

Prop

Type

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

Safe updates. Without expectedVersion or expectedUpdatedAt, the last write wins. With one of them, the update applies only if nobody changed the workflow since you read it. Otherwise it throws WorkflowConflictError, whose currentRecord holds the current workflow so you can merge without another read:

import { WorkflowConflictError } from "@nodaro/sdk"

try {
  await client.workflows.update(workflowId, { settings, expectedVersion: wf.version })
} catch (err) {
  if (err instanceof WorkflowConflictError && err.currentRecord) {
    const merged = mergeSettings(err.currentRecord.settings, settings)
    await client.workflows.update(workflowId, {
      settings: merged,
      expectedVersion: err.currentVersion,
    })
  } else {
    throw err
  }
}

Run-state values on node data, such as the running status or the current job id, are removed by the server and never saved.

delete(id)

Deletes a workflow.

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

Prop

Type

await client.workflows.delete(workflowId)

Throws NotFoundError when the id does not exist or is not yours, so a delete never fails silently.

run(id, params?)

Starts a run of the workflow and returns at once with { executionId, status }. The run continues on the server. Poll client.executions.get() for its progress and result.

run(id: string, params?: {
  nodeIds?: string[]
  inputOverrides?: Record<string, Record<string, unknown>>
  continueFromExecutionId?: string
  continueMode?: "from-failed"
}, options?: { idempotencyKey?: string }): Promise<{ executionId: string; status: "pending" | "running" }>

Prop

Type

const { executionId } = await client.workflows.run(workflowId, {
  nodeIds: ["text-prompt-1", "image-gen-2"],
})
const { data: execution } = await client.executions.get(executionId)
  • Throws InsufficientCreditsError when the account cannot cover the run's highest possible cost.
  • While a run of this workflow is still under way, a new one throws an AlreadyRunningError (409 already_running). Its executionId is the run to follow.
  • An OAuth token needs the workflows:execute scope.
  • To run a workflow in a workspace, call run on a client from client.withWorkspace().

Continue an earlier run. With continueFromExecutionId, only the nodes in nodeIds run. Every other node hands on what the earlier run produced, never the workflow's saved results. An Edit Plan hands on its plan with your current review applied. The inputOverrides of the earlier run apply again, and the ones you send now apply over them, field by field.

await client.workflows.run(workflowId, {
  nodeIds: [renderNodeId, captionsNodeId],
  inputOverrides: { [renderNodeId]: { quality: "final" } },
  continueFromExecutionId: previewRunId,
})

A refused continuation throws before any run exists. The earlier run is not found: NotFoundError with the code continuation_not_found. Otherwise a NodaroError whose code is continuation_subset_required (no nodeIds), continuation_workflow_mismatch, continuation_version_mismatch or continuation_not_completed.

Continue a failed run. With continueMode: "from-failed", continueFromExecutionId is your own failed run of the workflow, and the run continues from where it stopped. Only the nodes that run did not complete run: what it completed is handed on, never run or charged again. A node that failed runs again in full. nodeIds is optional: without it, the run is continued on the nodes it was started on, plus every node left failed or held back. client.executions.get(id) names the nodes the first run was started on in nodeScope (null means every node). Every id in nodeIds counts as named: a node of work that no run of the chain ever ran runs and is priced. A node that ran in any run of the chain is never run again by naming it. A source or upload, a trigger, a picker or a Group or Collect container hands on what the run handed on, and a node set to skip never runs. Send an idempotencyKey, so a retry of the same call starts nothing new.

await client.workflows.run(
  workflowId,
  { continueFromExecutionId: failedRunId, continueMode: "from-failed" },
  { idempotencyKey: crypto.randomUUID() }, // reuse the same key when you retry this call
)
  • A run that a newer continuation supersedes throws a NodaroError with the code continuation_superseded. Its message names that continuation: continue it instead, or find its results there when it completed everything.
  • When the newest continuation stopped at a Preview review, the call throws a ContinuationPreviewPendingError (code continuation_preview_pending, exported from @nodaro/sdk/errors). Its next is the run that finishes the review: send client.workflows.run(workflowId, error.next) first, then continue.
  • A run that did not fail throws continuation_not_failed. A run with nothing left throws continuation_subset_required.
  • A continuation that your credits cannot cover throws InsufficientCreditsError before any run exists.

See Continue a failed run for the full rules.

On an install where a Preview stops the run for a review, a run of a workflow whose Apply EDL render is set to Preview is refused with 400 preview_review_required, because nobody reviews an SDK run. Set the render to Final for that run with inputOverrides: { [renderNodeId]: { quality: "final" } }. On Nodaro Cloud, a run does not stop at a Preview, so this refusal does not happen.

renderFinal(id, params)

Renders the final of an Apply EDL render that produced a Preview, once you reviewed it. It runs what the editor's Render final button runs: the render at Final for this run only, then every node after it. The node keeps its own Quality. When Camera Switch sits between the Edit Plan and the render, Camera Switch runs again first.

renderFinal(id: string, params: { renderNodeId: string; continueFromExecutionId: string }): Promise<{ executionId: string; status: "pending" | "running" }>

Prop

Type

The server works out which nodes run, by the editor's own rule, so you send only the render and the earlier run. Every node that does not run hands on what the earlier run produced.

const { executionId } = await client.workflows.renderFinal(workflowId, {
  renderNodeId,
  continueFromExecutionId: previewRunId,
})
  • A node id that does not exist is refused with render_final_node_not_found, and a node that is not an Apply EDL render with render_final_not_a_render. The continuation codes of run() apply too.
  • A run that the payer cannot cover throws InsufficientCreditsError (402) before any run exists, with required and available.
  • An OAuth token needs the workflows:execute scope.

estimateRenderFinal(id, params)

Quotes a renderFinal() and creates nothing. It takes the same parameters.

estimateRenderFinal(id: string, params: { renderNodeId: string; continueFromExecutionId: string }): Promise<{ data: RenderFinalQuote }>
const { data: quote } = await client.workflows.estimateRenderFinal(workflowId, {
  renderNodeId,
  continueFromExecutionId: previewRunId,
})
if (quote.sufficient) {
  await client.workflows.renderFinal(workflowId, { renderNodeId, continueFromExecutionId: previewRunId })
}

A RenderFinalQuote has renderNodeId, nodeIds (the nodes that will run), inputOverrides (the override it runs with), estimatedCredits, holdCredits, sufficient and available. holdCredits is what the run holds while it renders: a UGC clip at its hold, about twice its price, and never below the estimate. sufficient says whether the payer can cover that hold, and available is the payer's spendable credits. available is null when a workspace budget pays. The figures are null in an edition without credits. When the run executes an LLM node, the quote adds estimatedCreditsIsEstimate: true: that node is charged its actual usage, so show the figure as ≈N.

export(workflowId, opts?)

Exports the saved version of a workflow as a portable JSON bundle, the file format that the editor's Export menu writes. With assets: true, the bundle also carries the assets the workflow uses, like With Assets in the editor.

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

Prop

Type

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

Portability. A bundle can only point at media another instance can fetch. When nodes use URLs that other instances cannot reach, such as a self-hosted install's own storage on localhost, a local network address or an .internal name, the bundle lists them under portability.unreachableMedia:

bundle.portability?.unreachableMedia
// [{ nodeId: "n1", nodeLabel: "Video URL", field: "videoUrl", url: "http://localhost:3000/storage/..." }]

The field is absent when every media URL is public. Such a bundle still imports, but those nodes cannot run on the other instance until the media is uploaded there again. See Import and export.

import(input)

Imports an exported bundle into one of your projects and returns the new workflow.

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

Prop

Type

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

What the import does:

  • Assets are recreated. Characters, objects, creatures and locations in the bundle become new items in your account. The workflow is re-pointed at them, in its asset nodes, in every @ mention in node data, and in the workflow's settings.
  • Media is copied. Media on other hosts is copied into this instance's storage when it can be reached, so the workflow does not depend on someone else's server. The limits are 25 files for the workflow's media and 25 more for the bundled assets, with images up to 20 MB and video or audio up to 50 MB.
  • Copies count against your storage. When your storage runs out, the workflow still lands, and the assets that did not fit are listed in the report.

The importReport says what happened:

importReport
// {
//   rehosted: 3,                                    // files copied to this instance
//   unreachable: [{ nodeId, nodeLabel, field, url }], // private hosts, left as they were
//   skipped: [{ nodeId, field, url, reason: "HTTP 404" }],
//   assetIdMap: { "<bundled asset id>": "<new asset id>" },
//   assetsSkipped: [{ kind: "character", id, name: "Kira", reason: "Storage limit exceeded" }],
// }

assetIdMap is present whenever the bundle carried assets. The server has already updated every mention inside the workflow. Use the map only for references you keep outside it. assetsSkipped appears only when something was left out.

setVisibility(id, visibility)

Makes a workflow "private", visible to its creator and the collaborators you add, or "workspace", visible to everyone in its workspace.

setVisibility(id: string, visibility: "private" | "workspace"): Promise<{ data: Workflow }>

Prop

Type

await client.workflows.setVisibility(workflowId, "workspace")

Only the creator or a workspace admin may change it; anyone else gets ForbiddenError. Workspaces exist in organizations on Nodaro Cloud.

move(id, params)

Moves a workflow to another project (POST /v1/workflows/:id/move). The workflow leaves its folder.

move(id: string, params: { projectId: string }): Promise<{
  data: Workflow
  droppedCollaborators: { userId: string; name: string | null }[]
}>

Prop

Type

const { droppedCollaborators } = await client.workflows.move(workflowId, { projectId: archiveProjectId })

When the move takes the workflow out of a workspace, the access that came from that workspace ends. Those people are returned in droppedCollaborators.

sharedWithMe()

Lists workflows other people shared with you directly. Work in a workspace you belong to is not included, because it already appears in that workspace's own lists. Each workflow carries the grantedRole you hold on it.

sharedWithMe(): Promise<{ data: (Workflow & { grantedRole: "viewer" | "editor" })[] }>
const { data: shared } = await client.workflows.sharedWithMe()
for (const wf of shared) console.log(wf.name, wf.grantedRole)

The list is empty on instances without organizations.

listMcpConversations(params?)

Lists your MCP conversations of one month, newest first, through GET /v1/mcp-conversations. Each conversation is a workflow that an assistant built from its steps. See MCP conversations become workflows.

listMcpConversations(params?: { month?: string; sources?: string[] }): Promise<McpConversationList>
  • month is "YYYY-MM". The default is the latest month that has conversations.
  • sources keeps only some sources, for example "claude", "claude-code" or "chatgpt".

The result carries month, months (every month that has conversations, with its count), sources (the month's sources, with the connections each one joins) and data, an McpConversation[]. A conversation is a workflow: open it with get(conversation.id).

const { data, months } = await client.workflows.listMcpConversations({ month: "2026-10", sources: ["claude-code"] })

collaborators

The people a workflow is shared with, reached as client.workflows.collaborators. These methods exist on instances with organizations; elsewhere they throw NotFoundError.

collaborators.list(workflowId: string): Promise<{ data: Collaborator[] }>
collaborators.add(workflowId: string, input: { userId?: string; email?: string; role: "viewer" | "editor" }): Promise<{ data: { userId: string; role: "viewer" | "editor" } }>
collaborators.update(workflowId: string, userId: string, input: { role: "viewer" | "editor" }): Promise<{ data: { userId: string; role: "viewer" | "editor" } }>
collaborators.remove(workflowId: string, userId: string): Promise<{ success: true }>

Prop

Type

await client.workflows.collaborators.add(workflowId, { email: "editor@example.com", role: "editor" })

const { data: people } = await client.workflows.collaborators.list(workflowId)
// [{ userId, name, avatar, role }]: email addresses are never returned

A collaborator can call remove() with their own user id to leave a workflow.

client.projects

A project groups workflows. Every workflow belongs to exactly one project.

projects.list()

Lists your projects.

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

projects.get(id)

Reads one project.

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

Prop

Type

const { data: project } = await client.projects.get(projectId)

projects.create(input)

Creates a project.

create(input: { name: string; description?: string; settings?: Record<string, unknown> }): Promise<{ data: Project }>

Prop

Type

const { data: project } = await client.projects.create({ name: "Spring campaign" })

projects.update(id, input)

Changes a project. Pass at least one field.

update(id: string, input: { name?: string; description?: string; settings?: Record<string, unknown> }): Promise<{ data: Project }>

Prop

Type

await client.projects.update(projectId, { description: "Assets for the spring launch" })

projects.delete(id)

Deletes a project.

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

Prop

Type

await client.projects.delete(projectId)

client.sessions

A generation session groups single generations inside a project, the same on every device. A project holds its sessions, and each session holds its items. A session's project is fixed when you create it. Each item is a link to one of your jobs, not a copy.

const { data: session } = await client.sessions.create({ projectId, tool: "create-image", title: "Beach" })

// A job joins a session when the generation request names it.
await client.nodes.runAndWait("generate-image", {
  prompt: "a red car",
  sessionId: session.id,
  origin: "create-image",
})

const { data: sessions, next } = await client.sessions.list({ projectId })
const { data: items } = await client.sessions.items(session.id)
MethodSignatureNotes
createcreate(input: CreateSessionInput): Promise<{ data: GenerationSession }>tool is required, for example create-image. projectId and title are optional.
listlist(params?: ListSessionsParams): Promise<SessionPage<GenerationSession>>Newest first. Filters: projectId ("none" for the sessions with no project), tool, includeArchived, limit, cursor. Each session carries itemCount (live items), removedCount (items in the Trash), coverJobId and up to four previewUrls (the cover's image first when one is set).
getget(id: string): Promise<{ data: GenerationSession }>One session.
updateupdate(id: string, input: UpdateSessionInput): Promise<{ data: GenerationSession }>title (null makes it untitled), archived and coverJobId (one of the session's items, 404 otherwise; null means automatic). The project cannot change.
deletedelete(id: string): Promise<{ success: true }>Deletes the session. Its jobs are kept, and stay in every other session that holds them.
itemsitems(id: string, params?: ListSessionItemsParams): Promise<SessionPage<SessionItem>>The live feed, newest first. Each item has jobId, addedAt, removedAt, imported, importedFrom and job. { removed: true } lists the items removed from the session instead, its Trash, newest removal first.
importItemsimportItems(id: string, input: { jobIds: string[] }): Promise<{ data: { imported: number; requested: number } }>Links up to 100 of your live jobs into the session. It is all or nothing: one job that is not yours fails the call with 404. An item removed from the session comes back where it was.
moveItemsmoveItems(targetId: string, input: MoveSessionItemsInput): Promise<{ data: { moved: number } }>{ jobIds, fromSessionId }, in one transaction. The target gets the items, and the source keeps them as removed items, so restoreItem(fromSessionId, jobId) undoes the move. It is all or nothing: 404 when any id is not a live item of the source.
removeItemremoveItem(id: string, jobId: string, options?: { forever?: boolean }): Promise<{ success: true }>Sends the item to the session's Trash until it is restored. Nothing is cleaned up automatically. The job, and every other session that holds it, stay. { forever: true } deletes an item that is already removed for good, and throws a 409 NodaroError not_in_trash on a live one.
restoreItemrestoreItem(id: string, jobId: string): Promise<{ success: true }>Brings a removed item back into the session, where it was.

A session's project is fixed, so moving items into a session of another project is how they change project: call moveItems() on a session there. An item that no session holds any more shows in General: client.jobs.list({ inSession: false }).

client.trash

The Trash holds the items you removed from sessions and the jobs you deleted. A creation tool shows it as one entry beside General. See The Trash.

list(params?: ListTrashParams): Promise<TrashPage> // GET /v1/trash

interface ListTrashParams {
  projectId?: string | "none" // a project, or the sessions with no project and General
  tool?: string               // what a session of this tool holds or held
  type?: string | string[]    // input_data.type, like jobs.list
  limit?: number              // 1–100 (default 50)
  cursor?: string             // the previous page's next
}

type TrashEntry =
  | { kind: "removed"; removedAt: string; session: TrashSession; job: Job }
  | { kind: "deleted"; deletedAt: string; purgeAt: string; job: Job }

The list is newest first. A removed item stays until sessions.restoreItem(session.id, job.id) or sessions.removeItem(session.id, job.id, { forever: true }). A deleted job stays until purgeAt, 30 days after deletedAt, and is then deleted for good. A copy saved to the Library stays. jobs.restore(job.id) brings it back into every session it was in, and jobs.delete(job.id, { forever: true }) deletes it now. A removed item whose job you also deleted is listed once, as the deleted job. A job no session ever held has no tool, so tool leaves it out: pass type, with your tool's job types, to include it.

const { data: trash } = await client.trash.list({ projectId, type: ["generate-image", "image-edit"] })
for (const entry of trash) {
  if (entry.kind === "deleted") await client.jobs.restore(entry.job.id)
  else await client.sessions.restoreItem(entry.session.id, entry.job.id)
}

Every request that creates a job accepts sessionId and origin, and the platform records them on the job. They are typed on every generation params type, such as GenerateImageParams, GenerateVideoParams and TextToSpeechParams, and on the generate* inputs of characters, objects, locations and creatures. origin must be a slug: lowercase letters, digits and dashes, at most 40 characters. Any other value fails with 400 validation_error. A job's session_id shows on client.jobs.get() and client.jobs.list().

Types

TypeShape
Workflowid, projectId, userId, name, description?, folderId?, version?, thumbnailUrl?, nodes?, edges?, settings?, sourcePrompt?, createdAt, updatedAt. The graph fields are present on full records only.
RunWorkflowParamsnodeIds?, inputOverrides?, continueFromExecutionId?
RenderFinalParamsrenderNodeId, continueFromExecutionId
RenderFinalQuoterenderNodeId, nodeIds, inputOverrides, estimatedCredits, estimatedCreditsIsEstimate?, holdCredits, sufficient, available
Projectid, userId, name, description?, settings?, createdAt, updatedAt
GenerationSession, SessionItem, SessionPageA generation session, one of its items, and a page of either
CollaboratoruserId, name?, avatar?, role
WorkflowExportThe portable bundle that export() returns and import() accepts
GenericNode, GenericEdgeThe node and connection shapes used in nodes and edges

All of them are exported from @nodaro/sdk. See Types.

Frequently asked questions

How do I run a Nodaro workflow from code?

Call client.workflows.run(workflowId). It returns an executionId right away. Poll client.executions.get(executionId) until the status is completed, failed, cancelled or timed_out.

Can I run only some nodes of a workflow?

Yes. Pass an object with nodeIds, the list of node ids to run, as the second argument of client.workflows.run. Only those nodes run.

How do I render the final of a Preview from code?

Call client.workflows.renderFinal with the Apply EDL render's node id and the id of the run that produced the Preview. It runs the render at Final and every node after it. client.workflows.estimateRenderFinal quotes it first.

How do I avoid overwriting someone else's changes?

Pass expectedVersion, the version you read, to client.workflows.update. When the workflow changed in the meantime, the call throws WorkflowConflictError with the current record, so you can merge and retry.

How do I copy a workflow to another Nodaro instance?

Export it with client.workflows.export and the assets option set to true, then import the bundle on the other instance with client.workflows.import and a projectId. Reachable media is copied to the new instance's storage.

Last updated on

On this page