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. - OpenAI-compatible
/v1/chat/completionsand/v1/models. - WebSocket endpoints for workflow execution, chat, the browser extension, and downloads.
- Health and liveness probes.
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 |
Bearer when AUTH_PROVIDER enforces |
no | OpenAI-compatible model listing |
| Chat | /v1/chat/completions |
POST |
Bearer when AUTH_PROVIDER enforces |
SSE when "stream": true |
OpenAI-compatible chat; SSE or single JSON |
| Workflows | /api/workflows |
GET |
Depends on AUTH_PROVIDER |
no | List workflows |
| Workflows | /api/workflows/{id}/run |
POST |
Depends on AUTH_PROVIDER |
no | Run a workflow once, return final outputs as one JSON response |
| Workflows | /api/workflows/names |
GET |
Depends on AUTH_PROVIDER |
no | {id: name} for the caller’s workflows (up to 1000) |
| Workflows | /api/workflows/tools |
GET |
Depends on AUTH_PROVIDER |
no | Workflows saved with run_mode: "tool", as {name, tool_name, description} |
| Workflows | /api/workflows/{id}/dsl-export |
GET |
Depends on AUTH_PROVIDER |
no | Graph as TypeScript DSL source (text/plain) |
| Workflows | /api/workflows/{id}/export-bundle |
GET |
Depends on AUTH_PROVIDER |
no | One workflow and its assets as a .nodetool zip |
| Workflows | /api/workflows/export-bundle |
POST |
Depends on AUTH_PROVIDER |
no | Several workflows in one .nodetool zip, by workflow_ids |
| Workflows | /api/workflows/import-bundle |
POST |
Depends on AUTH_PROVIDER |
no | Import a .nodetool zip into the caller’s library |
| Workflows | /api/debug/sessions/{id} |
GET |
Depends on AUTH_PROVIDER |
no | State of an interactive run: the escalation it is parked on, or its final report |
| Workflows | /api/debug/sessions/{id}/verdict |
POST |
Depends on AUTH_PROVIDER |
no | Answer the parked escalation, then wait for the next one or the final report |
| Workflows | /api/debug/sessions/{id}/cancel |
POST |
Depends on AUTH_PROVIDER |
no | Cancel the run and return its final report |
| SDK | /api/sdk/v1/workflows/{id}/interface |
GET |
Depends on AUTH_PROVIDER |
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 |
Depends on AUTH_PROVIDER |
streaming | One file out of a workspace as an attachment; 403 when NODETOOL_ENV=production |
| Assets | /api/assets/{id}/extract-audio |
POST |
Depends on AUTH_PROVIDER |
no | Extract a video asset’s audio track into a new WAV asset |
| 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 |
Depends on AUTH_PROVIDER |
no | Bulk ZIP download; 501 on this server |
| Apps | /api/applications/{id}/released-document |
GET |
Depends on AUTH_PROVIDER |
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 |
Depends on AUTH_PROVIDER |
no | One app and the full graph of every workflow it binds, as a downloadable ApplicationBundle |
| Apps | /api/applications/build |
POST |
Depends on AUTH_PROVIDER |
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/debug |
POST |
Depends on AUTH_PROVIDER |
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 |
none | no | The shipped example apps — slug, name, description, workflow names, operation count |
| Apps | /api/applications/examples/{slug} |
GET |
none | no | One example’s full ApplicationBundle; 404 when the slug names nothing shipped |
| Apps | /api/applications/examples/{slug}/install |
POST |
Depends on AUTH_PROVIDER |
no | Install an example into the caller’s library, creating the workflows it binds |
| Storyboards | /api/storyboards/{id}/export-zip |
GET |
Depends on AUTH_PROVIDER |
no | One board as a zip of Markdown plus its stills and clips; 404 when the caller does not own it |
| Providers | /api/fal/credits |
GET |
Depends on AUTH_PROVIDER |
no | The server’s fal.ai account balance; 204 when no FAL_API_KEY is configured |
| Providers | /api/fal/pricing |
GET |
Depends on AUTH_PROVIDER |
no | Unit price per fal.ai endpoint, one or more ?endpoint_id=; cached an hour |
| Providers | /api/fal/pricing/estimate |
POST |
Depends on AUTH_PROVIDER |
no | What a fal.ai endpoint costs for a given quantity; 204 when no FAL_API_KEY is configured |
| Providers | /api/kie/credits |
GET |
Depends on AUTH_PROVIDER |
no | The server’s kie.ai credit balance; 204 when no KIE_API_KEY is configured |
| Providers | /api/kie/pricing |
GET |
Depends on AUTH_PROVIDER |
no | Credit price per kie.ai model, one or more ?model_id=; cached an hour |
| Providers | /api/kie/resolve-dynamic-schema |
POST |
Depends on AUTH_PROVIDER |
no | Pasted kie.ai model docs to a node’s dynamic properties, inputs, and outputs |
| JS Scripts | /api/js-scripts/{id}/run |
POST |
Depends on AUTH_PROVIDER |
no | Run a saved JS script document in the sandbox and return its outputs |
| SDK | /api/sdk/v1/capabilities |
GET |
Depends on AUTH_PROVIDER |
no | What this server supports — profiles, encodings, execution options, limits |
| SDK | /api/sdk/v1/node-types |
GET |
Depends on AUTH_PROVIDER |
no | Paged inventory of the pin types in the registry and which nodes use them |
| SDK | /api/sdk/v1/workflows |
GET |
Depends on AUTH_PROVIDER |
no | Workflow summaries with the revision an interface was read at |
| SDK | /api/sdk/v1/workflow-interfaces |
POST |
Depends on AUTH_PROVIDER |
no | Up to 100 workflows’ interfaces in one call, with per-workflow errors |
| SDK | /api/sdk/v1/preflight |
POST |
Depends on AUTH_PROVIDER |
no | Whether a workflow is runnable, what it needs, and what it will cost |
| SDK | /api/sdk/v1/assets/temporary |
POST |
Depends on AUTH_PROVIDER |
no | Store one execution input in temporary storage; creates no asset row |
| SDK | /api/sdk/v1/models |
GET |
Depends on AUTH_PROVIDER |
no | Paged model catalog with per-model availability and the wire value a node property takes |
| SDK | /api/sdk/v1/model-downloads |
GET |
Depends on AUTH_PROVIDER |
no | Snapshot of this caller’s model downloads, running and finished |
| SDK | /api/sdk/v1/model-downloads |
POST |
Depends on AUTH_PROVIDER |
no | Start a model download; 202 with the operation’s first state |
| SDK | /api/sdk/v1/model-downloads/cancel |
POST |
Depends on AUTH_PROVIDER |
no | Cancel one download by operation_id |
| Extension | /api/extension/download |
GET |
Depends on AUTH_PROVIDER |
no | The built Chrome extension as a zip; 404 when the server has no build |
| Workflow WS | /ws |
WebSocket | Bearer header or api_key query when enforced |
yes | Workflow execution, chat, job control, live editor tools, and live updates (MessagePack or JSON) |
| Extension WS | /ws/extension |
WebSocket | Follows global auth settings | yes | Browser extension channel |
| Download WS | /ws/download |
WebSocket | Follows global auth settings | yes | Model/file downloads |
| Storage | /api/storage/* |
HEAD/GET |
Depends on AUTH_PROVIDER |
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 |
| 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 |
none | no | Stub — always 501. Bulk secret import is not part of the standalone server |
| Health | /health |
GET |
none | no | JSON: {status, timestamp, uptime, services} (200/503) |
| Health | /api/health |
GET |
none | no | JSON: {version, uptime} |
| Liveness | /ready |
GET |
none | no | Always 200 with {status:"ok"} |
When
AUTH_PROVIDERislocalornone, endpoints accept requests without a token for convenience. When it isstaticorsupabase, includeAuthorization: Bearer <token>on every request except the health/liveness routes.
Authentication and Headers
NodeTool uses Bearer token authentication. The behavior depends on your AUTH_PROVIDER setting:
| AUTH_PROVIDER | Token Required? | Use Case |
|---|---|---|
local / none |
No | Local development, desktop app |
static |
Yes — use the configured static token | Simple deployments with a shared secret |
supabase |
Yes — use a Supabase JWT | Production deployments with user management |
How to include credentials
- HTTP requests:
Authorization: Bearer <token>header on all non-public routes - WebSocket:
Authorization: Bearer <token>header (preferred) orapi_keyquery parameter - SSE streams (
/v1/chat/completions):Authorization: Bearer <token>andAccept: text/event-stream
Local development: When running locally with the default config (
AUTH_PROVIDER=local), no token is needed. You can omit theAuthorizationheader 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.
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, and that user wins, so
user_id in the body is ignored here. Send it only when redeeming a code that
link/start minted — a code bound to an external account names no user, and the
call is 400 without one.
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": 10,
"completion_tokens": 150,
"total_tokens": 160
}
}
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:
data: {"id":"chatcmpl-123","choices":[{"delta":{"role":"assistant"},"index":0}]}
data: {"id":"chatcmpl-123","choices":[{"delta":{"content":"Code"},"index":0}]}
data: {"id":"chatcmpl-123","choices":[{"delta":{"content":" flows"},"index":0}]}
data: {"id":"chatcmpl-123","choices":[{"delta":{"content":" like"},"index":0}]}
data: [DONE]
List Available Models
curl "http://localhost:7777/v1/models" \
-H "Authorization: Bearer YOUR_TOKEN"
Response:
{
"object": "list",
"data": [
{"id": "gpt-5.6", "object": "model", "owned_by": "openai"},
{"id": "gpt-5-mini", "object": "model", "owned_by": "openai"},
{"id": "claude-opus-5", "object": "model", "owned_by": "anthropic"},
{"id": "gpt-oss:20b", "object": "model", "owned_by": "ollama"}
]
}
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 all workflows
curl "http://localhost:7777/api/workflows" \
-H "Authorization: Bearer YOUR_TOKEN"
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.
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.
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
Workspaces browse the local filesystem, so the whole surface is off in
production — every path answers 403 with
{"detail": "Workspaces are disabled in production"} when
NODETOOL_ENV=production. Otherwise:
| Status | Meaning |
|---|---|
400 |
path is absolute |
403 |
path resolves outside the workspace root |
404 |
No such workspace for this caller, or no such file inside it |
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 needs no user and no
token; 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
}
]
workflows names the workflows installing the app would create. 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.
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].
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. A cancelled build settles as
failed with reason: "cancelled".
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.
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.
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,
"services": { "database": "ok", "server": "ok" }
}
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()
# 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 with JSON error bodies:
{
"error": {
"message": "Workflow not found: invalid_id",
"type": "not_found",
"code": 404
}
}
| Status Code | Meaning | Common Causes |
|---|---|---|
| 400 | Bad Request | Invalid parameters, malformed JSON |
| 401 | Unauthorized | Missing or invalid token |
| 403 | Forbidden | Token lacks permission |
| 404 | Not Found | Workflow/resource doesn’t exist |
| 422 | Validation Error | Parameter validation failed |
| 500 | Internal Error | Server-side error |
| 503 | Service Unavailable | Server overloaded or starting up |
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.