This page covers two distinct mechanisms that are easy to conflate:
- Workflow execution — how the kernel runs a graph of nodes. This is an in-process actor model with message-passing; there are no per-job threads, subprocesses, or containers.
- Code execution — how the Code node runs arbitrary user JavaScript in a QuickJS WebAssembly guest. That sandbox covers the code inside one node, not the workflow.
See also Architecture for the system overview and Automatic Message Correlation for the lineage model the scheduler relies on.
Workflow Execution: the actor model
A workflow is a DAG. The one allowed cycle is a loop that closes on a Loop node’s feedback input. The runtime lives in packages/kernel/:
WorkflowRunner(packages/kernel/src/runner.ts) orchestrates one job.NodeActor(packages/kernel/src/actor.ts) runs a single node.NodeInbox(packages/kernel/src/inbox.ts) buffers each node’s inputs per handle and tracks how many upstream sources remain open.
There is no JobExecutionManager and no pluggable “threaded / subprocess /
docker” strategy for running a workflow. The whole graph runs concurrently in
one event loop via async actors that pass messages to each other’s inboxes.
What WorkflowRunner does
WorkflowRunner.run(request, graphData) first emits job_update with status
running, so a run that fails validation still tells clients it started. Then
it performs, in order:
- Bypass rewrite —
rewriteBypassedNodesre-routes around nodes flaggedui_properties.bypassed. - Invalid-edge filtering — drops edges whose source or target node is missing.
- Correlation analysis —
analyzeCorrelation(mandatory) computes the static lineage scope of every node input/output. Issues abort the run with aGraphValidationErrorbefore any actor starts. - Graph + node validation — structural validation, an optional per-node
validateNodecallback, and a check that every workflow input node (nodetool.input.*) has a value, either in the request params or in its ownvalueproperty. - Inbox initialization — one
NodeInboxper node, seeded with the count of incoming data edges per handle (and one__control__upstream per unique controller). - Node initialization — resolves and caches one executor instance per node.
- Input dispatch —
_dispatchInputsruns each external input node’sprocess()once and delivers values to downstream inboxes. Non-streaming inputs then signal end-of-stream (EOS); streaming-output inputs (e.g.RealtimeAudioInput) stay open for laterpushInputValue()calls. - Actor spawning —
_processGraphcreates aNodeActorper node and runs them all withPromise.all. Actors block on their inboxes until upstream data arrives, so nodes whose inputs are ready run concurrently while the rest wait.
The runner also routes outputs downstream (_sendMessages), tracks per-edge
message counters for the UI (throttled edge_update events), propagates EOS
(_sendEOS), and emits job_update / output_update messages through the
ProcessingContext.
Job outcomes
run() returns a RunResult with status completed, failed, or cancelled.
Precedence at finalization is cancel > failed > completed: a node error
fails the whole job, and cancel() aborts the run-level AbortController and
closes every inbox to unblock waiting actors.
The four actor modes
NodeActor picks a mode from the node’s hydrated behavior flags. (The
runner requires a hydrated graph: the flags below must be set, or streaming
nodes would silently run as one-shot process() calls.)
| Mode | Trigger | Behavior |
|---|---|---|
| Buffered | default | Gathers inputs via the correlated scheduler and calls process() once per ready input set. |
| Streaming input | is_streaming_input |
Node drains its inbox itself via NodeInputs (run(inputs, outputs, ctx)), emitting through NodeOutputs. |
| Streaming output | is_streaming_output |
Calls genProcess(), which yields partial frames; each yield is routed downstream as it is produced. |
| Controlled | is_controlled |
Waits for control events on the __control__ handle, caches data inputs, and re-runs process() per run event until controllers signal EOS. |
Buffered and streaming-output nodes share the same correlation-aware gather
path (_runCorrelated); the difference is whether the executor exposes
process() or genProcess().
Two cases are checked before those modes:
- Trigger entry. When a delivered trigger event targets a node with
is_trigger, the node does not listen. It emits the event payload on its declared outputs and completes. A run started from the editor’s Run button falls through to the normal modes. - Loop.
nodetool.control.Loopruns in a kernel-owned loop mode. The kernel handles iteration, feedback matching, and termination, and the node’sprocess()is never called.max_iterationsdefaults to 10 and is clamped to- See the Loop node.
Correlation-aware scheduling (_runCorrelated)
Buffered/streaming-output nodes do not simply wait for “all inputs.” They
schedule per correlation key so that fan-out/fan-in over iterations stays
correctly paired. _runCorrelated (in actor.ts) classifies each connected
data handle by its static scope:
- max-scope — scope length equals the node’s invocation scope; bucketed per
projected lineage key. One handle may be the repeating driver
(
repeats_per_key), firing once per arriving envelope. - strict-prefix sticky — a shorter non-empty scope; the latest value is kept per projected parent key (side inputs that change less often).
- empty — empty scope; a single sticky value (or the node’s declared property default).
As envelopes arrive on iterAnyWithEnvelope(), the actor records each into its
bucket and re-evaluates which keys are now ready. A key is ready when every
handle has a value for it (lists wait for the handle to close so the full set is
captured). Each ready key fires process()/genProcess() with its matched
inputs, and the produced outputs inherit (or mint) the correct lineage so
downstream joins line up. Source nodes (no connected data handles) fire once
with empty inputs.
Inbox safety limits (max_pending_keys, max_pending_messages_per_key) abort
the run if pending keys grow without bound — typically a missing upstream close.
See correlation-design.md for the full model: lineage
as root-id → item-token maps, static scope analysis, and done / dropped-key /
scope-close propagation.
Sync modes (legacy framing)
The older zip_all / on_any / sticky sync-mode vocabulary maps onto the
correlation scheduler: zip_all-style “wait for all handles” is the max-scope
readiness rule, sticky is the strict-prefix / empty-scope handle classes, and
on_any is the per-arrival driver behavior. New nodes declare correlation via
input scope and output_correlation rather than a single sync_mode flag.
Control events
Controller nodes (e.g. agent nodes) drive controlled nodes over control
edges. The runner builds a _control_context describing each controlled node’s
properties and “run” action schema, injects it as an input, and exposes
sendControlEvent(targetNodeId, properties) (via
ProcessingContext.setSendControlEvent) so a controller can dispatch a run and
await that node’s next output. Responses are tracked FIFO per node so a burst of
concurrent control calls doesn’t drop a waiter.
Code Execution: the sandboxed Code node
Separately from workflow scheduling, the Code node
(nodetool.code.Code, packages/code-nodes/src/nodes/code-node.ts) executes
user JavaScript in a QuickJS WebAssembly guest — see
@nodetool-ai/agents/js-sandbox. It isolates the code in one node, not the
workflow, and runs the same way in the browser and on the server: no Docker, no
subprocess, no host interpreter.
The guest gets standard JavaScript plus a fixed set of bridges: fetch(),
workspace, getSecret(), sleep(), progress(), crypto, format, the
image, audio, video, media, and canvas helpers, and emit() and
output() for results. Dynamic inputs arrive on the inputs object. Libraries
come from sandbox packs that the body imports. See
JavaScript Sandbox for the full surface, limits, and
security model.
Cancellation and shutdown
- Workflow:
WorkflowRunner.cancel()aborts the run-levelAbortController(observed by node code viainputs.signal) and closes every inbox so waiting actors unblock; the job finalizes ascancelled. - Code node: the QuickJS guest is torn down with its host call; the run’s abort signal ends any in-flight bridge call.
Related
- Architecture — system components, message types, job lifecycle.
- Automatic Message Correlation — lineage, scopes, and the scheduler design.
packages/kernel/—runner.ts,actor.ts,inbox.ts,correlation-analysis.ts.packages/code-nodes/— the sandboxed Code node.