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/completionsand/v1/models. - WebSocket endpoints for workflow execution, chat, the browser extension, and downloads.
- An MCP endpoint at
/mcpand its OAuth routes. In production/mcpregisters only withNODETOOL_ENABLE_MCP=1. - Health and liveness probes.
- The built web app, when the server finds one, for every
GEToutside/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
noneskip session auth. The server does not read anAUTH_PROVIDERvariable. The mode comes fromSUPABASE_URLandSUPABASE_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) orapi_keyquery 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
Authorizationheader entirely.
See Authentication for full token handling rules.
Streaming Behavior
/v1/chat/completionsuses OpenAI-style SSE whenstreamis true; otherwise it returns a single JSON response.POST /api/workflows/{id}/rundoes not stream — it runs the workflow to completion and returns one JSON response.- The
/wsWebSocket 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.
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.- Send the bytes to
upload.urlwithupload.methodandupload.headers. assets.finalizeUploadwith the returnedasset_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, fromcall_quantity(default1, values below1are ignored).unit_pricecounts billing units — megapixels, seconds — fromunit_quantity(default1, floor0.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.
Public Workflow Links
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:
- From the UI: Open a workflow in the editor — the ID appears in the browser URL bar
- From the API: Call
GET /api/workflowsto list all workflows with their IDs - 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 |
Related Guides
- Chat API — OpenAI-compatible request/response schema and WebSocket usage.
- Workflow API — Workflow REST paths and execution.
- API Server Overview — Server architecture and modules.
- Deployment Guide — How servers are built and exposed.
- CLI Reference — Commands including
serve.