This document describes the WebSocket API used to run workflows, stream results, and receive real-time updates from the NodeTool backend.
Overview
NodeTool exposes a single WebSocket endpoint for workflow execution and live updates. Clients connect once and multiplex commands and responses for many concurrent workflows over that connection.
- Endpoint:
ws(s)://<host>/ws - Auth: Include a bearer token as a query parameter (
?api_key=<token>) or rely on the same auth flow used by REST endpoints. Tokens are optional inlocal/noneauth modes. - Protocol: Binary (MessagePack) or Text (JSON) frames. The server auto-detects frame type.
See examples/workflow_runner/js/workflow-runner.js for a complete client implementation used by the bundled runner UI.
Encoding
The protocol supports two encoding modes on the same connection:
| Mode | Frame type | When to use |
|---|---|---|
| Binary (default) | Binary WebSocket frame | MessagePack-encoded. Preferred for production use — compact format and native binary data (images, audio) without Base64 overhead. |
| Text | Text WebSocket frame | JSON-encoded. Useful for debugging, curl/websocat testing, and lightweight clients that don’t need binary payloads. |
Binary frames are decoded with MessagePack; text frames are decoded as JSON.
Both modes are interchangeable — the server accepts either and responds in the
same format. Every message is a map/object with at least a type field.
Connection Lifecycle
- Connect — open a WebSocket to
ws://<host>/ws(orwss://for TLS). For binary mode setbinaryType = "arraybuffer". - Ready — the
onopenevent fires; the client can now send commands. - Reconnect — if the connection drops, retry with exponential backoff (the reference client retries up to 10 times starting at 1 s).
- Close — call
socket.close()or let the server close the connection.
Live Editor Renderer Bridge
The web editor can execute ui_* tools for an MCP client through this same
/ws connection. It does not open a second agent socket and it does not create
a chat thread.
The bridge uses these connection-level messages:
| Direction | Type | Purpose |
|---|---|---|
| Server → editor | renderer_registered |
Assign an ID to this editor connection. |
| Editor → server | client_tools_manifest |
Advertise the frontend tools available in this editor. |
| Server → editor | renderer_tool_call |
Ask the editor to execute one advertised tool. |
| Editor → server | renderer_tool_result |
Return the result or a structured error. |
The server keeps a user-scoped registry of ready editors. An MCP ui_* call
can include renderer_id to select one editor. If it omits the ID, the server
uses that user’s most recently active editor. The list_renderers CodeAct belt
tool returns the available IDs. A disconnected editor is removed immediately.
The normal MessagePack or JSON encoding rules apply. These frames do not use
the { command, data } envelope.
{
"type": "renderer_tool_call",
"renderer_id": "<renderer-id>",
"tool_call_id": "<call-id>",
"name": "ui_get_graph",
"args": {}
}
{
"type": "renderer_tool_result",
"renderer_id": "<renderer-id>",
"tool_call_id": "<call-id>",
"ok": true,
"result": { "nodes": [], "edges": [] },
"elapsed_ms": 12
}
Multi-Instance Deployments
A run’s replay buffer and its cancel/stream hooks live in the one server
process executing it, so on a deployment with more than one instance both
reconnect_job and cancel_job have to reach that process. Two environment
variables drive this, and with neither set the whole mechanism is inert:
NODETOOL_INSTANCE_ID— this instance’s identity.FLY_MACHINE_ID— the fallback, set by Fly on every machine. It is also the valuefly-replay: instance=<id>addresses.
The instance executing a run stamps its id on the job row (runner_instance).
Two things follow.
Resuming lands on the owner. A reconnecting client appends
?resume_job=<job_id> to the handshake URL. If that job is non-terminal and
owned by another instance, the server answers the upgrade with
fly-replay: instance=<owner> instead of accepting it, and Fly’s proxy
re-issues the whole handshake there. A request the proxy already replayed
(fly-replay-src present) is never replayed again, so this cannot ping-pong.
The hint names one job — with runs in flight on several instances the rest
reconnect wherever they land and fall back to reconnect_job’s persisted-row
answer: the right status, without the replayed frames.
The client retires the hint after two consecutive failed connects, so the third
attempt goes out bare. Without that, a deploy that retires the owning machine
while its row still reads running would have every reconnect replayed at a
machine that no longer exists — and since the browser shares one socket across
chat and every other consumer, all of them would stay dark. A successful
connect resets the count.
Cancel travels through the row. cancel_job for a run this process does
not hold, whose row names a different instance, marks the row cancelled with
a conditional update — only while it is still non-terminal, so it cannot
overwrite the owner’s own outcome. Every instance re-reads its own running
runs on a timer (NODETOOL_JOB_CANCEL_POLL_MS, default 15000, 0 disables)
and cancels any whose row now reads cancelled: one indexed query per tick,
bounded by that instance’s concurrency.
The row is the only transport, so a cross-instance cancel takes up to a poll interval to land — the trade for having exactly one signal, the durable one. A cancel on the machine that does hold the run does not go through any of this; it reaches the session’s hooks directly and is immediate.
A row with no runner_instance (an HTTP, trigger, or MCP run — nothing holds a
session for those anywhere) is left alone and still answers “Job not found or
already completed”.
Draining
A restart is what a detachable turn does not survive: the process goes away with the turn’s unwritten transcript rows and its unflushed spans. So a machine is drained before it is restarted.
SIGUSR2 starts the drain, with no deadline of its own. From then on:
/healthanswers 503 withstatus: "draining", so the proxy stops routing new clients here. The payload always carriesturnsandjobs— the counts of chat turns and workflow runs this process is still executing — whether it is draining or not, so a poller can read them either way./readyis unchanged.- A new
/wshandshake is refused with 503; the client’s reconnect backoff carries it to a machine that is staying. - A connection with nothing in flight is closed with 1012 (service restart). One driving a turn or a run is closed when that settles.
chat_messageandrun_jobanswer anerrorframe, and the socket closes under the rule above. The refusal comes before the user message is persisted, so the client’s retry on the other machine is the only copy.
scripts/fly-rolling-deploy.sh is the caller: it signals one machine, polls its
/health until turns and jobs are both 0, replaces it, waits for it to
answer 200, and only then moves to the next.
SIGTERM is the fallback, for a restart nobody drained. It starts the same
drain, aborts every running turn with the reason shutdown and cancels every
running run, then waits up to NODETOOL_SHUTDOWN_GRACE_MS (default 240000, under
Fly’s 300 s cap) for them to settle before flushing telemetry and exiting. A
turn aborted this way writes Stopped: server restarting into its thread — the
one abort the user did not ask for, and so the one that says so.
Client → Server Commands
All client messages contain command and data fields.
run_job
Start a new workflow execution.
{
"command": "run_job",
"data": {
"type": "run_job_request",
"api_url": "http://localhost:7777/api",
"workflow_id": "<uuid>",
"job_type": "workflow",
"auth_token": "<token>",
"params": { "<input_name>": "<value>" },
"job_id": "<uuid>",
"user_id": "<user_id>",
"graph": {
"nodes": [],
"edges": []
},
"explicit_types": false
}
}
| Field | Type | Description |
|---|---|---|
type |
"run_job_request" |
Constant discriminator |
api_url |
string |
Base URL of the REST API |
workflow_id |
string |
UUID of the workflow to run |
job_type |
string |
Always "workflow" for workflow runs |
auth_token |
string |
Bearer token for authentication |
params |
object |
Input parameter values keyed by input name |
job_id |
string \| null |
Optional client-generated UUID to track the job |
user_id |
string |
User identifier |
graph |
object \| null |
Optional graph override ({ nodes, edges }) |
explicit_types |
boolean |
When false, let the server infer types |
execution_strategy |
string \| null |
Recorded on the jobs row and otherwise unused. Nothing on the run path reads it — there is no threaded/subprocess/docker execution mode. See Execution Strategies |
resource_limits |
object \| null |
Declared on RunJobRequest, but no runtime enforces it today |
cancel_job
Cancel a running job.
{
"command": "cancel_job",
"data": {
"job_id": "<uuid>",
"workflow_id": "<uuid>"
}
}
reconnect_job
Reconnect to an in-flight job (e.g. after a page reload). The server replays any missed updates.
{
"command": "reconnect_job",
"data": {
"job_id": "<uuid>",
"workflow_id": "<uuid>"
}
}
stream_input
Push a value into a streaming input node while a job is running.
{
"command": "stream_input",
"data": {
"job_id": "<uuid>",
"workflow_id": "<uuid>",
"input": "<input_name>",
"value": "<any>",
"handle": "<handle_name | null>"
}
}
end_input_stream
Signal that a streaming input is complete.
{
"command": "end_input_stream",
"data": {
"job_id": "<uuid>",
"workflow_id": "<uuid>",
"input": "<input_name>",
"handle": "<handle_name | null>"
}
}
update_node_properties
Update a node’s properties on a running or pending job.
{
"command": "update_node_properties",
"data": {
"job_id": "<uuid>",
"workflow_id": "<uuid>",
"node_id": "<uuid>",
"properties": { "<name>": "<value>" }
}
}
clear_models
Ask the server to unload cached models and free memory.
{
"command": "clear_models",
"data": {}
}
Server → Client Messages
Every server message contains a type field used for dispatch. Messages also
include routing fields (workflow_id, job_id, or thread_id) so the client
can multiplex updates across concurrent workflows.
job_update
Reports overall job status changes.
{
"type": "job_update",
"status": "running",
"job_id": "<uuid>",
"workflow_id": "<uuid>",
"message": "optional status text",
"error": null,
"traceback": null,
"result": null,
"duration": null,
"run_state": null
}
| Field | Type | Description |
|---|---|---|
status |
string |
"queued", "running", "completed", "failed", or "cancelled" |
job_id |
string \| null |
Job UUID |
workflow_id |
string \| null |
Workflow UUID for routing |
message |
string \| null |
Human-readable status message |
error |
string \| null |
Error description on failure |
traceback |
string \| null |
Python traceback on failure |
result |
object \| null |
Final result map on completion |
duration |
number \| null |
Execution duration in seconds |
run_state |
object \| null |
Extended state info (e.g. suspension reason) |
node_update
Reports per-node status changes during execution.
{
"type": "node_update",
"node_id": "<uuid>",
"node_name": "GenerateImage",
"node_type": "nodetool.image.Generate",
"status": "running",
"error": null,
"result": null,
"properties": null,
"workflow_id": "<uuid>"
}
| Field | Type | Description |
|---|---|---|
node_id |
string |
Node UUID |
node_name |
string |
Display name of the node |
node_type |
string |
Fully qualified node type |
status |
string |
"booting", "starting", "running", "completed", or "error" |
error |
string \| null |
Error message if the node failed |
result |
object \| null |
Node output on completion |
properties |
object \| null |
Updated node properties |
workflow_id |
string \| null |
Workflow UUID for routing |
node_progress
Reports progress for long-running nodes (e.g. image generation steps).
{
"type": "node_progress",
"node_id": "<uuid>",
"progress": 3,
"total": 20,
"chunk": "",
"workflow_id": "<uuid>"
}
| Field | Type | Description |
|---|---|---|
node_id |
string |
Node UUID |
progress |
number |
Current step |
total |
number |
Total steps |
chunk |
string |
Optional text chunk for streaming output |
workflow_id |
string \| null |
Workflow UUID for routing |
output_update
Delivers a final output value from an output node.
{
"type": "output_update",
"node_id": "<uuid>",
"node_name": "ImageOutput",
"output_name": "image",
"value": { "type": "image", "data": "<binary>" },
"output_type": "image",
"metadata": {},
"workflow_id": "<uuid>"
}
| Field | Type | Description |
|---|---|---|
node_id |
string |
Node UUID |
node_name |
string |
Display name |
output_name |
string |
Name of the output handle |
value |
any |
The output value (see Value Types) |
output_type |
string |
Type descriptor (e.g. "image", "string") |
metadata |
object |
Additional metadata |
workflow_id |
string \| null |
Workflow UUID for routing |
edge_update
Reports data flow status on a connection between nodes.
{
"type": "edge_update",
"workflow_id": "<uuid>",
"edge_id": "<edge_id>",
"status": "active",
"counter": 5
}
| Field | Type | Description |
|---|---|---|
workflow_id |
string |
Workflow UUID |
edge_id |
string |
Edge identifier |
status |
string |
Edge status |
counter |
number \| null |
Number of items that have passed through |
log_update
Streams log output from a running node.
{
"type": "log_update",
"node_id": "<uuid>",
"node_name": "RunModel",
"content": "Loading model weights...",
"severity": "info",
"workflow_id": "<uuid>"
}
| Field | Type | Description |
|---|---|---|
node_id |
string |
Node UUID |
node_name |
string |
Display name |
content |
string |
Log text |
severity |
string |
"info", "warning", or "error" |
workflow_id |
string \| null |
Workflow UUID for routing |
notification
Server-initiated notification to display to the user.
{
"type": "notification",
"node_id": "<uuid>",
"content": "GPU memory low",
"severity": "warning",
"workflow_id": "<uuid>"
}
| Field | Type | Description |
|---|---|---|
node_id |
string |
Originating node UUID |
content |
string |
Notification text |
severity |
string |
"info", "warning", or "error" |
workflow_id |
string \| null |
Workflow UUID for routing |
planning_update
Reports agent planning phases.
{
"type": "planning_update",
"phase": "analyzing",
"status": "running",
"node_id": "<uuid>",
"content": "Determining approach...",
"workflow_id": "<uuid>"
}
task_update
Reports agent task progress.
{
"type": "task_update",
"task": { "...task object..." },
"event": "started",
"node_id": "<uuid>",
"workflow_id": "<uuid>"
}
tool_call_update
Reports when an agent node invokes a tool.
{
"type": "tool_call_update",
"name": "web_search",
"args": { "query": "example" },
"node_id": "<uuid>",
"tool_call_id": "<id>",
"workflow_id": "<uuid>"
}
tool_result_update
Delivers the result of a tool call.
{
"type": "tool_result_update",
"node_id": "<uuid>",
"result": { "...result data..." },
"workflow_id": "<uuid>"
}
prediction
Reports prediction/inference status from an external provider.
{
"type": "prediction",
"id": "<prediction_id>",
"node_id": "<uuid>",
"status": "running",
"user_id": "<user>",
"workflow_id": "<uuid>",
"logs": "Downloading model...",
"error": null,
"duration": null
}
chunk
Streams incremental text/media content from a node.
{
"type": "chunk",
"content": "Hello ",
"content_type": "text",
"content_metadata": {},
"done": false,
"thinking": false,
"node_id": "<uuid>",
"workflow_id": "<uuid>"
}
| Field | Type | Description |
|---|---|---|
content |
string |
Content fragment |
content_type |
string |
"text", "audio", "image", "video", or "document" |
content_metadata |
object |
Extra metadata for the content |
done |
boolean |
true on the final chunk |
thinking |
boolean |
true when the model is in reasoning/thinking mode |
node_id |
string \| null |
Node UUID |
workflow_id |
string \| null |
Workflow UUID for routing |
system_stats
Reports the server’s CPU and memory load. Unlike every message above, this
is not tied to a run: the server samples on a wall-clock cadence and pushes to
every connected socket — one frame ~1s after connect (long enough for the CPU
delta to mean something), then every 5s — whether or not a workflow is running.
Clients that record a run’s frame stream should drop it as connection control,
alongside ping/pong and resource_change.
A server that enforces auth (SUPABASE_URL + SUPABASE_KEY — a shared
deployment) sends this message never: its CPU and RAM belong to a container the
user does not own. Clients must render the readout only once a frame arrives.
{
"type": "system_stats",
"stats": {
"cpu_percent": 23.4,
"memory_percent": 61.2,
"memory_used": 10522669056,
"memory_total": 17179869184,
"memory_used_gb": 9.8,
"memory_total_gb": 16.0
}
}
| Field | Type | Description |
|---|---|---|
cpu_percent |
number |
Whole-machine CPU use, 0–100, from the delta since the previous sample |
memory_percent |
number |
Used memory as a percentage of total |
memory_used / memory_total |
number |
Bytes |
memory_used_gb / memory_total_gb |
number |
The same figures in GB |
vram_total_gb / vram_used_gb |
number \| null |
Present only when the host samples a GPU; the default sampler omits them |
Value Types
Output values are typically objects with a type discriminator:
| Type | Shape | Description |
|---|---|---|
| Image | { "type": "image", "data": <binary> } |
Raw image bytes (PNG) |
| Audio | { "type": "audio", "data": <binary> } |
Raw audio bytes (MP3/WAV) |
| Video | { "type": "video", "data": <binary> } |
Raw video bytes (MP4) |
| Text | "plain string" |
Simple string value |
| Number | 42 or 3.14 |
Numeric value |
| Boolean | true / false |
Boolean value |
| Object | { ... } |
Arbitrary JSON-like object |
Binary data in MessagePack frames is transmitted as raw byte arrays, avoiding Base64 overhead. In JSON/text mode, binary payloads are Base64-encoded strings.
Message Routing
The server tags each message with one or more routing keys:
workflow_id— primary key for workflow execution updates.job_id— fallback whenworkflow_idis not present.thread_id— used for chat/conversation streams.
The GlobalWebSocketManager in the main web app multiplexes a single
connection and dispatches messages to per-workflow handlers based on these keys.
The standalone workflow runner uses a simpler approach, handling all messages in
a single callback.
Typical Message Sequence
Client Server
| |
|--- run_job ---------------------->|
| |
|<------------- job_update (queued) |
|<------------ job_update (running) |
| |
|<---- node_update (node A running) |
|<--- node_progress (node A 1/10) |
|<--- node_progress (node A 5/10) |
|<-- node_update (node A completed) |
| |
|<---- node_update (node B running) |
|<-------------- output_update (B) |
|<-- node_update (node B completed) |
| |
|<---------- output_update (final) |
|<-------- job_update (completed) |
| |
Error Handling
- Connection errors trigger automatic reconnection with exponential backoff.
- A 120-second timeout is applied to each
run_jobcall; if no terminaljob_updatearrives the promise is rejected. - Server errors are delivered as
job_updatemessages withstatus: "failed"and anerrorfield, or as standaloneerrortype messages. - Per-node errors arrive via
node_updatewith anerrorfield set.
Quick Start Examples
Binary mode (MessagePack)
const socket = new WebSocket("ws://localhost:7777/ws");
socket.binaryType = "arraybuffer";
socket.onopen = () => {
socket.send(
msgpack.encode({
command: "run_job",
data: {
type: "run_job_request",
workflow_id: "YOUR_WORKFLOW_ID",
auth_token: "YOUR_TOKEN",
job_type: "workflow",
params: { prompt: "hello world" }
}
})
);
};
socket.onmessage = (event) => {
const data = msgpack.decode(new Uint8Array(event.data));
console.log(data.type, data);
};
Text mode (JSON)
const socket = new WebSocket("ws://localhost:7777/ws");
socket.onopen = () => {
socket.send(
JSON.stringify({
command: "run_job",
data: {
type: "run_job_request",
workflow_id: "YOUR_WORKFLOW_ID",
auth_token: "YOUR_TOKEN",
job_type: "workflow",
params: { prompt: "hello world" }
}
})
);
};
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log(data.type, data);
};