DSL Native Flow — Technical Design
Status: In progress — the public surface runs in the QuickJS guest (§0) Last updated: 2026-08-17 — recorded the guest pivot (§0) and rewrote §1
0. The QuickJS pivot
What changed: the public surface is guest code, not a host import. §4–§6 describe the host API, which is now internal.
A user writes flow code in a Code node, where it runs in the sandbox like every
other body. The typed callables live in a shipped pack,
@nodetool-ai/sandbox-flow (packages/sandbox-packs/sandbox-flow/), built from
packages/dsl/src/flow/generated/ — one guest module per namespace, emitted by
the same npm run codegen pass as the graph tree. Each call crosses to the host
through the capability module @nodetool-ai/sandbox-nodetool/flow
(invoke_node, open_node_stream, take_node_stream, close_node_stream),
which lands on the host flow of §4–§6.
Point by point against what came before:
- No
@nodetool-ai/dsl/flowexport.startFlow,invoke,invokeStreamand the streaming adapters stay inpackages/dsl/src/flow/, consumed by the capability module. They are the backend, not the API; a caller who wants them is inside NodeTool. - Streaming input is arrays only, v1. §4.4’s
AsyncIterableon an input handle does not cross the guest boundary, so arun-contract node’s inputs widen toT | T[]and nothing more. The host adapter still accepts an iterable — nothing in the guest can hand it one. - Correlation and
emitGroupare unchanged (§5): a group flattens to its member emissions, lineage is not modelled, and a.streamon arun-contract node yields{slot, value}. - Isolation comes back. §2 counted the sandbox boundary as a cost the host surface avoids. It is now paid per call — one crossing for a one-shot call, one per item for a stream — and what it buys is that model-authored flow code is no longer trusted code.
Open item. Pack discovery refuses a non-relative import in authored pack
code (inspectJavaScript, packages/node-sdk/src/sandbox-pack-discovery.ts),
so the pack’s guest core cannot yet name the capability module. Either that
rule gains an allowance for @nodetool-ai/sandbox-nodetool/*, or the bridge
ships as a host module (SANDBOX_HOST_MODULES). A second, smaller one: the
host mounts a capability facade by scanning the body’s static imports
(packages/agents/src/codeact/capability-modules.ts), so a body using the pack
must also import "@nodetool-ai/sandbox-nodetool/flow" itself. Mounting what a
resolved pack module imports would remove that line from every program.
1. Summary
A third way to run nodes, cheaper than the graph: call a node as a typed async
function and write the control flow in plain JavaScript. No graph, no edges, no
WorkflowRunner. await is the edge, a variable is the wire, Promise.all is
the fan-out.
// A Code node body. `packages` declares "@nodetool-ai/sandbox-flow".
import "@nodetool-ai/sandbox-nodetool/flow";
import { template } from "@nodetool-ai/sandbox-flow/nodetool.text";
import { textToSpeech } from "@nodetool-ai/sandbox-flow/gemini.audio";
const draft = await template({
string: "Write a haiku about ",
values: { topic: "rain" }
});
const speech = await textToSpeech({ text: draft.output });
return speech.output;
Each call resolves the node class from the registry, assigns the inputs,
injects secrets, runs process()/genProcess() through the node’s own
toExecutor() bridge, and returns the outputs record — the same execution the
kernel’s NodeActor performs, minus the actor.
The existing graph DSL (workflow() + run()) is untouched, and the sandbox
gains a pack rather than a mode. Where a graph is the deliverable — something
to open in the editor, validate, supervise, or hand to the server — build one
with @nodetool-ai/sandbox-dsl instead (§5).
2. Why
Three execution paths exist today, and each pays for something a plain script does not need:
WorkflowRunner(kernel). Actor per node, message-passing mailboxes, correlation lineage, EOS propagation,ProcessingMessagefan-out. Right for the editor and for supervised runs; overhead and indirection when the caller is code that just wants values.- QuickJS sandbox (Code node, CodeAct). Every value crosses a host↔guest marshalling boundary, the guest has its own module loader and interrupt budget, and host packs bridge one call at a time. The isolation is the point — and the cost. For code we already trust, it buys nothing.
- Graph DSL
run(). Typed authoring, but the built graph still executes onWorkflowRunner, so branching, loops, and retries must be expressed as nodes (If,ForEach, …) instead ofif,for,try.
The native flow keeps the DSL’s typed node surface — the generated factories’
Inputs/Outputs types, checked at compile time — and swaps the execution
substrate for direct async calls.
3. Design goals
- A node call is one function call. No graph build, no scheduler, no
serialization. The only machinery per call: registry resolve, secret
injection,
process(). - JavaScript is the control plane. Branches, loops, retries, timeouts, concurrency — all plain JS. The API ships no control-flow combinators.
- Same types, same nodes, one codegen. The flow surface is generated
from the same registry metadata as the graph factories, in the same
npm run codegenpass, gated by the samecodegen:check. - Same observability. Each call opens a
node.processOTel span; LLM calls inside it still emitllm.chat/llm.streamspans and cost-ledger rows, because providers go through the sameProcessingContext. - Nothing new in the kernel or node-sdk. The flow layer is a consumer of
NodeRegistry,toExecutor(), andProcessingContextas they exist.
4. The API
4.1 startFlow and the Flow handle
import { startFlow, type Flow, type FlowOptions } from "@nodetool-ai/dsl/flow";
const flow: Flow = await startFlow({
userId: "1", // default "1", matching the CLI
secretResolver, // (key, userId) => value — same SecretResolver as RunOptions
registry, // optional NodeRegistry override
bridgeOptions, // Python worker bridge config (lazy, see 4.6)
signal, // AbortSignal — cancels in-flight calls
onCall // (event: FlowCallEvent) => void, see 4.7
});
A Flow owns what RunOptions configures today: one ProcessingContext
(job id, user, secrets, storage), the resolved registry chain (caller override
→ NodeRegistry.global → the lazily registered builtin packs, exactly as
run() in core.ts builds it), and at most one Python bridge.
Lifecycle:
interface Flow {
run<T>(body: () => Promise<T>): Promise<T>; // sets the ambient flow (ALS) for body
readonly context: ProcessingContext; // escape hatch for advanced callers
close(): Promise<void>; // closes the Python bridge, ends spans
[Symbol.asyncDispose](): Promise<void>; // `await using flow = ...`
}
4.2 Node calls
For every registered node, codegen emits a callable in the flow namespace,
sharing the graph DSL’s generated Inputs/Outputs types with
Connectable<T> collapsed to T — a value is a value, there are no handles:
// generated — flow/gemini.audio.ts
export function textToSpeech(
inputs: TextToSpeechInputs, // { text?: string; model?: ...; voice_name?: ... }
opts?: CallOptions // { flow?: Flow; signal?: AbortSignal }
): Promise<TextToSpeechOutputs>; // { output: AudioRef }
Resolution of the executing flow, in order: opts.flow → the ambient flow set
by an enclosing flow.run() (AsyncLocalStorage) → a process-default flow
created lazily on first use (env-var secrets only, no Python bridge). The
default flow makes a five-line script work with zero ceremony; flow.run()
is for anything that needs configured secrets, cancellation, or isolation
between concurrent flows.
Under the hood a call is:
registry.resolve({id, type, properties: inputs}) // fresh instance per call
→ executor.initialize()
→ executor.process(inputs, flow.context) // secrets injected by toExecutor()
→ executor.finalize()
One-shot nodes return the record process() resolves to. A node whose
genProcess yields multiple records is drained and the call returns the
last value per slot — the same fold run() applies to terminal outputs
today. Callers who want the stream use .stream().
4.3 Streaming output
Every generated callable for a streaming-output node also carries a .stream
member:
for await (const chunk of agents.agent.stream({ objective, model })) {
// chunk: Partial<AgentOutputs> — one genProcess yield
process.stdout.write(chunk.chunk ?? "");
}
.stream(inputs, opts?) returns AsyncIterable<Partial<Outputs>>, one item
per genProcess yield. Early break/return on the iterator calls the
generator’s return() so the node can clean up. Non-streaming nodes get no
.stream member — the type surface tells you which is which.
4.4 Streaming input
Nodes with the run(inputs, outputs, context) contract consume live streams.
In the flow API their generated inputs accept AsyncIterable<T> (or an
array, wrapped for convenience) on stream-typed handles, and the call returns
an async iterable of emissions:
const events = code.code.stream(
{ code: body, items: readLines(file) }, // items: AsyncIterable<string>
);
for await (const { slot, value } of events) { ... }
The adapter bridges the caller’s iterables into a StreamingInputs
implementation (per-handle queues, EOS on iterable end) and surfaces
outputs.emit(slot, value) as { slot, value } items. emitGroup flattens
to its member emissions — correlation tokens are a runner concept and do not
cross this boundary (see §5).
The awaited form (await code.code({...})) is also defined for these nodes:
drain everything, return last value per slot.
4.5 Errors, cancellation, control flow
A node that throws rejects the call with the original error. There is no
verdict machinery, no --supervise, no per-node error message stream — the
caller’s try/catch is the supervisor. Retry, fallback, timeout, and
concurrency limits are the caller’s JS (Promise.all, AbortSignal.timeout,
p-limit-style helpers); the API deliberately ships none, because shipping
them would be rebuilding the runner one combinator at a time.
FlowOptions.signal aborts the flow: in-flight calls reject with
AbortError, and the signal is forwarded into ProcessingContext so
providers that honor abort stop mid-request. Per-call CallOptions.signal
scopes cancellation to one call.
4.6 Python nodes
Same policy as run() in core.ts: a node type no TS registry resolves is a
Python candidate. The flow connects the bridge lazily on the first such
call (via connectPythonBridgeForGraph’s single-node equivalent), reuses it
for the rest of the flow, and closes it in close(). bridgeOptions and the
NODETOOL_WORKER_URL env behave exactly as documented for RunOptions.
4.7 Observability
- Each call runs inside a
node.processspan carryingnode.type, duration, and status — the same span name the kernel emits, so a trace of a native flow and a trace of a runner execution read with the same analyzer. FlowOptions.onCallreceives{ type, phase: "start" | "end" | "error", durationMs, error? }for callers who want progress without OTel.- Cost rows land in the prediction ledger unchanged: providers bill through
ProcessingContext, which the flow supplies.
5. What the runner provides that this does not
Stated, not hidden. Choosing the flow API means choosing away:
| Runner feature | Native flow answer |
|---|---|
node_update / job messages, editor progress UI |
onCall hook + OTel spans; no job row, no editor surface |
Correlation lineage, emitGroup atomic frames, zip semantics |
Gone. Grouping is the caller’s data structure |
Supervision (--supervise, escalations, verdicts) |
try/catch |
| Per-item fan-out via streaming edges | Promise.all(items.map(...)) |
| Trigger nodes (webhook/tick/file-watch entry points) | Out of scope — they need a resident run loop |
Graph-level static validation (nodetool validate) |
The type checker. Inputs are compile-time checked; there is no graph to validate |
| Persistable/sharable artifact (workflow JSON, editor round-trip) | None. A flow is code. When shareability matters, use the graph DSL |
That last row is the real boundary: if the artifact must open in the editor, be validated, supervised, or run by the server, build a graph. The flow API is for programs.
6. Codegen and package layout
packages/dsl/src/flow/core.ts—startFlow,Flow, ALS ambient context, the invoke path (resolve → secrets → process), the streaming-input adapter, the default-flow singleton. Reuses the builtin-pack registration list currently inlined incore.tsrun()by extracting it to a sharedbuildBuiltinRegistry()(one list, two consumers).packages/dsl/src/flow/generated/— one module per namespace, emitted by the samenpm run codegenscript from the same registry metadata. Each node emits: the plain-valueInputstype (Connectable stripped), the callable, and.streamwhenis_streaming_outputor theruncontract is present.codegen:checkcovers both trees, so a renamed node cannot leave a flow callable behind.- Package export:
@nodetool-ai/dsl/flow. The root export stays the graph DSL; the two surfaces sharetypes.ts(refs, model types) and the generated literal types. - The Code node keeps its
usesStreamInputContractprobe on the body, applied at call time instead of graph-build time.
7. Implementation plan
Eight tasks, ordered by dependency. T1–T3 are the core and land together or
in sequence; T4 unlocks the typed surface; T5–T8 are independent once T4 is
in. Every task ends with npm run test --workspace=packages/dsl green plus
the repo gate (npm run typecheck && npm run lint), and any task touching
packages/dsl re-runs npm run codegen:check.
T1 — Extract the shared registry/executor chain
Refactor only; no behavior change.
- Files:
packages/dsl/src/core.ts→ newpackages/dsl/src/registry.ts. - Steps:
- Move the builtin-pack registration block from
run()(theregisterBaseNodes+ five try/catch optional packs,core.ts:340–377) intobuildBuiltinRegistry(): Promise<NodeRegistry>, memoized per process —run()currently rebuilds it per call, which the flow API must not do per node call. - Move the resolution chain (
opts.registry→NodeRegistry.global→ builtin → Python candidate) intocreateExecutorResolver(opts, bridge): (node) => NodeExecutor. - Re-wire
run()through both helpers.
- Move the builtin-pack registration block from
- Acceptance:
tests/core.test.tsandtests/integration.test.tspass unchanged;registry.tsexports are consumed bycore.tsonly (until T2).
T2 — Flow core: startFlow, ambient context, invoke path
- Files: new
packages/dsl/src/flow/core.ts,packages/dsl/src/flow/invoke.ts; new testpackages/dsl/tests/flow-core.test.ts. - Steps:
Flow/FlowOptions/CallOptionstypes as in §4.1–4.2.Flowowns oneProcessingContext(job idflow-<uuid>,userIddefault"1",secretResolver,FileStorageAdapteron the default assets path — same wiring asrun-node.ts).- Ambient context: module-level
AsyncLocalStorage<Flow>;flow.run(body)=als.run(flow, body). - Flow resolution helper
resolveFlow(opts?: CallOptions): Flow—opts.flow→als.getStore()→ lazy process-default flow (env secrets only, created once, never auto-closed). invoke(nodeType, inputs, opts)ininvoke.ts: resolve executor via T1’s chain, fresh instance per call,initialize()→ draingenProcess(inputs, ctx)folding last-value-per-slot →finalize()in afinally. Rejects with the node’s error unwrapped.close()+[Symbol.asyncDispose]: idempotent; closes the Python bridge when one was connected (T7); subsequent calls on a closed flow reject withFlowClosedError.onCallevents (start/end/errorwithtype,durationMs) and anode.processspan per call via the existing tracing entry point the kernel uses.
- Tests (hermetic, no keys): one-shot call returns outputs
(
nodetool.text.Concat); multi-yieldgenProcessfolds to last value; ambient vs explicit{flow}precedence (two concurrentflow.runbodies don’t cross); default-flow fallback works with nostartFlow; error fromprocess()rejects andfinalize()still ran; closed flow rejects;onCallsequence recorded. - Acceptance: all of the above green; no import of
WorkflowRunneranywhere undersrc/flow/.
T3 — Streaming both directions
- Files:
packages/dsl/src/flow/streaming.ts; testpackages/dsl/tests/flow-streaming.test.ts. - Steps:
invokeStream(nodeType, inputs, opts): AsyncIterable<Record<string, unknown>>— yields eachgenProcessrecord; iteratorreturn()/throw()forwarded to the generator so earlybreakruns node cleanup;finalize()on completion either way.StreamingInputsadapter forrun-contract nodes: acceptsAsyncIterable<T> | T[] | Tper stream-typed handle; per-handle queue with EOS on source end; implementsstream,any,first, and the envelope variants with synthesized plain envelopes.StreamingOutputsadapter:emit(slot, value)pushes{slot, value}into the returned async iterable;emitGroupflattens to member emissions (correlation tokens dropped — §5).- Awaited form for
run-contract nodes = drain the emission iterable, fold last-value-per-slot. - Backpressure: the emission queue is unbounded is not acceptable —
cap it (default 1024) and make
emitawait the consumer past the cap.
- Tests: stream yields in order; early
breaktriggers generator cleanup (probe with afinallyin a test node); array input wraps to a stream; interleaved two-handleany()order; EOS terminatesrun(); emission backpressure (producer awaits when the consumer is slow);emitGroupflattening.
T4 — Codegen: the typed flow surface
- Files:
packages/dsl/scripts/codegen.ts,packages/dsl/src/flow/generated/(emitted),packages/dsl/src/flow/index.ts; extendpackages/dsl/tests/codegen.test.ts. - Steps:
- Emit one flow module per namespace next to the graph tree, from the
same metadata pass. Per node: a plain-value
Inputstype (today’s literal types minus theConnectable<...>wrapper — emit the inner type directly rather than unwrapping with a conditional type, so hover types stay readable), the callable(inputs, opts?) => Promise<Outputs>delegating to T2’sinvoke. .streammember only whenis_streaming_output(the flag read atcodegen.ts:376) or the class declares theruncontract; typedAsyncIterable<Partial<Outputs>>.- Stream-typed input handles on
run-contract nodes widen toT | T[] | AsyncIterable<T>. - Code node special case: the callable applies
usesStreamInputContracttoinputs.codeat call time to pick the invoke path. --checkcovers the flow tree (it already diffs the whole emitted set; assert with a deliberate stale file in the test).- Namespace re-exports from
flow/index.ts; keep the graph DSL’s namespace names.
- Emit one flow module per namespace next to the graph tree, from the
same metadata pass. Per node: a plain-value
- Acceptance:
npm run codegenis idempotent;codegen:checkfails on a hand-edited flow file (prove it once, revert);tscacceptstext.template({string: "x"})and rejects{string: 1}in a type-level test.
T5 — Package export and docs
- Files:
packages/dsl/package.json,packages/dsl/README.md,AGENTS.md(this repo file’s DSL row), this doc’s status line. - Steps: add the
./flowexport map entry (mirroring the root entry’s four conditions, includingnodetool-dev); README section with the §1 example; note in the exports table. - Acceptance:
node -e 'import("@nodetool-ai/dsl/flow")'resolves from a built workspace;npm run build:packagesclean.
T6 — Abort and error semantics
- Files:
flow/core.ts,flow/invoke.ts; testpackages/dsl/tests/flow-abort.test.ts. - Steps:
FlowOptions.signalandCallOptions.signalcombined per call (AbortSignal.any); already-aborted signal rejects before resolve.- Forward the combined signal into
ProcessingContextso providers that honor abort stop mid-request; nodes that ignore it still reject at the next yield boundary. - Streaming iterables settle their pending
next()with the abort rejection and run generator cleanup.
- Tests: pre-aborted call rejects with
AbortErrorand never invokesprocess(); mid-stream abort cleans up; flow-level abort rejects two concurrent in-flight calls.
T7 — Python bridge lazy-connect
- Files:
flow/core.ts(+ a small helper inpackages/runtimeifconnectPythonBridgeForGraph’s graph-shaped signature doesn’t fit a single node type — prefer reusing its internals via aconnectPythonBridgeForTypes(types, hasTsExecutor, opts)overload). - Steps: on the first call whose type no TS registry resolves, connect
once (single in-flight promise so concurrent first calls share it); reuse
for the flow’s lifetime; close in
close().bridgeOptions/NODETOOL_WORKER_URLas in §4.6. - Tests: unknown type with no bridge configured rejects with the existing “Unknown node type” error; bridge connect is invoked at most once under concurrency (fake bridge); integration test behind the existing worker-availability guard used by the graph DSL’s integration suite.
T8 — Harness registry entry
- Files:
packages/cli/src/harness/registry.ts. - Steps: add a
dsl-native-flowharness entry covering thepackages/dslsurface, naming the T2/T3/T6 suites as the keyless selfcheck sonodetool harness gateruns them on diffs touchingpackages/dsl/src/flow/. - Acceptance:
nodetool harness auditpasses with no new undocumented gap;harness gate --dry-runon a flow-touching diff plans the suites.
8. Out of scope
- Running untrusted code. The QuickJS sandbox remains the only surface for user- or model-authored code the host has not reviewed.
- Trigger nodes and resident flows.
- A migration of existing graph workflows.
workflows export-dslkeeps emitting the graph DSL; a flow-emitting exporter is possible later but is not part of this design.