Server Architecture

NodeTool runs a single Fastify HTTP + WebSocket server (@nodetool-ai/websocket — packages/websocket/src/server.ts). The same process serves:

  • REST routes under /api/* (workflows, assets, storage, and the surfaces in the matrix below). Jobs, models, and settings are tRPC procedures, not REST. See tRPC Procedures.
  • OpenAI-compatible /v1/chat/completions and /v1/models.
  • WebSocket endpoints for workflow execution, chat, the browser extension, and downloads.
  • An MCP endpoint at /mcp and its OAuth routes. In production /mcp registers only with NODETOOL_ENABLE_MCP=1.
  • Health and liveness probes.
  • The built web app, when the server finds one, for every GET outside /api, /ws, /v1, /trpc, and /mcp.

Start it with nodetool serve (default 127.0.0.1:7777). serve accepts only --host and --port — there is no --mode flag.

Endpoint Matrix

For detailed schemas, see Chat API and Workflow API.

Area Path Method / Protocol Auth Streaming Notes
Models /v1/models GET Session auth no OpenAI-compatible model listing. A fixed list, not a live catalog
Chat /v1/chat/completions POST Session auth SSE when "stream": true OpenAI-compatible chat; SSE or single JSON
Workflows /api/workflows GET, POST Session auth no GET lists the caller’s workflows with graphs (limit, cursor, run_mode, project_id). POST creates one from name and graph
Workflows /api/workflows/{id} GET, PUT, DELETE Session auth no Read, save, or delete one workflow. PUT creates it when the id is new and takes expected_updated_at for optimistic saves. DELETE answers 204
Workflows /api/workflows/{id}/run POST Session auth no Run a workflow once, return final outputs as one JSON response
Workflows /api/workflows/{id}/debug POST Session auth no Same body as run. Returns the debug report: per-node summary and a verdict
Workflows /api/workflows/names GET Session auth no {id: name} for the caller’s workflows (up to 1000)
Workflows /api/workflows/tools GET Session auth no Workflows saved with run_mode: "tool", as {name, tool_name, description}
Workflows /api/workflows/{id}/dsl-export GET Session auth no Graph as TypeScript DSL source (text/plain)
Workflows /api/workflows/{id}/export-bundle GET Session auth no One workflow and its assets as a .nodetool zip
Workflows /api/workflows/export-bundle POST Session auth no Several workflows in one .nodetool zip, by workflow_ids
Workflows /api/workflows/import-bundle POST Session auth no Import a .nodetool zip into the caller’s library
Workflows /api/debug/sessions/{id} GET Session auth no State of an interactive run: the escalation it is parked on, or its final report
Workflows /api/debug/sessions/{id}/verdict POST Session auth no Answer the parked escalation, then wait for the next one or the final report
Workflows /api/debug/sessions/{id}/cancel POST Session auth no Cancel the run and return its final report
SDK /api/sdk/v1/workflows/{id}/interface GET Session auth no One workflow’s input and output pins; ?version=1 is required
Workflows /api/workflows/public GET none no Workflows the owner marked access: "public"
Workflows /api/workflows/public/{id} GET none no One public workflow; 404 when it is not public
Examples /api/workflows/examples GET none no Shipped example templates — metadata only, graph is empty
Examples /api/workflows/examples/search GET none no Same list filtered by ?query= over name, description, tags
Examples /api/workflows/examples/thumbnails/{filename} GET none no Example thumbnail; .jpg and .png only
Examples /api/workflows/examples/{package}/{example} GET none no One example with its full graph, unlike the list; 404 when the package has no example by that name
Triggers /api/webhooks/{token} POST x-webhook-secret header (no session) no Deliver an event to a webhook trigger registration; wakes the workflow without waiting for the next poll
Integrations /api/integrations/{provider}/link/start POST NODETOOL_INTEGRATION_TOKEN bearer (no session) no Mint a one-time link code and the URL that redeems it; 10-minute TTL
Integrations /api/integrations/{provider}/link/complete POST NODETOOL_INTEGRATION_TOKEN bearer (no session) no Redeem a link code, binding the external account to a NodeTool user
Integrations /api/integrations/{provider}/token POST NODETOOL_INTEGRATION_TOKEN bearer (no session) no Exchange a linked external id for a one-hour delegated user token; 409 in local single-user mode
Integrations /api/integrations/{provider}/link DELETE NODETOOL_INTEGRATION_TOKEN bearer (no session) no Unlink an external account; {"unlinked": false} when it was not linked
MCP OAuth /.well-known/oauth-protected-resource GET none no RFC 9728 resource metadata for /mcp; 404 unless the flow is enabled. The path-inserted form …/mcp returns the same document
MCP OAuth /.well-known/oauth-authorization-server GET none no RFC 8414 authorization-server metadata — the endpoint URLs a client discovers; 404 unless the flow is enabled
MCP OAuth /oauth/authorize GET none (PKCE) no Park an authorization request and 302 to the consent page /oauth/consent?request_id=…
MCP OAuth /oauth/token POST none (PKCE) no Exchange an authorization code or refresh token for an nta_ access token
MCP OAuth /oauth/register POST none no RFC 7591 dynamic client registration
MCP OAuth /oauth/revoke POST none no RFC 7009 revocation of an access or refresh token
Nodes /api/nodes/metadata GET none no The node registry the editor loads at boot; slim summaries by default, one node’s full metadata with ?node_type=
Workspaces /api/workspaces/{id}/download/{path} GET Session auth streaming One file out of a workspace as an attachment; 403 in production unless the workspace is the server-managed one
Assets /api/assets POST Session auth no Create an asset. Multipart with a file part and an optional json part for name, content_type, parent_id, workflow_id, node_id, job_id, project_id, metadata. Cloud backends upload through assets.createUpload instead, see Uploading an Asset
Assets /api/assets/{id}/extract-audio POST Session auth no Extract a video asset’s audio track into a new WAV asset
Assets /api/assets/{id}/peaks GET Session auth no Waveform peaks of an audio or video asset, computed with ffmpeg and cached on disk
Assets /api/assets/packages/{package}/{file} GET none streaming Bytes behind a package:// ref, from a node pack’s assets directory
Assets /api/assets/packages GET none no Stub — always {"assets": [], "next": null}; there is no package listing
Assets /api/assets/packages/{package} GET none no Stub — same empty page. Fetch a package’s files by name, not by listing
Assets /api/assets/download POST Session auth no Bulk ZIP download; 501 on this server
Apps /api/applications GET, POST Session auth no List the caller’s apps (project_id filter) or create one
Apps /api/applications/{id} GET, PUT, PATCH, DELETE Session auth no Read, update, or delete one app
Apps /api/applications/{id}/released-document GET Session auth no The snapshot a published app should run, with each operation’s pinned graph; null when nothing is published
Apps /api/applications/{id}/export-bundle GET Session auth no One app and the full graph of every workflow it binds, as a downloadable ApplicationBundle
Apps /api/applications/{id}/export-project GET Session auth no One app as a standalone JavaScript project (Vite UI plus a Node server that runs its workflows and scripts), as a .zip
Apps /api/applications/import-bundle POST Session auth no Create an app, and the workflows it binds, from an ApplicationBundle
Apps /api/applications/build POST Session auth no Build a mini app from a prompt or a pinned spec; returns the BuildReport. poll: true returns a session id instead
Apps /api/applications/spec POST Session auth no Run only the build’s Spec stage on a prompt; returns {spec, issues}. poll: true returns a session id instead
Apps /api/applications/debug POST Session auth no Simulate a saved app by application_id, or a draft posted inline as document; returns the compacted debug report
Apps /api/applications/examples GET Session auth no The shipped example apps — slug, name, description, workflow names, operation count, thumbnail URL
Apps /api/applications/examples/{slug} GET Session auth no One example’s full ApplicationBundle; 404 when the slug names nothing shipped
Apps /api/applications/examples/{slug}/install POST Session auth no Install an example into the caller’s library, creating the workflows it binds
Apps /api/applications/examples/{slug}/scripts/{key}/run POST Session auth NDJSON with Accept: application/x-ndjson Run a JS script an example bundle carries, without installing the example
Storyboards /api/storyboards/{id}/export-zip GET Session auth streaming One board as a zip of Markdown plus its stills and clips; 404 when the caller does not own it
Games /api/games/{id}/export-zip GET Session auth streaming A game’s published build as a zip: target=web (default) or target=tauri; 404 when the caller does not own it
Games /api/games/{id}/build-executable POST Session auth NDJSON Compile the desktop app project with cargo on this machine; 403 on a cloud profile, 503 without cargo
Timelines /api/timelines/{id}/export-zip GET Session auth streaming One sequence and the bytes of every asset its clips name, as a zip; 404 when the caller does not own it
Timelines /api/timelines/{id}/export-premiere GET Session auth streaming One sequence as a Premiere Pro XML (xmeml) plus its media and an export report, as a zip; 404 when the caller does not own it
Timelines /api/timelines/{id}/export-media GET Session auth streaming Every clip’s media file plus clips.csv, as a zip; 404 when the caller does not own it
Timelines /api/timelines/import-zip POST Session auth no Multipart upload of such a zip, or of a zip holding a Premiere Pro XML and its media; stores the assets and creates a new timeline pointing at them
Providers /api/fal/credits GET Session auth no The server’s fal.ai account balance; 204 when no FAL_API_KEY is configured
Providers /api/fal/pricing GET Session auth no Unit price per fal.ai endpoint, one or more ?endpoint_id=; cached an hour
Providers /api/fal/pricing/estimate POST Session auth no What a fal.ai endpoint costs for a given quantity; 204 when no FAL_API_KEY is configured
Providers /api/kie/credits GET Session auth no The server’s kie.ai credit balance; 204 when no KIE_API_KEY is configured
Providers /api/kie/pricing GET Session auth no Credit price per kie.ai model, one or more ?model_id=; cached an hour
Providers /api/kie/resolve-dynamic-schema POST Session auth no Pasted kie.ai model docs to a node’s dynamic properties, inputs, and outputs
Providers /api/comfy/resolve-workflow POST Session auth no A ComfyUI API-format workflow (workflow) or exported PNG (png_base64) to the Comfy runners’ dynamic inputs, outputs, and exposable params
JS Scripts /api/js-scripts/{id}/run POST Session auth no Run a saved JS script document in the sandbox and return its outputs
SDK /api/sdk/v1/capabilities GET Session auth no What this server supports — profiles, encodings, execution options, limits
SDK /api/sdk/v1/node-types GET Session auth no Paged inventory of the pin types in the registry and which nodes use them
SDK /api/sdk/v1/workflows GET Session auth no Workflow summaries with the revision an interface was read at
SDK /api/sdk/v1/workflow-interfaces POST Session auth no Up to 100 workflows’ interfaces in one call, with per-workflow errors
SDK /api/sdk/v1/preflight POST Session auth no Whether a workflow is runnable, what it needs, and what it will cost
SDK /api/sdk/v1/assets/temporary POST Session auth no Store one execution input in temporary storage; creates no asset row
SDK /api/sdk/v1/models GET Session auth no Paged model catalog with per-model availability and the wire value a node property takes
SDK /api/sdk/v1/model-downloads GET Session auth no Snapshot of this caller’s model downloads, running and finished
SDK /api/sdk/v1/model-downloads POST Session auth no Start a model download; 202 with the operation’s first state
SDK /api/sdk/v1/model-downloads/cancel POST Session auth no Cancel one download by operation_id
Extension /api/extension/download GET Session auth no The built Chrome extension as a zip; 404 when the server has no build
Workflow WS /ws WebSocket Session auth. Bearer header or api_key query yes Workflow execution, chat, job control, live editor tools, and live updates (MessagePack or JSON)
Download WS /ws/download WebSocket Session auth. Off in production yes Model/file downloads
Storage /api/storage/* HEAD/GET Session auth streaming for GET Asset bytes at <userId>/<assetId>.<ext>, scoped to the caller. Read-only: writes and deletes go through the asset API (tRPC assets.delete removes an asset’s stored objects). storage.signUrl is the only tRPC storage procedure
Account /api/account/export GET An authenticated identity, even in Local mode no Everything this server holds about the caller, as one JSON download
Account /api/account DELETE An authenticated identity, even in Local mode no Erase the caller’s data. The body must be {"confirm": "DELETE MY ACCOUNT"}
Files /api/files/local GET, HEAD Session auth streaming One local file by absolute path, with Range support, for file:// previews. 403 when NODETOOL_ENV=production
OAuth /api/oauth/{provider}/* see Provider Sign-in Session auth, except the two callbacks no Hugging Face, GitHub, OpenAI, Claude, and Google credential flows
Collections /api/collections/{name}/index POST Session auth no Multipart upload of one text file into a vector collection. Other collection operations are tRPC
Documents /api/documents/extract-text POST Session auth no Extract text from an uploaded PDF or DOCX (25 MB cap). Stores nothing
Apps /api/apps/{token} GET none (the token is the credential) no A deployed mini app’s released document. Production only
Apps /api/apps/{token}/session POST none (the token is the credential) no Mint the short-lived nda_ session a deployed app’s visitor runs on. Production only
Workflows /api/shared-workflows/{token} GET none (the token is the credential) no A workflow’s name, description, tags, and graph behind a public share link
Timelines /api/timelines/{id}/track-object POST Session auth no Track a subject through a clip with the track_object capability
Timelines /api/timelines/{id}/bake-audio-animation POST Session auth no Turn an audio clip’s loudness or beats into keyframes on another clip
Timelines /api/timelines/{id}/isolate-subject POST Session auth no Cut a clip’s subject out with a matte model
Timelines /api/timelines/animations/bake POST Session auth no Run a custom animation’s JavaScript once and return keyframe curves
Timelines /api/assets/packages/timeline/fonts/{file} and .../google/{family}/css, .../google/{slug}/{file} GET none streaming Bundled timeline fonts, and Google Fonts CSS and files cached on the server
Providers /api/kie/webhook POST none no kie.ai task callback. Needs a taskId, answers {"status":"accepted"}
Providers /api/providers/fal/webhook/{token} POST Ed25519 signature headers (no session) no fal.ai queue callback, rate-limited to 120 per minute
Providers /api/providers/atlascloud/webhook POST Ed25519 signature header (no session) no AtlasCloud prediction callback, rate-limited to 120 per minute
Sandbox /api/sandbox-modules/* GET Session auth no Guest module source for the browser sandbox, by module id
MCP /mcp GET, POST, DELETE Session auth, or an ntk_ or nta_ token SSE MCP over HTTP. See MCP in production
Config /api/config GET none no How this server is configured, for a client that has not signed in yet: auth mode, Supabase URL and anon key, Google Workspace scopes, version
Admin /admin/secrets/import POST Session auth no Stub — always 501. Bulk secret import is not part of the standalone server
Health /health GET none no JSON: {status, timestamp, uptime, turns, jobs, services}. 503 when the database check fails or the server is draining
Health /api/health GET none no JSON: {version, uptime}
Liveness /ready GET none no Always 200 with {status:"ok"}

Session auth means the request must carry a credential unless Local mode trusts the caller. Rows marked none skip session auth. The server does not read an AUTH_PROVIDER variable. The mode comes from SUPABASE_URL and SUPABASE_KEY, as described in Authentication. The SDK discovery routes (capabilities, models, node-types, workflows, workflow-interfaces) also skip session auth unless the server enforces auth or the SDK authentication flag is set.

Authentication and Headers

The server picks one of two modes at startup. When both SUPABASE_URL and SUPABASE_KEY are set it runs in Supabase mode and validates a Supabase JWT on every request that is not public. Otherwise it runs in Local mode. Loopback callers are trusted as user 1 with no token, and other callers need an access token or an address listed in NODETOOL_TRUST_LOCAL_NETWORKS. The full rules are in Authentication.

Credentials a client can present:

Credential Prefix Where it works
Supabase JWT none Everything, in Supabase mode
Access token minted in Settings → MCP ntk_ Everything, in both modes
MCP OAuth access token nta_ /mcp only
Deployed app session nda_ /ws only, confined to one app
Delegated token from a messaging bridge ndt_ Everything, for the linked user. See Linking an External Messaging Account

How to include credentials

  • HTTP requests: Authorization: Bearer <token> header on all non-public routes
  • WebSocket: Authorization: Bearer <token> header (preferred) or api_key query parameter. The query parameter is read on WebSocket upgrades only
  • SSE streams (/v1/chat/completions): Authorization: Bearer <token>

Requests without a valid credential get 401. The public routes are listed in the matrix with none in the Auth column.

Local development: When running locally with the default config, loopback requests need no token. You can omit the Authorization header entirely.

See Authentication for full token handling rules.

Streaming Behavior

  • /v1/chat/completions uses OpenAI-style SSE when stream is true; otherwise it returns a single JSON response.
  • POST /api/workflows/{id}/run does not stream — it runs the workflow to completion and returns one JSON response.
  • The /ws WebSocket streams workflow/job events (job_update, node_update, node_progress, output_update, chunk, …) and chat tokens/tool calls. See the WebSocket API for the full protocol.
  • Storage routes stream file contents for large assets.

tRPC Procedures

The web app, the CLI, and the mobile app talk to the server over tRPC at /trpc. Procedures are plain JSON with no transformer. A query is GET /trpc/<router>.<procedure>?input=<url-encoded JSON>, and a mutation is POST /trpc/<router>.<procedure> with the input as the JSON body. Batching joins paths with commas (/trpc/a.x,b.y?batch=1, one input per index) and is capped at 20 calls per batch. Because long batched inputs overflow URLs behind reverse proxies, the server also accepts POST for queries. A result is {"result": {"data": ...}}.

An error carries the tRPC code and message, plus data.apiCode (an API error code such as NOT_FOUND or INVALID_INPUT) and data.zodError (field errors, or null). Every procedure needs a signed-in user except healthz, extension.status, nodes.replicateStatus, workflows.public.list, and workflows.public.get.

Procedures by router. Nested routers appear as a dotted path.

Router Queries Mutations
agentAccess mcpConnection, listTokens, getOauthRequest, listOauthGrants createToken, revokeToken, approveOauthRequest, denyOauthRequest, revokeOauthGrant
applications list, get, versions, released, releasedDocument, deployment, budget, usage, invocations create, update, delete, publish, deploy, undeploy, setBudget, beginInvocation, settleInvocation, release
assets list, get, externalImportConfig, recursive, search createUpload, finalizeUpload, createExternal, relinkExternal, ensureProxy, update, delete
codeGen — generate
collections list create, update, delete
costs dashboard —
credits status setPlan, topup
customProviders list save, delete, test
documents index —
extension status installHost
externalMcp list save, delete, probe
files list createFolder
fonts list —
games list, examples, get, getDraft, draftChanges, draftBeforeChange, revisions create, installExample, saveDraft, previewAuthoring, applyAuthoring, publish, restore, restoreDraft, installAsset, installCandidate
integrations list, describeLinkCode createLinkCode, confirmLink, unlink
jobs list, get, triggersRunning cancel, triggerStart, triggerStop
jsScripts list, palette, get, documentVersions.list, documentVersions.get create, update, delete, documentVersions.create, documentVersions.restore, documentVersions.delete
mcpConfig status install, uninstall
memories list, search delete
messages list —
models providers, recommended, recommendedImageTextToImage, recommendedImageImageToImage, recommendedLanguageEmbedding, recommendedAsr, recommendedTts, recommendedVideoTextToVideo, recommendedVideoImageToVideo, availableForKind, all, huggingfaceList, huggingfaceSearch, huggingfaceHubSearch, huggingfaceByType, transformersJsByType, transformersJsRecommended, ollama, llmByProvider, mediaOptions, imageByProvider, tts, ttsByProvider, music, musicByProvider, audioToAudio, audioToAudioByProvider, asr, asrByProvider, video, videoByProvider, embeddingByProvider huggingfaceDelete, ollamaDelete, huggingfaceCacheStatus, pullOllamaModel
nodes replicateStatus, list, get —
packs list, runtimeStatuses, getTrust, sandboxModules, sandboxPackageDocs, listBuiltins setTrust, setBuiltinEnabled, reload
projects list, archived, summaries, get, documents, restoreTabs, unassigned thread, create, update, archive, restore, delete, assignDocument, copyDocument
resources list, read create, update, delete
scripts list, get create, update, delete
segmentation — segment
settings secrets.list, secrets.get, history.get, list secrets.upsert, secrets.delete, secrets.validate, history.update, history.cleanup, history.compact, update
sketch list, get, versions.list, documentVersions.list, documentVersions.get create, update, delete, versions.append, versions.setFavorite, versions.delete, documentVersions.create, documentVersions.restore, documentVersions.delete, layers.create, layers.duplicate
skills list, get create, update, delete
storage signUrl —
storyboards list, get, examples create, update, delete, installExample, stylePresets
threads list, get create, update, delete, summarize
timeline examples, list, get, versions.list, versions.get, code.get installExample, create, update, delete, versions.create, versions.restore, versions.delete, code.set, code.rebake, code.detach, clips.create
triggers listByWorkflow fire
users list create, remove, resetToken
worker profiles.list, instances.list, apiKeyStatus, health profiles.create, profiles.delete, provision, stop, resume, terminate, stopAll, reconcile, attach, detach
workflows list, get, examples, recipes, public.list, public.get, terminalOutputs, versions.list, sharing.get, sharing.sharedWithMe create, update, delete, autosave, versions.create, versions.restore, versions.delete, sharing.createLink, sharing.revokeLink, sharing.setRole, sharing.removeCollaborator, sharing.accept, sharing.duplicatePublic
workspace list, listFiles, readFile create, update, delete, writeFile

healthz is a root-level query that returns {"ok": true}. Input and output shapes are the Zod schemas in packages/protocol/src/api-schemas/ and in the router files under packages/websocket/src/trpc/routers/. The AppRouter type exported by @nodetool-ai/websocket/trpc gives a typed client every shape.

Headless Mode: Running Workflows via CLI/API

NodeTool can run entirely without the UI—perfect for automation, CI/CD pipelines, and programmatic integrations. This section shows how to execute workflows from the command line or via HTTP requests.

Quick Start: Run a Workflow via cURL

# Run a workflow and get results (non-streaming)
curl -X POST "http://localhost:7777/api/workflows/YOUR_WORKFLOW_ID/run" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "params": {
      "prompt": "A cyberpunk cityscape at sunset",
      "style": "photorealistic"
    }
  }'

Response:

{
  "job_id": "job_abc123",
  "workflow_id": "YOUR_WORKFLOW_ID",
  "status": "completed",
  "outputs": {
    "image": {
      "type": "image",
      "uri": "http://localhost:7777/api/storage/assets/abc123.png"
    },
    "caption": "Generated image of a cyberpunk cityscape..."
  },
  "error": null,
  "message_count": 12,
  "background": false
}

outputs is an object keyed by output-node name. The route does not stream — for real-time progress, run the workflow over the WebSocket API.

Interactive Runs: Answering a Failed Node

By default a node that throws ends the run. Post "interactive": true to /api/workflows/{id}/run or /api/workflows/{id}/debug and the failure comes back to you instead: the run parks on the node and the response returns at once with the escalation. Your client decides what happens next, standing where the --supervise LLM supervisor otherwise would.

Reach for it when the caller (an agent, a test harness, a batch script) is the one that knows whether a given failure should be retried, skipped, or fatal.

curl -X POST "http://localhost:7777/api/workflows/YOUR_WORKFLOW_ID/run" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"interactive": true}'
{
  "status": "escalated",
  "session_id": "8629a9bc-052f-4af9-96fd-56779be0d94e",
  "job_id": "180129ebe0864d7ea5f70c1de80b22af",
  "workflow_id": "YOUR_WORKFLOW_ID",
  "escalation_id": "esc-1",
  "escalation": {
    "nodeId": "work",
    "nodeType": "nodetool.code.Code",
    "correlationLineage": [],
    "invocationKey": "",
    "allowedActions": ["skip", "fail"],
    "detail": "boom 42",
    "inputs": { "code": "throw new Error(\"boom 42\");" },
    "declaredOutputs": {},
    "attempt": 1,
    "spentCostUsd": 0,
    "createdAssets": false,
    "retrySafe": false,
    "emitted": false
  },
  "resolve": "POST /api/debug/sessions/8629a9bc-052f-4af9-96fd-56779be0d94e/verdict with {\"escalation_id\": \"esc-1\", \"verdict\": {\"action\": ...}} — allowed actions: skip, fail. The run is parked on this node until a verdict arrives; an unanswered escalation fails closed on the decision timeout."
}

inputs, detail, and candidateOutput are redacted and truncated before they leave the kernel. allowedActions is computed per invocation and enforced there too, so a verdict outside the list is refused however it was produced. retry appears only when the invocation is replayable — retrySafe, nothing spent, no assets created. substitute appears only when the node produced a malformed value a validator can check. A node reading a stream gets end_stream and fail and nothing else; skip and fail are always available.

Answer on the session. escalation_id identifies the escalation you are answering, and verdict.action is one of retry, substitute (with outputs), skip, end_stream, or fail:

curl -X POST "http://localhost:7777/api/debug/sessions/SESSION_ID/verdict" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"escalation_id": "esc-1", "verdict": {"action": "skip"}}'

The call blocks until the run escalates again, in the same "status": "escalated" shape, or finishes. A finished run returns the ordinary run payload with session_id added:

{
  "job_id": "180129ebe0864d7ea5f70c1de80b22af",
  "workflow_id": "YOUR_WORKFLOW_ID",
  "status": "completed",
  "outputs": { "out": [] },
  "error": null,
  "message_count": 9,
  "background": false,
  "session_id": "8629a9bc-052f-4af9-96fd-56779be0d94e"
}

Two more calls round out the session:

# Where is the run right now? Never blocks.
curl "http://localhost:7777/api/debug/sessions/SESSION_ID" \
  -H "Authorization: Bearer YOUR_TOKEN"

# Stop it. Returns the final report with status "cancelled".
curl -X POST "http://localhost:7777/api/debug/sessions/SESSION_ID/cancel" \
  -H "Authorization: Bearer YOUR_TOKEN"

Sessions belong to the user who started the run, so an unknown, foreign, or expired id answers 404 with {"detail": "Debug session not found"} rather than 403. A verdict whose action is not one of the five is a 400.

Every boundary resolves as fail, including an escalation nobody answers: the decision timeout is ten minutes by default. Three optional fields in the run body move the bounds, each clamped server-side — decision_timeout_ms (up to 30 minutes), max_decisions (default 10, up to 100), and max_retries_per_node (default 2, up to 20). A settled session stays readable for ten minutes.

Agents reach the same machinery through the run_workflow and debug_workflow tools with interactive: true, answering with resolve_workflow_escalation. See Workflow Debugging and the supervisor design for the verdict vocabulary.

Triggering a Workflow by Webhook

A workflow whose graph carries a WebhookTrigger node gets a registration with its own path token and shared secret. POST /api/webhooks/{token} is where an outside system delivers the event, and the route sits on the public allowlist — there is no session, so the x-webhook-secret header is the only thing standing between the caller and the run.

curl -X POST "http://localhost:7777/api/webhooks/YOUR_TOKEN" \
  -H "x-webhook-secret: YOUR_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"order_id": 41, "status": "paid"}'
{
  "status": "accepted",
  "input_id": "webhook:YOUR_TOKEN:9f2c…",
  "duplicate": false
}

The trigger node receives {body, headers, query, method}. headers has the shared secret stripped out before anything is stored. A body that is not JSON is passed through as the raw string, so form posts and plain text work without a content type the server has to recognize.

Send x-webhook-id to make retries safe: it becomes the delivery’s idempotency key, and a repeat answers 200 with "duplicate": true having stored nothing. Without that header the key is a hash of token, body, and the current minute — an identical body resent within the same minute is one event, the same body an hour later is two.

The failures are distinct on purpose:

Status Meaning
404 No registration carries this token. Checked before the secret, so a wrong token never reveals whether a secret would have matched
401 x-webhook-secret missing or wrong; compared in constant time against the stored sha256
410 The registration exists but is disabled
413 Body over 1 MiB
429 Over the route’s own limit of 120 deliveries per minute
curl -X POST "http://localhost:7777/api/webhooks/nosuchtoken" \
  -H "Content-Type: application/json" -d '{"hello":1}'
{ "error": "Unknown webhook token" }

Linking an External Messaging Account

A messaging bridge — the nodetool telegram bot, and later Discord — is not a browser and holds no user credential. It proves which external account is speaking, and the server decides which NodeTool user that is. The four /api/integrations/{provider}/* routes are that exchange. {provider} is telegram or discord; anything else is 400.

These routes authenticate with NODETOOL_INTEGRATION_TOKEN — the server’s own service token, sent as a bearer and compared in constant time — rather than a session, which is why they sit outside the session-auth hook the way the webhook route does. A server with that variable unset, or set to fewer than 16 characters, never registers them: every path answers 404, not 401. Set the same value on the bridge process.

Linking runs in one of two directions, and which one you are in decides which route redeems the code. Either way the code is 24 random bytes, base64url, good for ten minutes and spent on first use.

Bot-initiated. The bridge mints a code bound to the external account and sends the URL into the chat:

curl -X POST "http://localhost:7777/api/integrations/telegram/link/start" \
  -H "Authorization: Bearer $NODETOOL_INTEGRATION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"external_id": "482913044"}'
{
  "code": "Yb3xK9_qLm2vR7nT4pWzA1sD6fG8hJ0c",
  "url": "http://localhost:7777/integrations/link?code=Yb3xK9_qLm2vR7nT4pWzA1sD6fG8hJ0c",
  "expires_at": "2026-08-23T12:41:07.000Z"
}

url is built from the request’s own Host header unless NODETOOL_PUBLIC_URL is set — set that when the bridge reaches the server at an address the user’s browser cannot, such as http://nodetool:7777 inside a compose network.

The bridge stops there. The user opens that URL, and the confirmation page spends the code over tRPC (integrations.describeLinkCode, then integrations.confirmLink) under their own session, so the account that gets linked is the one they are signed in as — never one the code named.

Web-initiated. The mirror image, and the one /link/complete exists for. Settings → Integrations mints a code bound to the signed-in user (integrations.createLinkCode) and renders it as t.me/<bot>?start=<code>. Pressing Start delivers /start <code> to the bot, which redeems it with the external id it can see:

curl -X POST "http://localhost:7777/api/integrations/telegram/link/complete" \
  -H "Authorization: Bearer $NODETOOL_INTEGRATION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"external_id": "482913044", "code": "Yb3xK9_qLm2vR7nT4pWzA1sD6fG8hJ0c"}'
{ "linked": true }

A user-bound code already carries the user who minted it, so a user_id in the body is ignored. A code that link/start minted names no user and is refused here with 400: only the signed-in browser can redeem it. A refused call does not spend the code.

With the link in place either way, the bridge exchanges identity for access on every connection:

curl -X POST "http://localhost:7777/api/integrations/telegram/token" \
  -H "Authorization: Bearer $NODETOOL_INTEGRATION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"external_id": "482913044"}'
{
  "token": "ndt_eyJ2IjoxLCJ1IjoiM2Y5YSIsImUiOjE3ODc0OTE4Njd9.9c1f…",
  "expires_at": "2026-08-23T13:31:07.000Z",
  "user_id": "3f9a…"
}

The token lasts one hour and authenticates as that user on /ws, /trpc, and asset URLs. Tenant isolation is then the server’s usual rules — threads, tools, permissions, and cost tracking all stay server-side, and the bridge holds no conversation state of its own.

Unlinking takes the external id in the body of a DELETE:

curl -X DELETE "http://localhost:7777/api/integrations/telegram/link" \
  -H "Authorization: Bearer $NODETOOL_INTEGRATION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"external_id": "482913044"}'
{ "unlinked": true }

"unlinked": false means there was nothing to remove. The failures across all four routes:

Status Meaning
404 The routes are not registered (NODETOOL_INTEGRATION_TOKEN unset or under 16 characters), or — on /token — the external id is not linked to any user
401 Missing or wrong service token
400 Unknown provider, missing external_id, or a code issued for a different account
410 The link code expired or was already used
409 /token only: the server runs in local single-user mode, where every request is already user 1, so a delegated token would isolate nothing. Run with an enforcing auth provider (Supabase)

Design and the bot’s side of the flow: Telegram bot design. The bridge command is nodetool telegram.

MCP OAuth Discovery

For /mcp, NodeTool is both the OAuth authorization server and the protected resource. An MCP client that meets a 401 on /mcp follows the WWW-Authenticate challenge to two discovery documents and takes the endpoint URLs from there — so a client needs no NodeTool-specific configuration beyond the server’s address.

curl "https://nodetool.example.com/.well-known/oauth-protected-resource"
{
  "resource": "https://nodetool.example.com/mcp",
  "authorization_servers": ["https://nodetool.example.com"],
  "scopes_supported": ["mcp"],
  "bearer_methods_supported": ["header"],
  "resource_name": "NodeTool MCP"
}
curl "https://nodetool.example.com/.well-known/oauth-authorization-server"
{
  "issuer": "https://nodetool.example.com",
  "authorization_endpoint": "https://nodetool.example.com/oauth/authorize",
  "token_endpoint": "https://nodetool.example.com/oauth/token",
  "registration_endpoint": "https://nodetool.example.com/oauth/register",
  "revocation_endpoint": "https://nodetool.example.com/oauth/revoke",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"],
  "scopes_supported": ["mcp"],
  "client_id_metadata_document_supported": true,
  "authorization_response_iss_parameter_supported": true
}

Both documents come back with Cache-Control: public, max-age=3600. Deployed under a sub-path, RFC 8414 and RFC 9728 put them at the path-inserted forms instead — issuer https://host/base publishes at /.well-known/oauth-authorization-server/base, and the resource document for https://host/base/mcp at /.well-known/oauth-protected-resource/base/mcp. At the origin root the resource form is /.well-known/oauth-protected-resource/mcp, which returns the same document as the bare path above.

All six routes answer 404 unless the flow can actually complete, and four conditions decide that: NODETOOL_DISABLE_MCP_OAUTH is not 1, the /mcp mount is enabled (in production that needs NODETOOL_ENABLE_MCP=1; in dev it is on by default), NODETOOL_PUBLIC_URL is set, and that URL is HTTPS or loopback. The server never advertises a discovery document it would then refuse to serve — with the flow off, /mcp sends no WWW-Authenticate challenge at all.

The flow itself is authorization code with PKCE (S256) and no client secret. GET /oauth/authorize parks the request and 302s to /oauth/consent?request_id=…, a page the web app renders; approving it mints the code that POST /oauth/token exchanges for an nta_ access token. Replaying a code revokes the grant, per the OAuth 2.1 rule.

Pasting an ntk_ token minted in Settings → MCP → Connect an agent remotely stays the alternative for a client that does not do OAuth. See MCP in production for the operator’s walkthrough and MCP OAuth design for the full flow.

Chat API (OpenAI-Compatible)

NodeTool exposes OpenAI-compatible endpoints, so you can use standard OpenAI clients:

# Simple chat completion
curl -X POST "http://localhost:7777/v1/chat/completions" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "model": "gpt-5.6",
    "messages": [
      {"role": "user", "content": "Explain quantum computing in simple terms"}
    ]
  }'

Response:

{
  "id": "chatcmpl-abc123",
  "object": "chat.completion",
  "created": 1699000000,
  "model": "gpt-5.6",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "Quantum computing uses quantum mechanics..."
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 0,
    "completion_tokens": 0,
    "total_tokens": 0
  }
}

usage is always zeros, because the endpoint does not count tokens. The provider comes from the model name, and a request without model uses llama3.2:latest. See the Chat API for the rules.

Streaming Chat

# Streaming chat (prints tokens as they arrive)
curl -X POST "http://localhost:7777/v1/chat/completions" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "model": "gpt-5.6",
    "messages": [
      {"role": "user", "content": "Write a haiku about programming"}
    ],
    "stream": true
  }'

Streaming response. Each event is a chat.completion.chunk, the last one carries finish_reason, and the stream ends with [DONE]:

data: {"id":"chatcmpl-…","object":"chat.completion.chunk","created":1699000000,"model":"gpt-5.6","choices":[{"index":0,"delta":{"content":"Code"},"finish_reason":null}]}

data: {"id":"chatcmpl-…","object":"chat.completion.chunk","created":1699000000,"model":"gpt-5.6","choices":[{"index":0,"delta":{"content":" flows"},"finish_reason":null}]}

data: {"id":"chatcmpl-…","object":"chat.completion.chunk","created":1699000000,"model":"gpt-5.6","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]

List Available Models

curl "http://localhost:7777/v1/models" \
  -H "Authorization: Bearer YOUR_TOKEN"

The route returns a fixed list. It does not query providers or reflect configured keys:

{
  "object": "list",
  "data": [
    {"id": "llama3.2:latest", "object": "model", "created": 0, "owned_by": "ollama"},
    {"id": "gpt-4", "object": "model", "created": 0, "owned_by": "openai"},
    {"id": "gpt-4o", "object": "model", "created": 0, "owned_by": "openai"},
    {"id": "gpt-4o-mini", "object": "model", "created": 0, "owned_by": "openai"},
    {"id": "claude-sonnet-4-20250514", "object": "model", "created": 0, "owned_by": "anthropic"},
    {"id": "claude-opus-4-20250514", "object": "model", "created": 0, "owned_by": "anthropic"}
  ]
}

A chat request accepts any model name, not only these. For the live catalog with per-model availability, use GET /api/sdk/v1/models.

Node Metadata

GET /api/nodes/metadata is the node registry — every node type the server can run, with its properties, inputs, and outputs. The editor fetches it at boot, before anyone has signed in, so the route takes no token. Reach for it when you are building a client, a palette, or a graph by hand and need to know what a node type is called and what it accepts.

The default response is a slim summary, because the full registry is large:

curl "http://localhost:7777/api/nodes/metadata?limit=2"
[
  {
    "node_type": "fal.3d_to_3d.Hunyuan3dV31Part",
    "title": "Hunyuan 3d V31Part",
    "description": "Split 3D models into parts with Hunyuan 3D\nprocessing, 3d-to-3d, 3d, mesh, hunyuan, part",
    "namespace": "fal.3d_to_3d"
  },
  {
    "node_type": "fal.3d_to_3d.Hunyuan3dV31SmartTopology",
    "title": "Hunyuan 3d V31Smart Topology",
    "description": "Optimize 3D mesh topology with Hunyuan 3D Smart Topology.\nprocessing, 3d-to-3d, 3d, mesh, hunyuan, smart, topology",
    "namespace": "fal.3d_to_3d"
  }
]

Five query parameters narrow it:

Parameter Effect
node_type Exact lookup. Returns that one node’s full metadata — properties, outputs, recommended_models, layout — or 404
namespace Keep nodes whose namespace starts with this prefix
query Comma-separated terms scored against title, description, node type, and namespace; unmatched nodes drop out and the best matches come first
fields summary (default) or full to get complete metadata for every node in the result
limit Truncate the result
curl "http://localhost:7777/api/nodes/metadata?node_type=nodetool.text.Concat"
{
  "title": "Concat",
  "description": "Concatenates text inputs into a single output. …",
  "namespace": "nodetool.text",
  "node_type": "nodetool.text.Concat",
  "layout": "default",
  "body": "content_card",
  "properties": [],
  "outputs": [{"name": "output", "type": {"type": "str", "type_args": []}}],
  "recommended_models": []
}

List Workflows

# List the caller's workflows (graphs included)
curl "http://localhost:7777/api/workflows" \
  -H "Authorization: Bearer YOUR_TOKEN"

The response is {"workflows": [...], "next": <cursor or null>}. Pass limit (default 100, cap 500), cursor, run_mode, or project_id to page and filter. Create, read, save, and delete are covered in the Workflow API.

Moving Workflows Between Servers

A .nodetool bundle is a zip holding one or more workflow graphs plus the bytes of every asset they reference, so a workflow travels as a single file instead of a graph whose asset:// refs dangle on the far side. Three routes cover the round trip; the CLI’s workflows export-bundle / import-bundle and the editor’s command menu go through the same packer.

GET /api/workflows/{id}/export-bundle returns one workflow:

curl "http://localhost:7777/api/workflows/<workflow_id>/export-bundle" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -o my-workflow.nodetool

The response is application/zip with content-disposition: attachment; filename="<workflow name>.nodetool" — the name with everything outside A-Za-z0-9._- replaced by _. Inside, each graph is a file under workflows/, asset bytes sit under their own entries, and manifest.json indexes both:

{
  "format": "nodetool-workflow-bundle",
  "version": 2,
  "created_at": "2026-08-07T07:03:40.352Z",
  "workflows": [
    { "file": "workflows/c3b15268a02242e682035e5e4be8a22a.json", "name": "Bundle Demo" }
  ],
  "assets": [],
  "thumbnail": null
}

POST /api/workflows/export-bundle packs several at once. workflow_ids must be a non-empty array of strings — anything else is a 400. The download is named after the single workflow when there is one, and <n>-workflows otherwise:

curl -X POST "http://localhost:7777/api/workflows/export-bundle" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"workflow_ids": ["<id_a>", "<id_b>"]}' \
  -o my-pack.nodetool

POST /api/workflows/import-bundle takes the zip back, as a file part in a multipart form or as the raw request body:

curl -X POST "http://localhost:7777/api/workflows/import-bundle" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -F "file=@my-workflow.nodetool"
{
  "workflows": [
    {
      "id": "bcec888aff5a429dae4dce4ca29fcc4c",
      "name": "Bundle Demo",
      "access": "private",
      "graph": { "nodes": [], "edges": [] }
    }
  ],
  "imported": 0,
  "missing": [],
  "checksum_mismatches": []
}

Every workflow is created fresh under the caller with a new id and access: "private" — importing never overwrites an existing one. imported counts the assets stored, missing names refs the bundle did not carry, and checksum_mismatches names asset bytes that did not hash to what the manifest recorded. Neither list is fatal — the graphs still import, with those refs unresolved.

A bundle that cannot be unpacked, or a graph the workflow API rejects, is a 400 ({"detail": "Invalid bundle: …"}). The import is not transactional, so a bundle that fails partway can leave the workflows created before the failure behind.

Moving Timelines Between Servers

A timeline zip is the same idea for a cut: manifest.json, timeline.json, and the bytes of every asset the clips point at under assets/, so a sequence arrives with its media instead of dangling asset ids.

GET /api/timelines/{id}/export-zip packs one sequence:

curl "http://localhost:7777/api/timelines/<timeline_id>/export-zip" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -o my-cut.zip

The response is application/zip with content-disposition: attachment; filename="<timeline name>.zip" — the name with everything outside A-Za-z0-9._- replaced by _. A timeline the caller does not own is a 404 with {"detail": "Timeline not found"}, the same answer as an id that does not exist.

The archive streams with no content-length. The server reads each local file in place while it writes, so a large asset is never held in memory. A file over 4 GiB does not fit a zip entry without Zip64 and is listed as missing.

timeline.json is the sequence wire shape without its id, project, owner, and timestamps. manifest.json maps each asset id in that document to the file holding its bytes, and records what the archive could not carry:

{
  "format": "nodetool-timeline-bundle",
  "version": 1,
  "created_at": "2026-09-06T09:12:03.114Z",
  "timeline": { "file": "timeline.json", "name": "My Cut" },
  "assets": [
    {
      "file": "assets/9f2c….mp4",
      "asset_id": "3c1a…",
      "name": "shot.mp4",
      "content_type": "video/mp4",
      "bytes": 4813222,
      "sha256": "9f2c…"
    }
  ],
  "missing_assets": [],
  "fonts": ["Inter"],
  "foreign_refs": {
    "workflow_ids": [],
    "storyboard_ids": [],
    "script_ids": [],
    "job_ids": []
  }
}

Asset files are content-addressed, so two clips sharing bytes pack one file. fonts names the families the document’s titles and captions ask for — fonts ship with the app, so none are packed. foreign_refs names ids that mean something only on the exporting install (workflows, storyboards, scripts, jobs); import carries them through untouched rather than trying to resolve them.

GET /api/timelines/{id}/export-premiere writes the same sequence for Adobe Premiere Pro. The zip holds <timeline name>.xml, a Final Cut Pro 7 XML sequence that Premiere imports through File > Import, the active asset of every exported clip under media/, and export-report.txt, which lists each clip that needs baking or was left out. The XML points at its media with relative media/<file> paths. The response carries x-nodetool-export-warnings with the number of report entries, and is named <timeline name>-premiere.zip by the same rule as above.

GET /api/timelines/{id}/export-media packs only the media: the original file of every video, image, and audio clip under media/, named as in the Premiere export, and clips.csv with one row per clip (clip, track, media_type, start_s, end_s, in_s, file). x-nodetool-export-missing counts the files that could not be read or packed. The zip is <timeline name>-media.zip.

POST /api/timelines/import-zip also accepts a zip with no manifest.json that holds a Final Cut Pro 7 XML (xmeml) sequence, such as Premiere Pro’s File > Export > Final Cut Pro XML zipped with its media. Media is matched by file name in any folder of the zip. The response adds format: "premiere_xml", missing_clips (clips whose file was not in the zip), and warnings (what had no NodeTool counterpart, such as transitions or nested sequences).

POST /api/timelines/import-zip reads the zip back as a multipart form. file is required; project_id defaults to "default" (no project) and name overrides the name in the bundle:

curl -X POST "http://localhost:7777/api/timelines/import-zip" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -F "file=@my-cut.zip" \
  -F "project_id=<project_id>"
{
  "timeline": { "id": "8f1c…", "projectId": "default", "name": "My Cut", "clips": [] },
  "imported": 1,
  "missing": [],
  "checksum_mismatches": []
}

Import always creates a new timeline under the caller — it never overwrites an existing one. Every asset is stored fresh and the clip fields that named the old ids (currentAssetId, thumbnailAssetId, waveformAssetId, and each versions[].assetId) are rewritten to the new ones. imported counts the assets stored, missing names ids the archive carried no bytes for — those keep their original value and stay dangling — and checksum_mismatches names files that did not hash to what the manifest recorded. Neither list is fatal. An archive that cannot be unpacked, or a document the timeline schema rejects, is a 400 ({"detail": "Invalid bundle: …"}).

Fetching a Shipped Example’s Graph

GET /api/workflows/examples lists the shipped templates, but every entry comes back with "graph": {"nodes": [], "edges": []} — the list is metadata, kept small because it ships hundreds of workflows. GET /api/workflows/examples/{package}/{example} is how you get one example’s actual graph. Both are unauthenticated: the templates ship with the install and belong to nobody.

The two path segments are the package name and the example name, URL-encoded — the same package_name and name the list returned.

curl "http://localhost:7777/api/workflows/examples/nodetool-base/Sharpen%20Footage"
{
  "id": "",
  "access": "private",
  "name": "Sharpen Footage",
  "description": "Add apparent detail back after a denoise or a downscale. …",
  "tags": ["video", "utility", "example"],
  "package_name": "nodetool-base",
  "graph": { "nodes": [ … ], "edges": [ … ] },
  "input_schema": { … },
  "output_schema": { … }
}

The id is empty because an example is a template, not a row — POST the graph to /api/workflows to make it yours. A name the package does not ship is a 404:

{ "detail": "Example 'Nope' not found in package 'nodetool-base'" }

Uploading an Asset

On a cloud storage backend (Supabase or S3) the bytes go straight from the client to the bucket — the API only mints a target and confirms the result.

  1. assets.createUpload (tRPC) with the file’s name, content type, parent folder, and size. It creates the asset row, picks the storage key (<userId>/<assetId>.<ext> — never the client’s choice), and returns a short-lived upload target.
  2. Send the bytes to upload.url with upload.method and upload.headers.
  3. assets.finalizeUpload with the returned asset_id. The server reads the object’s real size back off the bucket, records it, and generates a thumbnail. A missing, empty, or over-cap upload is rejected and the pending row removed.

upload comes back null on the local file backend, which has no direct-upload concept; fall back to the multipart POST /api/assets. The web client does this automatically.

Extracting a Video’s Audio Track

POST /api/assets/{id}/extract-audio copies a video asset’s audio into a new WAV asset parented to the video. The timeline calls it when a video is imported so the audio becomes an independently editable clip; call it directly when you want the same thing outside the editor.

curl -X POST "http://localhost:7777/api/assets/<video_asset_id>/extract-audio" \
  -H "Authorization: Bearer YOUR_TOKEN"
{
  "has_audio": true,
  "asset": {
    "id": "b41f…",
    "name": "clip.mp4 (audio)",
    "content_type": "audio/wav",
    "parent_id": "<video_asset_id>",
    "duration": 12.5,
    "get_url": "/api/storage/1/b41f….wav"
  }
}

A video with no audio track returns {"has_audio": false} and creates nothing. Posting a non-video asset is a 400 ({"detail": "Asset is not a video"}), an asset you do not own is a 404, and a server with no ffmpeg runtime available answers 503.

Waveform Peaks

GET /api/assets/{id}/peaks?count=2000 returns the waveform the timeline draws on an audio clip: count abs-max values of channel 0 (1 to 20000, default 2000) and the decoded length.

{ "peaks": [0, 0.0132, 0.4821, …], "duration_ms": 184032.5 }

ffmpeg decodes the asset as a stream, reading a local file in place. The result is cached under the NodeTool cache directory, keyed by the asset, the file’s size and mtime (or the row’s updated_at on a store with no local file), and count, so a file changed in place gets new peaks. The x-peaks-cache header is hit or miss. A non-media asset or a bad count is a 400, an asset you do not own is a 404, a file with no audio stream is a 422, and a server with no ffmpeg runtime answers 503.

Package Assets

Shipped node packs carry media alongside their code — the images and audio that example workflows reference as package://<package>/<file>. Those refs resolve over GET /api/assets/packages/{package}/{file}, which needs no token because the bytes ship with the install.

curl "http://localhost:7777/api/assets/packages/nodetool-base/A%20Boolean%20Constant.jpg" \
  -o thumb.jpg

The file path may be nested (audio/loop.mp3); .. segments and backslashes are rejected. Responses carry cache-control: public, max-age=31536000, immutable and an ETag, since a package’s assets change only when the package version does.

The two listing routes above that path are stubs. GET /api/assets/packages and GET /api/assets/packages/{package} both answer 200 with an empty page, whatever the package name:

curl "http://localhost:7777/api/assets/packages/nodetool-base"
{ "assets": [], "next": null }

The REST surface never grew a real listing, so address a package’s files by the name the workflow’s package:// ref already carries. Agents that need to enumerate them call the list_assets tool with source: "package", which walks the packages on disk and returns each file’s uri and url.

Bulk Asset Download

POST /api/assets/download is reserved for zipping the requested assets into one response, keeping the folder structure their parent_id relationships describe. This server does not implement it and answers 501:

curl -X POST "http://localhost:7777/api/assets/download" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"asset_ids": ["b41f…", "c02a…"]}'
{
  "code": "SERVICE_UNAVAILABLE",
  "detail": "ZIP download not available in standalone mode"
}

The status does not depend on the body — an unknown asset id returns the same 501. Download assets one at a time through their get_url instead.

Downloading a Workspace File

A workspace is a directory on the server’s filesystem that agents and file tools read and write. Listing and CRUD moved to the tRPC workspace router; GET /api/workspaces/{id}/download/{path} stayed on REST because it returns bytes rather than JSON.

path is relative to the workspace root and may be nested. The response is an attachment, with a content type guessed from the extension:

curl "http://localhost:7777/api/workspaces/ws_abc123/download/notes/report.md" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -o report.md

When NODETOOL_ENV=production, only the server-managed workspace is readable. A workspace row that points at some other host folder answers 403 with {"detail": "This workspace is not readable in production"}. Otherwise:

Status Meaning
400 path is absolute
403 path resolves outside the workspace root, or the workspace is not readable in production
404 No such workspace for this caller, or no such file inside it
405 The method is not GET
500 The workspace has no storage configured
curl "http://localhost:7777/api/workspaces/nosuchws/download/a.txt"
{ "detail": "Workspace not found" }

Provider Credits

GET /api/fal/credits and GET /api/kie/credits report what the account behind the server’s API key has left. The editor shows the number next to the provider; call them yourself to watch a budget from outside the UI.

Both read the key the server holds — the stored FAL_API_KEY / KIE_API_KEY secret, falling back to the same environment variable — not anything the caller sends. With no key configured the answer is 204 No Content.

curl "http://localhost:7777/api/fal/credits" \
  -H "Authorization: Bearer YOUR_TOKEN"
{
  "credit_balance": { "amount": 42.5, "currency": "USD" },
  "username": "your-fal-account"
}

kie.ai bills in credits rather than dollars, so its currency is the literal string "credits":

{ "credit_balance": { "amount": 1200, "currency": "credits" } }

A provider that refuses the key still answers 200, with the reason spelled out — the SPA treats a gateway status as a bug in NodeTool, so the failure is carried in the body instead:

{
  "unavailable": true,
  "detail": "Invalid API key or malformed Authorization header",
  "credit_balance": null
}

Reading a fal.ai balance needs an Admin key (create one at https://fal.ai/dashboard/keys); an ordinary key gets 403 from fal.ai, which comes back as an unavailable body saying so.

Provider Pricing

GET /api/fal/pricing returns the unit price of one or more fal.ai endpoints. Repeat endpoint_id for each; omitting it entirely is a 400. Prices are cached per endpoint for an hour, and the route answers 204 when no FAL_API_KEY is configured.

curl "http://localhost:7777/api/fal/pricing?endpoint_id=fal-ai/flux/schnell&endpoint_id=fal-ai/flux/dev" \
  -H "Authorization: Bearer YOUR_TOKEN"
{
  "byEndpointId": {
    "fal-ai/flux/schnell": {
      "unit_price": 0.003,
      "billing_unit": "megapixel",
      "currency": "USD"
    }
  },
  "fetched_at": "2026-08-05T08:21:49.251Z"
}

GET /api/kie/pricing is the same shape over kie.ai model ids (?model_id=, repeatable, 400 when absent). It needs no API key — kie.ai publishes its pricing pages openly — and returns a per-model summary:

{
  "byModelId": {
    "flux-2/pro-text-to-image": {
      "model_id": "flux-2/pro-text-to-image",
      "unit_price": 9,
      "billing_unit": "second",
      "currency": "credits",
      "usd_price": 0.045,
      "tier_count": 4,
      "pricing_url": "https://kie.ai/flux-2"
    }
  },
  "fetched_at": "2026-08-05T08:19:54.783Z"
}

Most kie.ai models are priced in tiers. unit_price is the cheapest of them, tier_count says how many there were, and billing_unit is "varies" when the tiers are not billed by the same unit.

An id with no published price is absent from the map rather than an error, so a request for five models can come back with three.

Estimating a fal.ai Call’s Cost

GET /api/fal/pricing gives the price of one unit; POST /api/fal/pricing/estimate turns that into a total for a quantity you name, so you can price a batch before running it. endpoint_id is required — without it the route answers 400 — and estimate_type picks how the quantity is read:

  • historical_api_price (the default) counts whole calls, from call_quantity (default 1, values below 1 are ignored).
  • unit_price counts billing units — megapixels, seconds — from unit_quantity (default 1, floor 0.000001).
curl -X POST "http://localhost:7777/api/fal/pricing/estimate" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"endpoint_id": "fal-ai/flux/schnell", "call_quantity": 250}'
{
  "endpoint_id": "fal-ai/flux/schnell",
  "estimate_type": "historical_api_price",
  "total_cost": 0.75,
  "currency": "USD",
  "fetched_at": "2026-08-10T09:14:02.118Z",
  "cached": false
}

The key comes from the secret store, falling back to the FAL_API_KEY environment variable; with neither the route answers 204 and the SPA shows no estimate. A fal.ai call that fails or answers with an unusable body is a 502.

Answers are cached for an hour per endpoint and estimate type — the quantity is not part of the cache key, so a second request that changes only the quantity returns the first one’s total with cached: true. Vary the endpoint or the estimate type to get a fresh number inside that hour.

Resolving a KIE Model’s Schema

kie.ai adds models faster than a node can be written for each, so KieAINode takes its shape from the model’s documentation. POST /api/kie/resolve-dynamic-schema does that parse: give it the docs page as model_info, get back the node’s dynamic properties, inputs, and outputs. The editor calls it when you paste docs into the node; call it directly to check what a page would produce before wiring anything.

curl -X POST "http://localhost:7777/api/kie/resolve-dynamic-schema" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"model_info": "| **Format** | `bytedance/seedance-2` |\n\n### input Object Parameters\n\n#### prompt\n- **Type**: `string`\n- **Required**: No\n- **Description**: The text prompt for the video.\n"}'
{
  "model_id": "bytedance/seedance-2",
  "dynamic_properties": { "prompt": "" },
  "dynamic_inputs": {
    "prompt": {
      "type": "str",
      "type_args": [],
      "optional": true,
      "description": "The text prompt for the video."
    }
  },
  "dynamic_outputs": {
    "video": { "type": "video", "type_args": [], "optional": false }
  }
}

The model id comes from the docs’ Format row; parameters are read from the #### <name> headings under input Object Parameters, so an excerpt missing that section resolves to a node with no inputs.

Docs with no recognizable model id are a 400 ({"code": "INVALID_INPUT", "detail": "Could not find model ID in documentation"}), as is a missing or empty model_info.

What a Published App Runs

Publishing a mini app freezes a snapshot: the document as it stood, plus the graph of every workflow its operations call. GET /api/applications/{id}/released-document returns that snapshot, so a runtime can serve the published app without reading the draft the author is still editing.

curl "http://localhost:7777/api/applications/<application_id>/released-document" \
  -H "Authorization: Bearer YOUR_TOKEN"
{
  "id": "548770b4c014436ba8549509575e9be6",
  "applicationId": "76a381309a584a13b823b297cbd9b4b1",
  "version": 1,
  "document": {
    "schemaVersion": 3,
    "ui": { "root": { "props": {} }, "content": [], "zones": {} },
    "operations": [],
    "resources": [],
    "variables": []
  },
  "capabilities": { "workflows": [], "resources": [] },
  "released": true,
  "createdAt": "2026-08-05T08:19:40.275Z",
  "workflows": [
    {
      "workflowId": "wf_abc123",
      "version": 4,
      "graphHash": "9f2c…",
      "graph": { "nodes": [], "edges": [] }
    }
  ]
}

Each entry in workflows is the graph as the release froze it. version and graph are null on a snapshot published before releases pinned anything — a runtime that meets one falls back to the live workflow.

An app with nothing published answers 200 with a body of null; an app you do not own is a 404. Publishing itself is a tRPC call (applications.publish), not a REST route.

Installing a Shipped Example App

NodeTool ships a set of curated mini apps. They are ApplicationBundle files on disk rather than database rows, so listing and reading one touches no user data. The routes still sit behind session auth, and installing one writes into the caller’s library.

GET /api/applications/examples is the catalog:

curl "http://localhost:7777/api/applications/examples"
[
  {
    "slug": "dataset-builder",
    "name": "Dataset Builder",
    "description": "The smallest app in the set, and the reference for the Table widget: a dataframe reads better as rows than as a Preview node.",
    "workflows": ["Data Generator"],
    "operationCount": 1,
    "thumbnailUrl": "/api/workflows/examples/thumbnails/Data%20Generator.jpg?v=3f2a9c1e"
  }
]

workflows names the workflows installing the app would create, and thumbnailUrl is the gallery art of the first of them, or null when it ships none. To read the whole thing first — the app document plus the full graph of every workflow it binds — fetch the bundle:

curl "http://localhost:7777/api/applications/examples/dataset-builder"

That returns {schemaVersion, name, description, app, workflows}. A slug nothing ships is a 404 ({"detail": "No example app named \"nope\""}).

POST /api/applications/examples/{slug}/install goes through the normal bundle import and answers with the created application. An optional projectId in the body files it under a project:

curl -X POST "http://localhost:7777/api/applications/examples/dataset-builder/install" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{}'
{
  "id": "f85f3d79566346df90827c752eab5bcf",
  "projectId": "default",
  "name": "Dataset Builder",
  "description": "…",
  "document": {
    "schemaVersion": 3,
    "ui": { "root": { "props": {} }, "content": [], "zones": {} },
    "operations": [],
    "resources": [],
    "variables": []
  },
  "createdAt": "2026-08-07T07:03:52.836Z",
  "updatedAt": "2026-08-07T07:03:52.836Z"
}

Omitting projectId files the app under default.

Installing also creates the workflows the app binds. Workflows carrying a sourceId are created once per user, so installing two examples that share a template leaves one workflow row both apps point at.

To install a bundle of your own instead of a shipped one, post it to POST /api/applications/import-bundle.

POST /api/applications/examples/{slug}/scripts/{key}/run runs one of the JS scripts an example bundle carries, so the app’s page can run an example before anyone installs it. key is a scripts[].key value from the bundle returned by GET /api/applications/examples/{slug}. The body is the same as POST /api/js-scripts/{id}/run except that script_version does not apply: the run uses the document stored in the bundle.

curl -X POST "http://localhost:7777/api/applications/examples/product-price-drop/scripts/plan_storyboard-v1/run" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"inputs": {"recipe": {"operations": []}}}'
{
  "ok": false,
  "logs": [],
  "duration_ms": 277,
  "error": "Select a finishing model before planning."
}

A script that throws answers 200 with ok: false, as it does for a saved script. The run belongs to the caller’s personal project, so assets and documents the script creates land there. Send Accept: application/x-ndjson to stream the run: each line is one JSON object, {"type": "message", ...} for agent progress, {"type": "emit", "name": ..., "value": ...} for an emit call, and a last {"type": "result", "result": ...} line carrying the body the plain answer would have returned.

A slug or key the bundles do not ship is a 404 ({"detail": "Example app script not found"}). An inputs value that is not an object is a 400 ({"detail": "Invalid script inputs"}).

Exporting an App as a Bundle

GET /api/applications/{id}/export-bundle is the other half of import-bundle: it packs one of the caller’s apps and the full graph of every workflow its operations bind into a single ApplicationBundle JSON file. Reach for it to move an app between servers, or to check into git what import-bundle will recreate.

curl "http://localhost:7777/api/applications/<application_id>/export-bundle" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -O -J

The response is application/json with a content-disposition built from the app’s name — attachment; filename="Ask_Your_Documents.app.json" — so curl -OJ writes it under that name. The body is the bundle:

{
  "schemaVersion": 1,
  "name": "Ask Your Documents",
  "description": "Retrieval-augmented answers with citations, and a fully local fallback…",
  "app": {
    "schemaVersion": 4,
    "ui": { "root": {}, "content": [] },
    "operations": [
      {
        "id": "ask",
        "name": "Ask",
        "workflowId": "chat-with-your-documents",
        "inputs": { "question_input": { "from": "variable", "variableId": "question" } },
        "outputs": {},
        "policy": "replace"
      }
    ],
    "resources": [],
    "variables": []
  },
  "workflows": [
    {
      "key": "chat-with-your-documents",
      "name": "Chat With Your Documents",
      "description": "Retrieval-augmented Q&A over your own documents…",
      "graph": { "nodes": [], "edges": [] },
      "version": null,
      "graphHash": null
    }
  ],
  "scripts": []
}

The operation’s workflowId is that workflow’s key in this file, not a row id — which is what lets import-bundle create the workflows on the target server and rewrite the keys to whatever ids they get there. scripts carries any JS script documents the app binds, on the same terms.

By default this exports the draft document. ?released=1 (or ?released=true) exports the published snapshot instead — the document GET /api/applications/{id}/released-document returns, packed together with the graphs that release pinned:

curl "http://localhost:7777/api/applications/<application_id>/export-bundle?released=1" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -o my.app.json

The two routes disagree about an app that has never been published, so pick by which answer you want to handle. released-document calls it a 200 with a null body; export-bundle?released=1 calls it a 404 with {"detail": "Application has no released version"}.

An id the caller does not own is also a 404. The CLI wraps the same call as nodetool apps export-bundle <id> [-o file] [--released].

Exporting an App as a Standalone Project

GET /api/applications/{id}/export-project returns the app as a JavaScript project that runs without NodeTool, zipped under a <project-name>/ folder. ?released=1 exports the released snapshot, as for export-bundle.

curl "http://localhost:7777/api/applications/<application_id>/export-project" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -O -J

The response is application/zip with content-disposition: attachment; filename="<project-name>.zip". The project layout is described in @nodetool-ai/app-export. The CLI equivalent is nodetool apps export-project.

Building an App on the Server

POST /api/applications/build runs the same six-stage build the CLI’s nodetool app build runs — spec, plan, author, check, run, judge — and answers with the BuildReport. Reach for it when a caller wants a batch build without a CLI on the machine.

Provider and model come from the body, falling back to NODETOOL_APP_BUILD_PROVIDER and NODETOOL_APP_BUILD_MODEL. Neither one set either way is a 400:

curl -X POST "http://localhost:7777/api/applications/build" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "an app that drafts a note from a prompt",
    "provider": "anthropic",
    "model": "claude-sonnet-5"
  }'

Body fields, all optional except one of prompt or spec:

Field Meaning
prompt What to build, in words. Either this or spec
spec A pinned BuildSpec, skipping the Spec stage. Either this or prompt
provider / model The builder’s provider and model
judge_model provider/model for the Judge stage. Omitted, the server picks a configured model the builder did not use
workflow_ids Workflows to pin, in operation declaration order, instead of planning them
max_repairs Repair rounds allowed (default 3)
cost_cap_usd Ceiling on build spend (default 2)
timeout_ms Wall-clock cap on the build
poll Return a session id immediately instead of holding the request open

max_repairs must be a non-negative integer and cost_cap_usd a positive number. A present-but-invalid value is a 400 rather than silently becoming the default — cost_cap_usd: 0 turning into $2 would spend money the caller said not to.

A build runs for minutes, so poll: true answers as soon as it starts:

{
  "status": "running",
  "session_id": "…",
  "build_id": "…",
  "poll": "GET /api/debug/sessions/…",
  "cancel": "POST /api/debug/sessions/…/cancel"
}

Read those two paths until the session settles. While the build runs, the poll answers status: "running" with progress: {stage, round}: the stage under way (spec, plan, author, check, run or judge) and the repair round, 0 for the first pass. A cancelled build settles as failed with reason: "cancelled".

POST /api/applications/spec takes prompt, provider, model, cost_cap_usd and poll, runs the Spec stage alone, and answers with {status, spec, issues, cost_usd}. spec is null and error names the first issue when no valid spec came back. Pass the spec, edited or not, back to /api/applications/build as spec to build exactly what was read. The App guided flow uses the pair for its Design and Build steps.

The bundle behind a green verdict is offered, never installed: turning it into an application is a separate POST /api/applications/import-bundle.

Debugging an App on the Server

POST /api/applications/debug simulates a mini app headlessly — it validates every widget binding, seeds input defaults, replays an interaction script, runs the workflows on the kernel, and reports what each widget ended up showing.

Name a saved app with application_id, or post the live draft as document. Neither is a 400 ("An app debug run needs either an application_id or a document.").

curl -X POST "http://localhost:7777/api/applications/debug" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"application_id": "<application_id>", "run": false}'
Field Meaning
application_id A saved application, read from the row. Either this or document
document The live draft, verbatim. Either this or application_id
params Reactive values applied before the interactions, keyed by input name
interact The interaction script. Omitted, the app’s natural run trigger fires
run Execute workflow runs (default true); false is a static wiring check
timeout_ms Per-run timeout
poll Return a session id immediately, as with build above

The response is the compacted report — the verdict plus what each widget shows. target.ref echoes what was named: an application id, or inline-document when the draft was posted as document.

{
  "debug_id": "app-debug-186814b9-d387-4e67-8b8f-e7eab397575d",
  "status": "failed",
  "target": { "ref": "inline-document", "source": "application", "workflowId": null },
  "app": { "title": "Ask Your Documents", "widgetCount": 25 },
  "verdict": {
    "ok": false,
    "headline": "App has issues — TextInput \"in-ask-search\": bound to \"op:ask/in:search_input\" but operation \"ask\" runs a workflow with no node \"search_input\".",
    "issues": ["…"]
  }
}

nodetool app debug runs the same simulation locally and writes a full bundle to disk; see CLI.

Exporting a Storyboard as a Zip

The web app reads and writes boards over /trpc/storyboards.*. GET /api/storyboards/{id}/export-zip is the one non-tRPC door, because the body is a binary archive rather than JSON. Reach for it to hand a board to someone without a NodeTool account — a director, a client, a review thread.

curl "http://localhost:7777/api/storyboards/<storyboard_id>/export-zip" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -O -J

The response is application/zip with cache-control: no-store and a content-disposition built from the board’s name, with each run of characters outside A-Za-z0-9._- replaced by a single _ — a board named First Light — Travel Teaser becomes attachment; filename="First_Light_Travel_Teaser.zip" — so curl -OJ writes it under that name. Inside:

storyboard.md                      the board, shot by shot, linking its media
stills/01-dunes-before-sunrise.png the selected keyframe per shot
clips/01-dunes-before-sunrise.mp4  the selected clip per shot

Media file names are <two-digit shot number><-slug>.<ext>, where the slug is the shot’s own slug (or its action text) lowercased and hyphenated to 40 characters. The extension comes from the stored path; when the path carries none it falls back to the ref’s type, and then to .bin.

storyboard.md opens with the board title, then a bullet for each of logline, brief, style, aspect ratio, and music prompt the board has set, always a shot count, and the narration when there is one. Each shot follows under a level-two heading — its number, then its slug or the first line of its action — with the still embedded when one was packed, the action text, and a bullet for each of camera, motion, dialogue, narration, duration, status, and notes it carries, plus a link to its clip.

A shot’s media is resolved server-side from asset://, a /api/storage/ path, an https:// URL, or an inline data: URI — and only those. A ref in any other form is not fatal: the shot keeps its text and the Markdown carries the reason where the media would have been.

- **Missing clip:** `asset://f3c1…` could not be read, so it is not in this archive.

That covers a blob: handle only the browser ever held, a URL that no longer answers, and package:// — which is what the shipped example boards carry, so exporting one of those before rendering anything of your own yields storyboard.md and no media files.

Unlike a .nodetool workflow bundle this is not a re-importable format — nothing rewrites the refs, and there is no import route that reads it back. A board the caller does not own is a 404 with {"detail": "Storyboard not found"}, the same answer as an id that does not exist.

Exporting a Game

The editor reads and writes games over /trpc/games.*. Two routes sit outside tRPC because their bodies are not JSON. Both build the game’s published revision, the same build the agent’s build_native_game capability makes (see Game Editor).

GET /api/games/{id}/export-zip streams the build as a zip. Reach for it to hand a playable game to someone without a NodeTool account.

curl "http://localhost:7777/api/games/<game_id>/export-zip?target=web" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -O -J
Query Values Effect
target web (default), tauri web is the static web player, tauri is the desktop app project
revision 32-character hex revision id Build that revision instead of the published one

Any other query parameter is rejected. The response is application/zip with cache-control: no-store and the file name game-<first 12 characters of the game id>-web.zip (-desktop.zip for tauri). Every entry sits under a web/ or desktop/ folder. A game the caller does not own, or a missing revision, is a 404 with {"detail": "Game not found"} or {"detail": "Game revision not found"}. A build that cannot run is a 422 with detail.

POST /api/games/{id}/build-executable builds the desktop project and compiles it with cargo on the server’s machine. It streams cargo’s output as newline-delimited JSON (application/x-ndjson), and closing the request stops cargo.

curl -N -X POST "http://localhost:7777/api/games/<game_id>/build-executable" \
  -H "Authorization: Bearer YOUR_TOKEN"
{"type":"status","message":"Compiling My Game with cargo","project_path":"/path/to/workspace/game-builds/..."}
{"type":"log","line":"   Compiling tauri v2..."}
{"type":"done","binary_path":".../bin/my-game","project_path":"..."}

A failed build ends the stream with {"type":"error","detail":"..."}, which adds runtime_package when a runtime package such as rust is missing. Before the stream starts, the route answers 403 when the cloud profile is active (NODETOOL_NODE_PROFILE=cloud, or NODE_ENV=production with the profile unset), 503 with runtime_package: "rust" when cargo is not installed, 404 for a game the caller does not own, and 422 when the workspace is not on this machine’s disk. All games share one cargo target directory, so only the first build compiles Tauri itself.

What This Server Supports

GET /api/sdk/v1/capabilities is the handshake an SDK client makes before anything else: which route families are live, which wire encodings the server speaks, what an execution request may ask for, and the numeric limits it will enforce. Read it once at startup instead of hard-coding assumptions about the server on the other end.

curl "http://localhost:7777/api/sdk/v1/capabilities" \
  -H "Authorization: Bearer YOUR_TOKEN"
{
  "protocol_version": "1",
  "nodetool_version": "0.7.0-rc.36",
  "server_time": "2026-08-21T06:09:57.898Z",
  "supported_encodings": ["messagepack", "json-text"],
  "default_encoding": "messagepack",
  "profiles": {
    "discovery": "available",
    "execution": "available",
    "preflight": "available",
    "model_catalog": "available",
    "model_download": "available",
    "temporary_asset_upload": "available"
  },
  "registry_revision": 2723,
  "python_bridge": "starting",
  "auth_modes": ["trusted_local"],
  "asset_uri_schemes": ["asset"],
  "execution_options": {
    "persistence": ["job", "session"],
    "event_detail": ["full", "outputs", "terminal"],
    "asset_persistence": ["auto", "temporary"],
    "defaults": {
      "persistence": "job",
      "event_detail": "full",
      "asset_persistence": "temporary"
    }
  },
  "limits": {
    "max_rpc_batch": 100,
    "max_inline_bytes": 0,
    "max_upload_bytes": 1073741824,
    "max_queued_jobs": 0,
    "max_job_event_replay": 0,
    "request_timeout_seconds": 30
  }
}

Each entry in profiles is available, disabled, or unavailable. On this server the map is a fixed list that always reports available, so it names the route families that exist rather than reporting which are switched on — a disabled family still shows available here and answers 503 when called. Do not gate on it; handle the 503.

In limits, only max_upload_bytes is a ceiling the server enforces — it tracks NODETOOL_MAX_UPLOAD_BYTES (1 GiB by default) and is what /api/sdk/v1/assets/temporary rejects an over-size file against. The rest are advertised figures this server does not itself enforce, and the zeros are not limits of zero: max_inline_bytes is 0 because the profile carries media by asset reference rather than promising any inline payload size, and max_queued_jobs and max_job_event_replay are 0 because it states no bound for them.

auth_modes is trusted_local on a server with no enforcing auth provider and bearer once one is configured, so a client can tell whether it needs a token before it sends a request without one. python_bridge is ready once the Python bridge has connected and starting until then — including on a server with no Python installed, where it stays starting indefinitely rather than reporting unavailable. A workflow using Python nodes is not runnable until it reads ready. registry_revision is the same counter the node inventory and workflow summaries report, so a client can tell whether the node registry moved under it.

Two environment flags can switch parts of this family off, and they cover different routes. NODETOOL_DISABLE_SDK_LIFECYCLE_V1=1 disables this route, /preflight, and /assets/temporary, which then answer 503 with {"code": "SDK_LIFECYCLE_DISABLED", …}. NODETOOL_DISABLE_SDK_WORKFLOW_INTERFACE_V1=1 disables the discovery routes — /node-types, /workflows, /workflow-interfaces, and /api/sdk/v1/workflows/{id}/interface — which answer 503 with SDK_WORKFLOW_INTERFACE_DISABLED, except /node-types, which reports SDK_NODE_TYPE_INVENTORY_DISABLED. The model catalog at /api/sdk/v1/models and the model-download routes are unaffected by either. See Configuration.

The Node Type Inventory

GET /api/sdk/v1/node-types reports the pin types the loaded registry actually uses — one entry per type signature, how many nodes read or write it, and example pins to look at. Where /api/nodes/metadata is indexed by node, this is indexed by type, so it is the route to ask what a given type connects to without walking every node’s metadata.

curl "http://localhost:7777/api/sdk/v1/node-types?limit=2" \
  -H "Authorization: Bearer YOUR_TOKEN"
{
  "version": 1,
  "registry_revision": 2723,
  "registry_ready": true,
  "python_bridge_ready": false,
  "node_count": 2722,
  "type_count": 1158,
  "provenance_counts": { "typescript": 2722 },
  "cursor": 0,
  "next_cursor": 2,
  "types": [
    {
      "signature": "any",
      "type": "any",
      "type_name": null,
      "optional": false,
      "type_args": [],
      "values": [],
      "values_truncated": false,
      "input_uses": 74,
      "output_uses": 54,
      "node_count": 67,
      "sources": { "typescript": 128 },
      "examples": [
        {
          "node_type": "fal.vision.ArbiterImage",
          "pin": "values",
          "direction": "output"
        }
      ]
    }
  ],
  "unavailable_packs": [
    {
      "id": "elevenlabs",
      "name": "ElevenLabs",
      "reason": "disabled by built-in pack configuration"
    }
  ]
}

cursor (default 0) and limit (1–100) page through the list; anything outside that range is a 400 ({"code": "INVALID_INPUT", …}). next_cursor is what to pass as the next cursor.

node_count appears at both levels and means different things: at the top it is every node in the registry, and inside a type entry it is the nodes using that type. type_count is how many entries exist across all pages, and provenance_counts splits the registry by where the nodes came from — typescript here, with Python nodes appearing once the bridge is up.

input_uses and output_uses count pins, not nodes, so one node with two pins of a type counts twice — node_count is the number of distinct node types. examples is capped at 5 entries, and for an enum type values is capped at 64 with values_truncated set when there were more. unavailable_packs names packs whose nodes are missing from these counts and why, so a type that looks absent can be traced to a pack that never loaded rather than to a registry bug. python_bridge_ready is false until the Python bridge connects, and Python-only types are absent while it is.

A Workflow’s Input and Output Pins

Two routes answer what a workflow takes and returns, without fetching its graph. Use them to build a form, validate a parameter bag, or decide which outputs to subscribe to.

GET /api/sdk/v1/workflows/{id}/interface?version=1 covers one workflow. The version=1 query parameter is required — omitting it is a 400 ({"code": "UNSUPPORTED_WORKFLOW_INTERFACE_VERSION", …}), not a default:

curl "http://localhost:7777/api/sdk/v1/workflows/<workflow_id>/interface?version=1" \
  -H "Authorization: Bearer YOUR_TOKEN"
{
  "version": 1,
  "workflow_id": "23486bda2a46425fa8559f47c7c0adc9",
  "etag": "777f80b8651d2e56d7e1861a11f12667",
  "source": "server",
  "inputs": [
    {
      "node_id": "in1",
      "name": "prompt",
      "description": "Text to echo",
      "type": { "type": "str", "optional": false, "type_args": [], "type_name": null },
      "required": true,
      "default": ""
    }
  ],
  "outputs": [
    {
      "node_id": "out1",
      "name": "result",
      "description": "The echoed text",
      "type": { "type": "str", "optional": false, "type_args": [], "type_name": null },
      "stream": false
    }
  ],
  "diagnostics": []
}

An input pin’s name is the key to use in a run’s params; an output pin’s name is the key its value arrives under. stream marks an output that emits repeatedly rather than once. etag changes whenever the interface changes, so a client can cache a form against it and pass it back as workflow_etag on a preflight. A graph the server can read but not interpret comes back with entries in diagnostics (severity, code, message, and the node_id / pin_name at fault) rather than an error.

POST /api/sdk/v1/workflow-interfaces does the same for 1–100 workflows in one call. Both fields are required, and the ids must be unique:

curl -X POST "http://localhost:7777/api/sdk/v1/workflow-interfaces" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"ids": ["23486bda2a46425fa8559f47c7c0adc9"], "version": 1}'

Each entry of interfaces is exactly the object the single-workflow route returns. One bad id does not fail the batch — it lands in errors instead, and the rest still come back:

{
  "interfaces": [
    { "version": 1, "workflow_id": "23486bda2a46425fa8559f47c7c0adc9", "etag": "777f80b8651d2e56d7e1861a11f12667", "source": "server", "inputs": [], "outputs": [], "diagnostics": [] }
  ],
  "errors": [
    { "workflow_id": "deadbeef", "code": "workflow_not_found", "message": "Workflow not found" }
  ]
}

code is workflow_not_found or invalid_graph. A malformed body — an empty list, over 100 ids, duplicate ids, or a missing version — is a 400 ({"code": "INVALID_INPUT", "message": "Expected 1 to 100 unique workflow ids"}) and nothing is returned.

To find the ids in the first place, GET /api/sdk/v1/workflows lists the caller’s workflows in the shape this family uses:

curl "http://localhost:7777/api/sdk/v1/workflows?limit=2" \
  -H "Authorization: Bearer YOUR_TOKEN"
{
  "workflows": [
    {
      "id": "23486bda2a46425fa8559f47c7c0adc9",
      "name": "Interface Demo",
      "description": "Echoes a string input to an output",
      "revision": "2026-08-21T06:10:04.279Z",
      "registry_revision": 2723,
      "run_mode": "workflow"
    }
  ],
  "next": null
}

limit is 1–100, default 50; outside that range is a 400. Page by passing next back as cursor until it is null. revision is the workflow’s own last-modified stamp and registry_revision the registry’s, so a cached interface can be invalidated by either moving. This is the discovery-shaped sibling of /api/workflows — that route returns whole workflow records including graphs, this one returns only what a client needs to pick one.

Checking a Workflow Before Running It

POST /api/sdk/v1/preflight answers whether a workflow can run right now, what it needs first, and what it will cost — before a job row exists and before any node is paid for:

curl -X POST "http://localhost:7777/api/sdk/v1/preflight" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "workflow_id": "23486bda2a46425fa8559f47c7c0adc9",
    "workspace_id": null,
    "workflow_etag": null,
    "interface_version": 1,
    "level": "availability",
    "inputs": {"prompt": "hi"}
  }'
{
  "version": 1,
  "level": "availability",
  "workflow_id": "23486bda2a46425fa8559f47c7c0adc9",
  "workflow_etag": "777f80b8651d2e56d7e1861a11f12667",
  "runnable": true,
  "issues": [],
  "requirements": [
    {
      "kind": "node_pack",
      "id": "base",
      "name": "base",
      "status": "available",
      "blocking": true,
      "message": null,
      "details": { "node_ids": ["in1", "out1"] }
    }
  ],
  "cost": {
    "amount": 0,
    "currency": "USD",
    "confidence": "exact",
    "unknown_cost_nodes": [],
    "approval_required": false
  }
}

level picks how much work the check does, and each level costs more than the one before:

Level What it decides
static The graph and the inputs, against the registry. Requirements are listed but their status is unknown — nothing is probed
availability The same, plus a probe of each requirement: a pack becomes available or missing, a model available, downloading, or unavailable, a credential present or not. A probe that cannot decide reports unknown
execution The same, plus whether there is capacity to run it now — adds worker requirements for the server itself and for execution capacity

A requirement’s kind is one of provider, credential, model, node_pack, runtime, asset, worker, or approval; blocking says whether runnable turns false without it. issues carries validation findings with severity warning or error — at execution level a server that cannot read its own capacity reports the warning execution_capacity_unknown and stays runnable, because an unknown queue depth is not a broken workflow.

cost.confidence separates two different kinds of doubt. exact means every node priced exactly (as it does for a graph with nothing billable in it, where amount is 0). estimate means everything priced but at least one price is itself an estimate. partial means some nodes could not be priced at all, and those are the ones unknown_cost_nodes names. unknown means none could — and that is the one case where amount is null rather than a number, so read confidence before displaying amount. approval_required compares the total against the server’s own configured threshold, so gate a spend prompt on it rather than on a threshold of the client’s.

Passing the etag from the workflow’s interface as workflow_etag checks the graph has not moved since you read it. When it has, the answer is still a 200 — runnable turns false, issues gains an error with code workflow_etag_mismatch, and the response’s own workflow_etag is the current value, so a client can re-read the interface and try again. Pass null to skip the check. execution_target picks where the run would go — {"kind": "local"}, {"kind": "worker", "worker_id": …}, or {"kind": "runner", "runner_id": …} — and defaults to local.

Every other field is required, including workspace_id and inputs, which take null and {} rather than being omitted. A body missing one is a 400 ({"code": "INVALID_REQUEST", "message": "Invalid preflight request"}).

Uploading an Execution Input

POST /api/sdk/v1/assets/temporary puts one file where a workflow run can read it, without creating an asset row, a thumbnail, or anything the user will later have to clean out of their library. Use it for a run’s inputs; use POST /api/assets when the file belongs in the asset library.

The request is multipart/form-data with the file in a field named file:

curl -X POST "http://localhost:7777/api/sdk/v1/assets/temporary" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -F "file=@sample.txt;type=text/plain"
{
  "version": 1,
  "uri": "/api/storage/temp/sdk-inputs/7d0571c6-6d9c-4c3c-9cc2-292f2b0a8574.txt",
  "name": "sample.txt",
  "content_type": "text/plain",
  "size": 15,
  "expires_at": null
}

Pass uri as the value of the workflow input that takes the file. expires_at is null when the configured temporary store sets no expiry — retention is the store’s, not this route’s, so nothing here promises the file survives until the run starts. A body that is not multipart, or multipart without a file field, is a 400 — {"code": "INVALID_REQUEST", "message": "Expected multipart/form-data"} or "Multipart field 'file' is required". A file over the server’s upload limit — limits.max_upload_bytes from the capabilities route, 1 GiB by default — is a 413 ({"code": "UPLOAD_TOO_LARGE", …}).

Running a Saved JS Script

POST /api/js-scripts/{id}/run executes a stored JS script document in the QuickJS sandbox and returns what it emitted. The web editor reads and writes scripts over /trpc/jsScripts.*; this is the one plain-HTTP door, for a run console, a mini app, or a client driving a script it did not author.

inputs is keyed by the script’s declared input ports. For a script declaring numbers whose body is:

const total = inputs.numbers.reduce((a, b) => a + b, 0);
await output("total", total);
curl -X POST "http://localhost:7777/api/js-scripts/<script_id>/run" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"inputs": {"numbers": [1, 2, 3, 4]}}'
{
  "ok": true,
  "logs": [],
  "duration_ms": 41,
  "outputs": { "total": 10 },
  "streamed": []
}

outputs is keyed by the script’s declared output ports, and streamed holds its emit calls in order — empty here because this body makes none.

A body that pulls its inputs with stream rather than reading the inputs object is fed through input_streams, which stages a list of items per input handle. For a script whose body is:

let total = 0;
for await (const n of stream("numbers")) {
  total += n;
  await emit("running", total);
}
await output("total", total);
curl -X POST "http://localhost:7777/api/js-scripts/<script_id>/run" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"inputs": {}, "input_streams": {"numbers": [1, 2, 3, 4]}}'
{
  "ok": true,
  "logs": [],
  "duration_ms": 55,
  "outputs": { "total": 10 },
  "streamed": [
    { "name": "running", "value": 1 },
    { "name": "running", "value": 3 },
    { "name": "running", "value": 6 },
    { "name": "running", "value": 10 }
  ]
}

The two are not interchangeable: staging items for a body that reads inputs.numbers leaves that name undefined, and the run fails. A handle the script does not declare as an input is a 400:

{
  "detail": "input_streams names nope, which this script does not declare as inputs"
}

The script runs inside its own envelope — the packs its body imports, its declared secrets, and its own timeoutSeconds — so nothing in the request widens what it may reach. A body that throws is not an HTTP error: the response is 200 with ok: false and the message in error, because a script that failed is a result to show, not a transport failure.

{
  "ok": false,
  "logs": [],
  "duration_ms": 29,
  "error": "TypeError: cannot read property 'reduce' of undefined"
}

Scripts are per-user. An id belonging to someone else is a 404 ({"detail": "JS script not found"}), the same answer as an id that does not exist. The response is plain JSON — there is no streaming surface here, so progress() calls do not arrive as they happen.

Listing Models an SDK Client Can Use

GET /api/sdk/v1/models is the model catalog behind the SDK’s discovery profile: every model the server knows about, what state it is in, and the exact value to put in a node’s model property. Reach for it when a client has to offer a model picker without hard-coding ids.

curl "http://localhost:7777/api/sdk/v1/models?availability=downloadable&limit=1" \
  -H "Authorization: Bearer YOUR_TOKEN"
{
  "version": "1",
  "catalog_revision": "7db91c5aa30bc13588a9e4437d7ff7cabe88d82fbfc8049c8d507825326a10b6",
  "scope": "local",
  "entries": [
    {
      "key": "llama_cpp_model||ggml-org/gemma-3-12b-it-GGUF|gemma-3-12b-it-Q4_K_M.gguf",
      "display_name": "Gemma 3 12B IT (GGUF)",
      "compatibility": "llama_cpp_model",
      "availability": "downloadable",
      "recommended": true,
      "scope": "local",
      "provider": null,
      "id": "ggml-org/gemma-3-12b-it-GGUF:gemma-3-12b-it-Q4_K_M.gguf",
      "repo_id": "ggml-org/gemma-3-12b-it-GGUF",
      "path": "gemma-3-12b-it-Q4_K_M.gguf",
      "supported_tasks": [],
      "size_on_disk": 7838315315,
      "wire_value": {
        "type": "llama_cpp_model",
        "repo_id": "ggml-org/gemma-3-12b-it-GGUF",
        "path": "gemma-3-12b-it-Q4_K_M.gguf"
      }
    }
  ],
  "next_cursor": "llama_cpp_model||ggml-org/gemma-3-12b-it-GGUF|gemma-3-12b-it-Q4_K_M.gguf"
}

wire_value is the whole point of an entry: assign it to a node’s model property and the graph runs. availability is one of five values, decided in this order: a download in flight is downloading, a model already in the local cache is ready_local, a remote provider’s model is ready_remote once that provider is configured, and a recommended repository model (Hugging Face or GGUF) you have not fetched yet is downloadable. Everything else — including a remote model whose provider has no API key — is unavailable.

Query parameters, all optional:

Parameter Meaning
compatibility Keep only entries of one node-property type, e.g. llama_cpp_model
availability One of the five availability values
provider Keep only one provider’s models, e.g. ollama
scope local (default) or worker
cursor The previous page’s next_cursor
limit 1–500, default 200

Page by passing next_cursor back as cursor; it is null on the last page. catalog_revision hashes the entries a query produced, so a client can tell whether anything moved without diffing the list. A value outside the allowed set is a 400 ({"code": "INVALID_INPUT", …}), not a silently ignored filter.

scope=worker reads the models cached on the attached Python worker instead of the server’s own. With no worker attached that is a 501:

{
  "code": "MODEL_SCOPE_UNAVAILABLE",
  "message": "No worker is attached to this server.",
  "detail": "No worker is attached to this server.",
  "retryable": true
}

Downloading a Model over the SDK Routes

Three routes cover the download lifecycle without a WebSocket: start one, poll the snapshot, cancel it. They drive the same download manager the /ws/download WebSocket does, so a headless client fetches a model exactly as the editor’s model manager would — it just polls instead of subscribing.

POST /api/sdk/v1/model-downloads starts a download and answers 202 with the operation’s first state:

curl -X POST "http://localhost:7777/api/sdk/v1/model-downloads" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"repo_id": "hf-internal-testing/tiny-random-gpt2", "model_type": "hf.text_generation"}'
{
  "version": "1",
  "operation_id": "mdl_nY06SIV5ikFSqVqIvWOHQqIKQjJtkikk0VpLuRZszTc",
  "scope": "local",
  "repo_id": "hf-internal-testing/tiny-random-gpt2",
  "path": null,
  "model_type": "hf.text_generation",
  "status": "start",
  "downloaded_bytes": 0,
  "total_bytes": 0,
  "downloaded_files": 0,
  "current_files": [],
  "total_files": 0,
  "error": null,
  "started_at": "2026-08-06T08:16:40.600Z",
  "updated_at": "2026-08-06T08:16:40.600Z"
}

repo_id and model_type are required. path fetches a single file from the repo — a .gguf weight, say — and cannot be combined with allow_patterns or ignore_patterns, which otherwise take glob lists to narrow a whole-repo fetch. scope defaults to local; worker hands the download to the attached Python worker.

The operation_id is derived from the request, so starting the same download twice returns the state already in flight rather than a second run.

GET /api/sdk/v1/model-downloads is the snapshot, newest update first. It takes an optional scope (default local) and operation_id to narrow to one:

curl "http://localhost:7777/api/sdk/v1/model-downloads" \
  -H "Authorization: Bearer YOUR_TOKEN"
{
  "version": "1",
  "downloads": [
    {
      "version": "1",
      "operation_id": "mdl_nY06SIV5ikFSqVqIvWOHQqIKQjJtkikk0VpLuRZszTc",
      "scope": "local",
      "repo_id": "hf-internal-testing/tiny-random-gpt2",
      "path": null,
      "model_type": "hf.text_generation",
      "status": "cancelled",
      "downloaded_bytes": 0,
      "total_bytes": 488544,
      "downloaded_files": 0,
      "current_files": [],
      "total_files": 8,
      "error": null,
      "started_at": "2026-08-06T08:16:40.600Z",
      "updated_at": "2026-08-06T08:16:48.638Z"
    }
  ]
}

status walks start → progress → completed, or ends at error or cancelled; error carries the message when it does. Finished operations stay in the snapshot so a client that reconnects can see how a download ended — the oldest terminal ones are dropped past 200 retained operations.

POST /api/sdk/v1/model-downloads/cancel stops one and returns its final state:

curl -X POST "http://localhost:7777/api/sdk/v1/model-downloads/cancel" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"operation_id": "mdl_nY06SIV5ikFSqVqIvWOHQqIKQjJtkikk0VpLuRZszTc"}'

Cancelling an operation that already finished returns the state it settled in. An id this caller never started is a 404 ({"code": "MODEL_DOWNLOAD_NOT_FOUND", …}). Ollama models are pulled by Ollama itself, so "model_type": "llama_model" is a 501 ({"code": "MODEL_DOWNLOAD_UNAVAILABLE", …}).

Downloading the Chrome Extension

GET /api/extension/download zips up the browser extension build the server can find and hands it over, so you can load it unpacked without cloning the repo.

curl "http://localhost:7777/api/extension/download" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -o nodetool-chrome-extension.zip

The response is application/zip with content-disposition: attachment; filename="nodetool-chrome-extension.zip". The server looks for the build at NODETOOL_EXTENSION_DIST (set by the desktop app to its bundled copy), then walks up from its own directory and the working directory looking for chrome-extension/dist/manifest.json. When none of those holds a build, the answer is 404 with {"detail": "Extension build not found"} — build it first, per Chrome Extension.

How This Server Is Configured

A client has to decide whether to show a login screen before it has a token, so GET /api/config needs none. It reports the auth mode the server picked and the values a browser needs to complete a sign-in.

curl "http://localhost:7777/api/config"
{
  "authMode": "local",
  "supabaseUrl": null,
  "supabaseAnonKey": null,
  "authRedirectUrl": null,
  "googleWorkspace": false,
  "googleScopes": [],
  "version": "0.7.0-rc.38"
}

authMode is "supabase" when both SUPABASE_URL and SUPABASE_KEY are set, and "local" otherwise — the same choice the server’s own auth hook makes, so a client never has to guess it from the other fields. supabaseUrl, supabaseAnonKey and authRedirectUrl mirror SUPABASE_URL, SUPABASE_ANON_KEY and AUTH_REDIRECT_URL; each is null when unset. Note the anon key is the public one — the service key (SUPABASE_KEY) is never in this response.

googleWorkspace says whether the Google Workspace capability is on (see NODETOOL_GOOGLE_WORKSPACE in Configuration). When it is, googleScopes lists the OAuth scopes the connect step must request; when it is not, that array is empty.

Exporting or Erasing Your Account Data

Two routes answer the data-subject rights the privacy policy promises: GET /api/account/export hands back everything this server holds about the caller, and DELETE /api/account erases it. Neither takes a subject — the user id comes from the identity the auth hook resolved, so a request carrying none is a 401 rather than falling back to the local user 1. There is no admin path and no way to name somebody else’s account, in the path, the query or the body.

curl "http://localhost:7777/api/account/export" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -O -J

The response is application/json with cache-control: no-store and a content-disposition of attachment; filename="nodetool-personal-data-export-2026-06-20.json", dated the day it was generated, so curl -OJ writes it under that name. Inside is one entry per table:

{
  "format": "nodetool.personal-data-export/1",
  "subjectUserId": "1",
  "generatedAt": "2026-06-20T09:12:33.921Z",
  "tables": {
    "nodetool_workflows": {
      "disposition": "delete",
      "reach": { "kind": "direct", "column": "user_id" },
      "rowCount": 12,
      "truncated": false,
      "withheldColumns": [],
      "rows": [{ "id": "wf_9f3c", "name": "Daily digest", "graph": "…" }]
    },
    "nodetool_secrets": {
      "disposition": "delete",
      "reach": { "kind": "direct", "column": "user_id" },
      "rowCount": 3,
      "truncated": false,
      "withheldColumns": ["encrypted_value"],
      "rows": [
        {
          "key": "OPENAI_API_KEY",
          "encrypted_value": "[withheld: credential material]"
        }
      ]
    }
  },
  "excluded": [
    {
      "table": "mcp_oauth_clients",
      "reason": "A dynamically registered OAuth client (RFC 7591): name plus redirect URIs, with no owner column. …"
    }
  ]
}

disposition is what erasure would do with that table — delete, redact, retain, anonymize, or not-personal — and reach is how the rows are found, either a direct user-id column or an indirect join through a parent table. withheldColumns names the columns replaced with the string [withheld: credential material]: an API key, an OAuth token or a token hash is listed by name and never by value, so the export says which credentials existed without minting a fresh readable copy of a live one. Each table is capped at 50 000 rows and sets truncated: true when it hits the cap. excluded lists the tables holding nothing portable for this person, each with the reason from the registry. Which table gets which treatment is PERSONAL_DATA_REGISTRY in packages/models/src/personal-data-registry.ts.

Erasure is irreversible, so it fires only on the exact phrase:

curl -X DELETE "http://localhost:7777/api/account" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"confirm": "DELETE MY ACCOUNT"}'

Anything else — no body, {}, a different string, or an extra field such as user_id — is a 400 and nothing is touched, so a stray or replayed DELETE cannot erase an account:

{
  "detail": "Erasure is irreversible. Send {\"confirm\": \"DELETE MY ACCOUNT\"} to confirm."
}

A confirmed call answers 200 with the request id that ties the audit rows together and a per-table report:

{
  "requestId": "8629a9bc-052f-4af9-96fd-56779be0d94e",
  "report": {
    "userId": "1",
    "completedAt": "2026-06-20T09:14:02.550Z",
    "tables": [
      { "table": "nodetool_workflows", "disposition": "delete", "deleted": 12, "redacted": 0, "retained": 0 },
      { "table": "nodetool_predictions", "disposition": "redact", "deleted": 0, "redacted": 340, "retained": 340 }
    ],
    "deleted": 812,
    "redacted": 340,
    "retained": 353,
    "objectKeysDeleted": ["1/9f3c1a2b.png", "1/a04ef881.mp4"]
  }
}

A redact row is kept and stripped: nodetool_predictions is the billing ledger, so cost, tokens, model, provider and timestamps survive while parameters, metadata and logs are nulled. objectKeysDeleted names the stored objects removed under the caller’s <userId>/ prefix, so the bytes go with the rows rather than staying readable to anyone holding a URL.

The call is idempotent — a second one re-runs the same sweep, finds nothing, and answers 200 with zeros. The data_erasure_requested and data_erasure_completed events survive the sweep as the record that the request arrived and was answered.

Provider Sign-in

/api/oauth/* runs the credential flows for outside accounts. Only the two browser redirect targets skip session auth, because the identity provider sends the user back with no Authorization header. Every other path reads or changes credentials stored for the caller. Methods that do not match answer 405.

Provider Routes
Hugging Face GET /api/oauth/hf/start (returns {"auth_url": ...}), GET /api/oauth/hf/callback (public), GET /api/oauth/hf/tokens, POST /api/oauth/hf/refresh, GET /api/oauth/hf/whoami
GitHub GET /api/oauth/github/start, GET /api/oauth/github/callback (public), GET /api/oauth/github/tokens, GET /api/oauth/github/user
OpenAI (Codex) GET /api/oauth/openai/start, POST /api/oauth/openai/complete, GET /api/oauth/openai/tokens, POST /api/oauth/openai/disconnect
Claude subscription GET /api/oauth/claude/start, POST /api/oauth/claude/complete, GET /api/oauth/claude/tokens, POST /api/oauth/claude/disconnect
Google Workspace POST /api/oauth/google/session, GET /api/oauth/google/tokens, POST /api/oauth/google/disconnect. The session route takes the Supabase Google login’s access_token and answers 404 unless NODETOOL_GOOGLE_WORKSPACE is on

The Claude subscription login is one per server, shared by every user’s Claude Agent SDK calls. Only an admin may start, complete or disconnect it, and other users get 403. An admin is user "1" in Local mode, an id listed in ADMIN_USER_IDS, or an API user with the admin role. For other users, tokens reports whether the server is connected but leaves out the plan and the credentials path.

Hugging Face and GitHub use PKCE with a single-use state. The start route builds the redirect URI from the request’s Host header. Token routes return metadata (scope, expiry), never the token itself. Any other path under /api/oauth/ is a 404.

Streaming a Local File

GET /api/files/local?path=<absolute path> streams one file from the machine the server runs on, with Range support. The editor uses it to preview file:// media. The path must resolve inside a configured root (the home directory by default), both lexically and after symlinks. A missing path or a directory is a 400, a path outside the roots is a 403, and a missing file is a 404. With NODETOOL_ENV=production every request is a 403.

Indexing a File into a Collection

POST /api/collections/{name}/index takes a multipart/form-data body with one file field, splits the text into chunks, and adds them to the vector collection. The response is {"path": "<file name>", "chunks": 12, "error": null}. A collection that does not exist or belongs to someone else is a 404, a body that is not multipart or has no file is a 400, and a file over NODETOOL_MAX_UPLOAD_BYTES is a 413. Create, list, query, and delete are tRPC procedures on collections.

Extracting Text from a Document

POST /api/documents/extract-text reads one PDF or DOCX sent as the file field of a multipart body and returns {"text": "...", "pages": 3} (pages only for PDF). Nothing is stored. The limit is 25 MB (413). The type comes from the content type, or from the file extension when the content type is application/octet-stream. Another type is a 415. A file that cannot be parsed, or a PDF or DOCX with no text, is a 422. Script and screenplay imports use it.

Timeline Operations

Four routes run timeline edits that need the server. The first three read the stored sequence, call a capability, save the result, and return it. The editor then reloads the document. A refusal from the capability, such as a missing clip, is a 400 with {"detail": "..."}. A body that fails validation is also a 400 and the detail names the field.

Route Body What it does
POST /api/timelines/{id}/track-object clip_id, initial_region (x, y, width, height), start_ms, end_ms, optional provider, model, name, direction (forward, backward, both), track_id, regenerate Tracks a subject through a clip and stores the track
POST /api/timelines/{id}/bake-audio-animation audio_clip_id, target_clip_id, property (scale, opacity, offsetX, offsetY), output_range ([quiet, loud]), optional mode (envelope, beats), sensitivity, attack_ms, release_ms, offset_ms, tolerance, max_points, frame_ms, max_seconds, replace Turns an audio clip’s loudness or beats into keyframes on the target clip
POST /api/timelines/{id}/isolate-subject clip_id, provider, model (a model the provider lists for matte_video, such as fal-ai/birefnet/v2/video/Matting), optional regenerate, background Generates a matte for the clip’s subject. A generation that fails answers 200 with status: "failed" and keeps the previous matte
POST /api/timelines/animations/bake code or script_id (exactly one), role (in, out, emphasis, loop), duration_ms, clip_duration_ms, canvas (width, height), optional params, stagger_count, sample_count Runs a custom animation body once in the sandbox, with no secrets and no toolbelt, and returns {ok, curves, logs, duration_ms}. A script_id that is not the caller’s is a 404

Deployed App Routes

GET /api/apps/{token} returns a deployed mini app’s released document and POST /api/apps/{token}/session mints the nda_ token its visitor runs on over /ws. The deployment token in the path is the credential, so neither route uses session auth. Both answer 404 with {"detail": "This app is not available"} for an unknown or revoked token, and for every request outside production. The GET response is gzip-compressed when the client accepts it.

GET /api/shared-workflows/{token} returns the name, description, tags, and graph of the workflow behind a public share link. The owner mints the link with workflows.sharing.createLink and role: "public", and revokes it with workflows.sharing.revokeLink. The token is the credential, so the route uses no session auth. It answers 404 with {"detail": "This workflow is not available"} for an unknown, revoked, view, or edit token. A public link grants no role. A signed-in user copies the workflow into their own workflows with the workflows.sharing.duplicatePublic tRPC mutation, which creates a new private workflow.

Provider Callbacks

Three routes receive results from providers that call back instead of making the server poll.

Route Authentication Behavior
POST /api/kie/webhook None Reads taskId (or task_id, also under data) and the status, resolves or rejects the waiting run, and answers {"status": "accepted"}. A body with no task id is a 400
POST /api/providers/fal/webhook/{token} Ed25519 signature over the raw body, in x-fal-webhook-* headers Verifies the signature and request id, records the result in the durable generation inbox, and answers {"status": "accepted", "duplicate": false}. A bad signature is a 400. A transient verification or inbox failure is a 503 so fal retries
POST /api/providers/atlascloud/webhook Ed25519 signature over the raw body, in x-atlascloud-webhook-* headers Same pattern for AtlasCloud predictions

The fal and AtlasCloud routes allow 120 requests per minute.

Sandbox Modules

GET /api/sandbox-modules/{module id} serves the source of a guest module to the browser sandbox. The id is a pack entry specifier such as @acme/geo, or an internal file id such as @acme/geo::sandbox/helper.js. The response carries the module as its body with an ETag, Cache-Control: private, no-cache, and headers that name its digest, SHA-256, pack, file id, and dependency list. An unknown id is a 404, and a module the caller may not receive is a 403.

MCP over HTTP

/mcp is the Model Context Protocol endpoint. It binds the user the auth hook resolved, so an agent presents an ntk_ access token or an nta_ OAuth token. Without a user, initialize answers 401. In production it registers only with NODETOOL_ENABLE_MCP=1. See MCP OAuth Discovery and MCP in production.

Health Check

# Check if server is running (no auth required)
curl "http://localhost:7777/health"

Response:

{
  "status": "ok",
  "timestamp": "2026-06-20T00:00:00.000Z",
  "uptime": 123,
  "turns": 0,
  "jobs": 0,
  "services": { "database": "ok", "server": "ok" }
}

turns and jobs count the chat turns and workflow runs this process is still executing. status is "degraded" with a 503 when the database check fails, and "draining" with a 503 after the server receives SIGUSR2. See Draining.

CLI Workflow Execution

You can also run workflows from the command line. nodetool run executes a TypeScript/JavaScript DSL workflow file:

# Run a DSL workflow file
nodetool run ./my_workflow.ts

# Output results as JSON
nodetool run ./my_workflow.ts --json

To run a saved workflow by ID (requires a running server), use the workflows subcommands:

# List workflows
nodetool workflows list

# Run a workflow by ID
nodetool workflows run workflow_abc123 --params '{"prompt": "test"}'

TypeScript / Node.js Client Example

const BASE_URL = 'http://localhost:7777';
const TOKEN = 'your_token_here';

// Run a workflow (runs to completion, returns one JSON response)
async function runWorkflow(workflowId, params) {
  const response = await fetch(`${BASE_URL}/api/workflows/${workflowId}/run`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${TOKEN}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ params })
  });
  const body = await response.json();
  // body: { job_id, workflow_id, status, outputs, error, message_count, background }
  return body.outputs;
}

// For real-time progress, run the workflow over the WebSocket endpoint instead.
// See the WebSocket API page and examples/workflow_runner/js/workflow-runner.js.

// Using OpenAI SDK (works with NodeTool!)
import OpenAI from 'openai';

const openai = new OpenAI({
  apiKey: TOKEN,
  baseURL: `${BASE_URL}/v1`
});

const completion = await openai.chat.completions.create({
  model: 'gpt-5.6',
  messages: [{ role: 'user', content: 'Hello!' }]
});

console.log(completion.choices[0].message.content);

Python Client Example

import requests

BASE_URL = "http://localhost:7777"
TOKEN = "your_token_here"  # Not needed for local development
HEADERS = {
    "Authorization": f"Bearer {TOKEN}",
    "Content-Type": "application/json",
}

# List workflows
workflows = requests.get(f"{BASE_URL}/api/workflows", headers=HEADERS).json()["workflows"]

# Run a workflow (runs to completion, returns one JSON response)
result = requests.post(
    f"{BASE_URL}/api/workflows/{workflows[0]['id']}/run",
    headers=HEADERS,
    json={"params": {"prompt": "A sunset over mountains"}},
).json()
# result: {"job_id", "workflow_id", "status", "outputs", "error", "message_count", "background"}
print("Outputs:", result["outputs"])

# For real-time progress, run the workflow over the WebSocket endpoint instead.

# Use with OpenAI Python SDK (works with NodeTool!)
from openai import OpenAI

client = OpenAI(api_key=TOKEN, base_url=f"{BASE_URL}/v1")
completion = client.chat.completions.create(
    model="gpt-5.6",
    messages=[{"role": "user", "content": "Hello!"}],
)
print(completion.choices[0].message.content)

Finding Your Workflow ID

To run a workflow via API, you need its ID. Here’s how to find it:

  1. From the UI: Open a workflow in the editor — the ID appears in the browser URL bar
  2. From the API: Call GET /api/workflows to list all workflows with their IDs
  3. From the CLI: Run nodetool workflows list

Error Handling

API errors return standard HTTP status codes. The body shape depends on the route family:

Family Body
Most REST routes {"detail": "Workflow not found"}, sometimes with a code such as INVALID_INPUT
/api/sdk/v1/* {"code": "INVALID_REQUEST", "message": "..."}
/v1/* OpenAI shape, {"error": {"message", "type", "param", "code"}}
Webhook and provider callbacks {"error": "..."}
/trpc tRPC error object with data.apiCode and data.zodError
Status Code Meaning Common Causes
400 Bad Request Invalid parameters, malformed JSON, a graph the server refuses to run
401 Unauthorized Missing or invalid token
403 Forbidden Production guard on local-file and workspace routes, path traversal
404 Not Found Resource missing, or owned by someone else
409 Conflict A workflow save with a stale expected_updated_at
422 Unprocessable A document or media file that cannot be read
429 Too Many Requests Rate limit, or too many live debug sessions
500 Internal Error Server-side error
501 Not Implemented Stub routes such as bulk asset download
503 Service Unavailable A feature flag is off, ffmpeg is missing, or the server is draining