@nodetool-ai/dsl has typed factory functions that build a workflow graph in code and serialize it to the same JSON the visual editor opens.

import { constant, text, workflow, run } from "@nodetool-ai/dsl";

const x = constant.string({ value: "Hello" });
const y = constant.string({ value: ", " });
const joined = text.collect({ input_item: x.output(), separator: y.output() });

const wf = workflow(joined);   // 3 nodes, 2 edges
const result = await run(wf);  // runs locally through the kernel

Installation

Install from npm:

npm install @nodetool-ai/dsl

Or inside the NodeTool monorepo, all workspace packages are available after npm install at the repo root.

Import namespaces directly:

import { constant, text, image } from "@nodetool-ai/dsl";
import { workflow } from "@nodetool-ai/dsl";

The package root is the only public entry point. package.json also exports ./flow, but that is the internal native-flow backend, not a supported API (see the native flow design). A subpath import of a generated namespace file does not resolve. Run npm run codegen --workspace=packages/dsl and read packages/dsl/src/generated/index.ts for the namespace names this build ships.

Core Concepts

OutputHandle

When you create a node, you get back a DslNode object. Its output() method returns an OutputHandle — a symbolic reference to one of the node’s output slots. You pass handles as inputs to other nodes to create connections.

const a = constant.integer({ value: 5 });
a.output()  // → OutputHandle<number> — reference, not the value itself

Connectable

Every input field accepts a literal value, an output handle, or a compatible single-output node:

const greeting = constant.string({ value: "hi" });

const joined = text.collect({ input_item: "hi", separator: ", " });                // literal values
const joined2 = text.collect({ input_item: greeting.output(), separator: ", " });  // connection + literal

DslNode

The frozen object returned by every factory function:

const node = constant.integer({ value: 42 });
node.nodeId    // explicit id when supplied, otherwise a UUID
node.nodeType  // "nodetool.constant.Integer"
node.inputs    // { value: 42 }
node.output()  // OutputHandle for the node's default output slot

Typed workflows and composition

The terminal-node form workflow(...nodes) remains available. A schema and callback provide named parameters and results:

import { workflow, run, t, template, text } from "@nodetool-ai/dsl";

const greeter = workflow(
  { name: t.string(), suffix: t.string().optional("!") },
  ({ name, suffix }) => ({ greeting: template`Hello ${name}${suffix}` })
);

const result = await run(greeter, { params: { name: "Ada" } });
// { greeting: "Hello Ada!" }

const pair = workflow({}, () => ({
  first: greeter({ name: "Ada" }, { id: "first" }).greeting,
  second: greeter({ name: "Grace" }, { id: "second" }).greeting
}));

t provides t.string(), t.int(), t.float(), t.boolean(), t.image(), t.audio(), t.video(), t.list(item), and t.value() (an arbitrary value). For example, t.list(t.image()) carries image handles into a map body. Required scalar and media parameters become the existing specialized Input nodes (StringInput, IntegerInput, FloatInput, BooleanInput, ImageInput, AudioInput, VideoInput). Lists, arbitrary values, and optional parameters use ValueInput, which preserves their values without conversion. .optional(defaultValue) supplies a fallback. .optional() defaults to null and includes null in the inferred type. Missing required parameters fail before execution.

Callback inputs are symbolic handles. Return a non-empty object whose keys name the workflow’s Output nodes. A definition exposes .nodes and .edges. Use toKernelGraph(definition) to obtain kernel properties and execution flags for validation, saving, or visual editing. A definition is callable during another build. Each invocation uses a namespace for its operations and explicit IDs. Pass { id } to give that namespace a name. Duplicate explicit invocation names fail. Automatic invocation names are local to the build.

Builders run synchronously during authoring and composition. Keep host side effects outside them. Runtime values cannot drive ordinary JavaScript if, for, string coercion, or property access on media handles.

Runtime branches, maps, and text

import { workflow, t, choose, map, template } from "@nodetool-ai/dsl";

const greetings = workflow(
  { names: t.list(t.string()), formal: t.boolean() },
  ({ names, formal }) => ({
    greetings: map(names, (name, index) => choose(formal, {
      then: () => template`${index}: Hello ${name}`,
      else: () => template`${index}: Hi ${name}`
    }))
  })
);

choose(condition, { then, else }) builds both branch callbacks as isolated Subgraphs. At runtime, If nodes gate every branch input so only the selected Subgraph executes. Operations must be constructed inside those callbacks. A producer created before choose remains an ordinary upstream dependency. Branch output types must match, except that int and float may mix. A mismatch throws Branch output types do not match. Callbacks must return synchronously.

map(list, (item, index) => result) uses ForEach, a Subgraph body, and Collect. Item/index correlation and captured values remain aligned. Nested maps are supported. An empty list produces []. Execution order and concurrency follow the kernel’s existing actors. There is no separate scheduler or parallelMap API.

The template tag connects interpolated nodes and handles to the existing Template node’s dynamic inputs. Literal text stays in its template property. Use this tag instead of coercing symbolic values with a JavaScript template literal.

Generated nodes expose node.outputs.slot for every declared slot, including names that collide with the node API. Safe names also support node.slot, such as branch.if_true. node.output("slot") remains available. Reserved names include output, outputs, nodeId, nodeType, inputs, defaultOutputHandle, then, constructor, and __proto__.

Canonical export preserves lowered graph nodes, IDs, dynamic output metadata, input modes, correlations, and connections. It regenerates executable source rather than preserving the original helper calls or handwritten text.

Basic Workflow

import { constant, text } from "@nodetool-ai/dsl";
import { workflow } from "@nodetool-ai/dsl";

// 1. Create nodes
const x = constant.string({ value: "Hello" });
const y = constant.string({ value: ", " });

// 2. Connect nodes by passing output handles
const joined = text.collect({ input_item: x.output(), separator: y.output() });

// 3. Build the workflow graph
const wf = workflow(joined);

console.log(wf.nodes);  // 3 nodes
console.log(wf.edges);  // 2 edges (x→joined, y→joined)

workflow() traces all connections from the terminal nodes back to their sources and returns a serializable Workflow with nodes and edges.

Connecting Nodes

Implicit single-output connections

Before:

const greeting = constant.string({ value: "Hello" });
const joined = text.concat({ a: greeting.output(), b: "!" });

After:

const greeting = constant.string({ value: "Hello" }, { id: "greeting" });
const joined = text.concat({ a: greeting, b: "!" }, { id: "joined" });
const graph = workflow(joined);

Both forms produce the same connection. resolveConnection(value) recognizes an explicit handle or a single-output node. It returns undefined for literals. The factory normalizes direct input values before graph traversal. Nodes with multiple outputs require an explicit selection even when the low-level factory has a default slot. A node buried inside an object or array is rejected by the host DSL because that shape cannot wire a direct input.

{ id } is available as the second argument on every generated factory. IDs must be non-empty strings and unique within one build. Omit the argument to retain automatic IDs. Calling workflow() clears the build registry, so a new build can reuse explicit IDs. Old nodes and handles remain spent.

A node is a symbolic operation. Factory calls build the graph, and run() executes it through the existing kernel. Interpolating a node into a string throws. Build runtime text with the template tag or the Template node’s dynamic inputs.

The sandbox pack accepts the same node and ID syntax. It retains readable automatic IDs and returns kernel nodes with properties. It also retains its existing list fan-in support for arrays of handles or single-output nodes. The host returns data, streaming, and streamingInput, which its runner and the CLI translate to kernel descriptors.

Linear Chain

const a = constant.string({ value: "five" });
const b = text.collect({ input_item: a.output(), separator: "," });
const c = text.collect({ input_item: b.output(), separator: " " });

const wf = workflow(c);
// 3 nodes, 2 edges: a→b→c

Diamond (Shared Dependencies)

A node’s output can be connected to multiple downstream nodes. The graph builder deduplicates automatically.

const shared = constant.string({ value: "ten" });
const left = text.collect({ input_item: shared.output(), separator: "," });
const right = text.collect({ input_item: shared.output(), separator: " " });
const final = text.collect({ input_item: left.output(), separator: right.output() });

const wf = workflow(final);
// 4 nodes, 4 edges — `shared` appears only once

Multiple Terminal Nodes

Pass multiple nodes to workflow() to trace all branches:

const branch1 = text.collect({ input_item: x.output(), separator: "," });
const branch2 = text.collect({ input_item: x.output(), separator: " " });

const wf = workflow(branch1, branch2);

Multi-Output Nodes

Some nodes produce multiple outputs (e.g., If has if_true and if_false). Use output("slotName") to select the slot you want:

import { control } from "@nodetool-ai/dsl";

const branch = control.if_({ condition: true, value: "hello" });

// Access named outputs
branch.output("if_true")   // → OutputHandle
branch.output("if_false")  // → OutputHandle

// Calling output() without a slot throws when there is no default output

Building the Workflow Graph

workflow()

function workflow(...terminals: WorkflowTerminal[]): Workflow;
function workflow(schema, builder): WorkflowDefinition;

A terminal is any node (or object) with a nodeId. The first form traces from the terminals via BFS, discovers all connected nodes and edges, performs a topological sort, and returns a frozen Workflow object. It throws if no terminal is given, if a terminal belongs to an earlier build, or if the graph has a cycle. A node input that holds a handle nested inside an array or object also throws, because only a handle assigned directly to an input becomes an edge. Give each source its own input instead. The second form is the typed schema form described above.

The result can be serialized to JSON:

const wf = workflow(outputNode);
const json = JSON.stringify(wf, null, 2);

The host runner and CLI convert this host DSL shape to kernel descriptors. The sandbox DSL returns the kernel graph shape directly for agent validation and persistence.

run() / runGraph()

async function run(wf: Workflow, opts?: RunOptions): Promise<WorkflowResult>;
async function runGraph(...terminals: DslNode<never>[]): Promise<WorkflowResult>;

run() executes the graph locally and returns the last value each result node produced. An Output node is keyed by its name property, and any other node with no outgoing data edge is keyed by its node id. It throws if the run fails or a node reports an error. runGraph(...terminals) is run(workflow(...terminals)).

run() resolves each node in this order: RunOptions.registry, then NodeRegistry.global, then the built-in packs, then the Python bridge. The built-in packs are the base node domains plus the whisper.cpp, ElevenLabs, MiniMax, Transformers.js, Reve, and Hugging Face packs. The FAL, Kie, Topaz, AtlasCloud, Higgsfield, Together, and Replicate packs are not loaded, so pass a registry for their nodes. A node from a pack that cannot load throws a missing-pack error. An unknown type throws Unknown node type.

RunOptions field Meaning
params Values for the workflow’s typed parameters (the schema form), or Input node values.
userId User whose secrets resolve. Defaults to "1", the CLI’s local user.
authToken Declared on the type. run() does not read it.
registry A NodeRegistry consulted first. Use it for custom pack nodes.
secretResolver (key, userId) => string \| null. Without it, secrets come from environment variables only and a node with requiredSettings warns that its secret is missing.
bridgeOptions Python worker bridge options (wsUrl, workerToken). Otherwise NODETOOL_WORKER_URL and NODETOOL_WORKER_TOKEN apply. Graphs with Python nodes connect the bridge automatically.

Running from the CLI

nodetool run <file.ts> imports a TypeScript or JavaScript file, finds every exported Workflow (or workflow definition), runs each with run() and no params, and prints the outputs per export name. It fails with No Workflow exports found when the file exports none. It accepts --json and --install-missing. A definition whose schema has required parameters fails that run, so give it a parameter-less wrapper export.

nodetool validate <file.ts> checks the graph without running it. nodetool debug <file.ts> and nodetool workflows run <file.ts> also accept a .ts file. These three commands load the file differently from nodetool run: they need exactly one exported workflow and fail with Multiple Workflow exports found otherwise. When the file exports none, they fall back to JSON the file prints with console.log. nodetool workflows export-dsl <id_or_file> [-o file] writes a saved workflow as DSL source. See the CLI reference.

Using nodes from a custom pack

Packs are not loaded by run(), and they have no generated factory. Build the node with createNode, register the pack on a registry, and pass it as RunOptions.registry:

import { createNode, output, workflow, run } from "@nodetool-ai/dsl";
import { NodeRegistry } from "@nodetool-ai/node-sdk";
import { register } from "nodetool-mypack";

const registry = new NodeRegistry();
register(registry);

const reversed = createNode<{ output: string }, "output">(
  "mypack.text.Reverse",
  { text: "hello" },
  { outputNames: ["output"], defaultOutput: "output" }
);
const result = await run(workflow(output.output({ name: "reversed", value: reversed })), { registry });
// { reversed: "olleh" }

createNode(nodeType, inputs, opts) takes id, outputNames, defaultOutput, outputTypes, multiOutput, streaming, streamingInput, dynamicOutputs, inputMode, and outputCorrelation. The generated factories fill these from node metadata. For a node that streams, copy the flags from its class (streaming: true when it overrides genProcess or declares iteration, forward, or chunk correlation).

Namespaces

The generated barrel lists the available namespaces. Run npm run codegen --workspace=packages/dsl to enumerate the current factories. Import the namespace object and call factory functions:

Import Description Example
constant Fixed-value nodes constant.integer({ value: 5 })
text Text processing text.template({ string: "Hello, {{ name }}" })
image Image I/O image.loadImageFile({ path: "..." })
audio Audio processing audio.sliceAudio({ start: 0, end: 5 })
video Video processing video.trim({ ... })
control Flow control control.if_({ condition: true, value: x })
agents AI agents agents.agent({ prompt: "..." })
geminiText Google Gemini geminiText.groundedSearch({ ... })
openaiText OpenAI text openaiText.webSearch({ ... })

Namespaces under nodetool. drop the prefix (nodetool.text becomes text). Other dotted namespaces become camelCase (gemini.text becomes geminiText, lib.image.color becomes libImageColor, vector stays vector). Factory names are the last segment of the node type in camelCase (nodetool.text.FilterString becomes text.filterString), with a trailing underscore for JavaScript reserved words (control.if_) and for repeated names. See the full list in packages/dsl/src/generated/index.ts.

Code Generation

The factory functions are auto-generated from node metadata. To regenerate after adding or modifying nodes:

npm run codegen --workspace=packages/dsl

This reads all nodes in ALL_BASE_NODES from @nodetool-ai/base-nodes and the whisper.cpp nodes, introspects their metadata (inputs, outputs, types, defaults), and emits typed factory functions into packages/dsl/src/generated/. The same pass also writes the guest-side native flow modules into packages/dsl/src/flow/generated/. Pass --graph or --flow to regenerate one tree.

Generated files are committed to git. The root scripts npm run codegen:dsl and npm run codegen:dsl:check run the same commands. npm run codegen:check --workspace=packages/dsl writes nothing and exits 1 when either tree differs from the registry, and CI runs it. The codegen script is at packages/dsl/scripts/codegen.ts.

Type Mapping

Node Type TypeScript Type
str string
int, float number
bool boolean
image ImageRef
audio AudioRef
video VideoRef
text TextRef
dataframe DataframeRef
folder FolderRef
storyboard StoryboardRef
list[T] T[]
dict[K,V] Record<K, V>
enum (with values) string literal union
optional types T \| undefined
union[...] union of the mapped types
entity Entity
game_slot GameSlotSpec
slot_fill SlotFill
any and any unmapped type (models, messages, 3D refs) unknown

Calling nodes directly (flow API)

@nodetool-ai/dsl/flow runs a single node as an async function. There is no graph, no WorkflowRunner, and no sandbox: a call resolves the node in the registry and runs its process(). You write the control flow in plain JavaScript.

This is an internal surface. Its users in the repository are the exported-script generator (packages/dsl/src/script-export.ts, which emits import { startFlow, invoke, invokeStream } from "@nodetool-ai/dsl/flow") and the sandbox flow capability (packages/agents/src/capabilities/flow.ts), which loads it through importHidden. docs/harnesses.md calls it internal. A program that must open in the editor, be validated, or run on the server should build a graph with workflow() and run() instead. The ./flow export in packages/dsl/package.json exists so those two callers can resolve it. Design record: dsl-native-flow-design.

Host API

All names below are exported from @nodetool-ai/dsl/flow (packages/dsl/src/flow/index.ts).

Export Signature and behavior
startFlow(opts?) (opts?: FlowOptions) => Promise<Flow>. Creates a flow with its own ProcessingContext (job id flow-<uuid>, user id "1" unless opts.userId is set).
createFlowForContext(context, opts?) (context: ProcessingContext, opts?: Omit<FlowOptions, "userId" \| "secretResolver">) => Promise<Flow>. Runs on a context the caller owns, so cost, secrets and storage stay with that run.
invoke(nodeType, inputs, opts?) Promise<Record<string, unknown>>. Runs the node and returns its outputs. A node that yields several records is drained and folded to the last value per output slot.
invokeStream(nodeType, inputs, opts?) AsyncGenerator<Record<string, unknown> \| StreamEmission>. Yields one record per genProcess yield, or one { slot, value } emission per emit() for a node that implements the run(inputs, outputs, context) contract.
invokeGenStream, invokeRunStream The two paths behind invokeStream, with the same arguments. The first yields records only, the second yields StreamEmission only.
foldEmissions(emissions) (AsyncIterable<StreamEmission>) => Promise<Record<string, unknown>>. Folds emissions to the last value per slot.
assertNodeAvailable(nodeType, opts?) Promise<void>. Checks that the node’s pack is available without running the node.
resolveFlow(opts?) Returns the flow a call would use: opts.flow, else the ambient flow from an enclosing flow.run(), else a process-default flow (environment-variable secrets only, never closed automatically).
resetDefaultFlow() Closes and drops the process-default flow. Intended for tests.
FlowClosedError, FlowAbortError, EmissionCancelledError Thrown for a call on a closed flow, an abort with no reason of its own (name is "AbortError"), and a node’s pending emit() when the consumer stops reading.
DEFAULT_EMISSION_QUEUE_LIMIT 1024.

Types: Flow, FlowOptions, FlowCallEvent, FlowCallPhase ("start" | "end" | "error"), CallOptions, StreamEmission, StreamSource<T> (AsyncIterable<T> | ReadonlyArray<T> | T, what a caller may pass for a stream-typed input).

FlowOptions: userId, secretResolver, registry (custom NodeRegistry, consulted before the global and builtin ones), bridgeOptions (Python worker bridge), signal (aborts every call), onCall(event) (receives { type, phase, durationMs, error? }), and maxQueuedEmissions (default 1024).

CallOptions: flow (use this flow instead of the ambient one), signal (aborts this call, combined with the flow’s signal), and maxQueuedEmissions.

Flow has run(body) (makes the flow ambient for every call inside body), context (the ProcessingContext), close() (idempotent), and [Symbol.asyncDispose](), so await using closes it.

import { startFlow, invoke, invokeStream } from "@nodetool-ai/dsl/flow";

await using flow = await startFlow({
  onCall: (e) => console.log(e.type, e.phase)
});

await flow.run(async () => {
  const { output } = await invoke("nodetool.text.Concat", {
    a: "Hello, ",
    b: "world"
  });
  console.log(output); // "Hello, world"

  // For a node with streaming output, take items as they arrive.
  // "my.pack.Tokens" is a placeholder type name.
  for await (const item of invokeStream("my.pack.Tokens", { prompt: "hi" })) {
    console.log(item);
  }
});

Relation to the sandbox guest

packages/dsl/src/flow/generated/ holds typed per-namespace callables, such as text.concat(inputs) and text.concat.stream(inputs). Codegen writes them (npm run codegen --workspace=packages/dsl, or --flow for this tree only). They are not host functions. guest-core.ts is the runtime behind them and runs only inside the QuickJS guest, as part of the @nodetool-ai/sandbox-flow pack. It calls four host capabilities from the flow capability module: invoke_node, open_node_stream, take_node_stream and close_node_stream. The host runs each one through the API above, on the invoking run’s ProcessingContext. Guest authors should read the nodetool-js-scripting skill (packages/system-skills/nodetool-js-scripting/SKILL.md) rather than this section.

Best Practices

  • Import namespaces (import { text } from "@nodetool-ai/dsl") for autocompletion.
  • Do not reuse handles across builds. workflow() clears the build registry, so handles from an earlier build are stale and throw in a new workflow() call.
  • Host DSL graphs run through run() or the CLI adapter. Sandbox DSL graphs go directly to agent workflow tools.

Canonical source and visual editing

workflowToDsl(graph) generates canonical TypeScript from a graph. It emits explicit IDs through generated factories or the low-level createNode() fallback so operation identity survives regeneration. The guarantee covers representable data-edge graphs and their executable semantics. Control edges are rejected. Editor layout, comments, variable names, constants, and spreads from the original source are not preserved.

The initial authoring subset uses imports, const bindings, generated factory calls, literal options, references to earlier bindings, explicit output selection, and workflow(...terminals). Literal arrays and objects are allowed where the input contract supports them. Symbolic nested objects are rejected. The exporter emits only the terminal form workflow(...terminals). It does not emit typed schema callbacks, returned output objects, or choose and map. A saved graph exports as lowered nodes, and Input and Output nodes export as ordinary nodes.

workflowToScript(graph, { target }) is a second exporter. It turns a graph into a JavaScript body that calls each node as an async function, with target set to "sandbox" (a script document or Code node body) or "node" (a standalone .mjs). It returns { code, inputs, outputs, unsupported }. See the native flow design.

Arbitrary if, loops, mutation, dynamic property access, reflection, unknown spreads, and filesystem/network behavior fall outside the guaranteed visual editing subset. They may run during graph construction but do not become runtime graph control flow. Canonical export preserves graph semantics rather than arbitrary handwritten TypeScript. See the authoring design and follow-up plan.