Skip to the content.

Graph Runtime Guide

core/graph is the built-in declarative DAG engine. It compiles a JSON or YAML graph definition into an agent.Engine, then runs waves over an agent.Board.

Layers

Layer Type Job
Wire GraphDefinition serializable graph document
Registration Registry + node types bind node type names to handlers
Build Build(def, reg, opts...) validate and compile edges/conditions
Execution *Graph run the frontier wave by wave

A *Graph is an agent.Engine.

Definition

{
  "name": "assistant",
  "version": "1",
  "entry": "chat",
  "nodes": [
    {
      "id": "chat",
      "type": "inference",
      "config": {
        "model": {
          "id": { "provider": "deepseek", "name": "deepseek-flash" }
        },
        "messages_channel": "__main_channel"
      }
    },
    {
      "id": "tools",
      "type": "tool",
      "config": {
        "messages_channel": "__main_channel",
        "results_key": "tool_results"
      }
    }
  ],
  "edges": [
    { "from": "chat", "to": "tools", "condition": "tool_pending == true" },
    { "from": "tools", "to": "chat" },
    { "from": "chat", "to": "__end__" }
  ]
}

condition and skip_condition use expr-lang expressions over board variables. ${board:<path>} references inside config strings resolve before node decode, so system_prompt may interpolate upstream output. Paths are dot-separated: ${board:user.name} reads var user and then its name field (an exact variable named user.name wins). Nested lookup walks maps with string keys (map[string]any, map[string]string, ...) and exported struct fields. An optional default follows a colon (${board:limit:3}); a reference standing alone keeps the variable's typed value, and defaults that are valid JSON literals keep their type. Referencing a missing variable is a validation error unless a default is given — prefix with a backslash (\${board:x}) to emit literal text. Node types must be registered in the registry passed to Build; an unregistered type fails Build, not GraphDefinition.Validate.

Engine settings (agent.Engine/graph)

The deployment-facing factory is core/graph/resource. Its settings subtree:

Key Meaning
graph graph definition: literal content, {file: ...}, or {embed: ...} (required)
script_runtime_name name of the agent.ScriptRuntime dep bound to script nodes; default js
build.max_iterations cap on nodes routed per Execute; default 100, 0 lifts the guard
build.timeout wall-clock bound for one Execute call (Go duration string, e.g. 1h); 0 means no engine-level bound
build.run_end_publish_timeout deadline for publishing terminal run events; default 5s, must be > 0
build.max_node_retries retries per node before the run fails
build.parallel.enabled enable parallel waves
build.parallel.branch_timeout per-branch wall-clock bound; 0 means none
build.parallel.max_concurrency max concurrent branches
build.parallel.max_branches max total branches per wave
build.parallel.merge_strategy first_write_wins or last_write_wins

Engine deps are derived from the definition: an inference node needs the inference dep (explicit model) and/or router dep (no model), a tool node needs tools, a script node needs script_runtime. workspace and sandbox are optional and unlock the script fs / shell globals (below). The optional script_bindings dep replaces the engine-level script surface; see "Script bindings" below.

Budgets and timeouts

Three independent budgets bound a graph run; they compose with whatever the host layers on top.

All three are per Execute, not per agent run: a revise attempt or a resume is a new Execute call and gets a fresh window. Bound the whole run from the agent layer with policy.run_timeout (a Go duration string, e.g. 10m) or agent.WithRunTimeout; that single deadline wraps every attempt, Referee and Committer, and the shorter of it and the engine's own timeout wins.

max_iterations is a loop guard, not a cost guard: node retries (build.max_node_retries) and provider calls made inside one node (a script looping over inference.generate, say) do not advance the counter. Enforce cross-attempt token or cost limits through the host's usage budget — agent.Host.ReportUsage returning errdefs.BudgetExceeded.

A failed engine call surfaces as BudgetExceeded (HTTP 429) or, once a deadline fires, as a timeout (HTTP 504). Both end the run, neither is retried by build.max_node_retries, and a non-completed attempt never consumes the revise budget. Timeouts raised inside a node by its provider or tool are retried like any other transient failure.

Build-time topology findings (unreachable nodes, cycles with no conditional exit, missing default branches) never fail the build; the resource factory logs each one as a graph build warning.

Script runtimes

Script nodes run on a bound agent.ScriptRuntime resource named by the engine's script_runtime_name (default js). The core runtimes:

resources:
  js:
    kind: agent.ScriptRuntime
    impl: js
    settings:
      pool_size: 4              # positive; number of pooled VMs
      max_call_stack_size: 512  # js only; positive call-stack bound
      max_exec_time: 30s        # Go duration; zero disables the cap

  lua:
    kind: agent.ScriptRuntime
    impl: lua
    settings:
      pool_size: 4
      max_exec_time: 30s

Implementations live in core/agent/scriptrt/{jsrt,luart}. A runtime that cannot nest sub-scripts degrades runtime.execScript to a not_available signal instead of panicking (see below).

Node types

inference — single-shot generation

Runs one Generate call: the channel tail is the current turn's input, the assistant message (tool calls included) is appended to the same channel. The node never executes tool calls — a tool_calls finish reason is flagged onto tool_pending_key and the graph routes onward.

Config field Meaning
model explicit target {id: {provider, name}, profile?}; absent defers selection to the wired router
model_hint per-call model preference for the router: provider/name or a bare name (e.g. ${board:model}); the hinted target is tried first, and on failure fallback restarts at the head of the default chain
messages_channel board channel holding the conversation; empty means the main channel (__main_channel)
system_prompt prepended system message when the context does not already start with one (may be {file: ...} / {embed: ...})
output_key board var receiving the full assistant Message
usage_key board var receiving the call's inference.Usage
tool_pending_key board var receiving finish_reason == tool_calls (the condition edges branch on)
undefined_tool_recovery {enabled, max_per_run}: convert an undefined-tool rejection into recoverable board feedback instead of failing the node; disabled by default
recover_pending_key board var receiving whether the round was recovered from an undefined-tool response (loop-back condition)
recover_count_key board var receiving the per-run recovery counter (node hard-fails past max_per_run)
recover_feedback_key board var receiving the user-role feedback text for the recovered round; the recovered inference round consumes it as its current input; defaults to the reserved per-node __recover_feedback.<node id> var when unset
stream open a GenerateStream; text/reasoning deltas stream incrementally, the board still gets one assembled message
stream_failure_policy {on_error, on_interrupt} actions (commit_partial, default, or discard) for what happens to buffered partial text when a streamed call fails or is interrupted; a recoverable undefined-tool rejection always discards
tools named catalog tools the model may call this turn
all_tools send the catalog's entire visible set; with tools, names are declared RequiredByName and must exist
tool_choice constrain when/which tools are called
intent canonical execution envelope: {text, image, audio, video} with per-modality controls (see below)
extensions provider knobs in the {provider, id, fields} wire form, resolved via the assembly's decoders
request_metadata opaque map[string]string copied onto the canonical GenerateRequest; keys are deployment-defined and core never interprets them

Behavior:

{
  "type": "inference",
  "model": { "id": { "provider": "openai", "name": "gpt-image-1" } },
  "intent": {
    "image": { "size": { "width": 1024, "height": 1024 }, "count": 2 }
  }
}

tool — batch tool execution

Reads the tool_call parts off the channel tail's assistant message, executes the whole batch through the tool dispatcher (model-issued call ids preserved, so the provider can pair results next turn), and appends the results as one role=tool message — again a valid tail for inference.

Config field Meaning
messages_channel channel whose tail holds pending tool calls; empty means the main channel (__main_channel)
results_key board var receiving the raw []message.ToolResult for downstream nodes/conditions

Each result uses the message wire shape — {call_id, content: {parts: [...]}, is_error} — so a multimodal tool result stays intact; read content.parts[].text for the text of a text part.

Behavior:

script — embedded script with host bindings

Runs inline JS or Lua source on the bound agent.ScriptRuntime. Decoding is strict: unknown top-level config fields are errors.

Config field Meaning
runtime name of the wired agent.ScriptRuntime (must equal the engine's script_runtime_name); required
source inline script source; required (may be {file: ...} / {embed: ...})
name execution label for errors and runtime pooling; defaults to the node ID
config becomes the script's config global; values may carry ${board:*} references

Each invocation assembles a fresh script environment with the standard bridges below, executes the source, and maps control signals to Go errors. Subscriptions opened mid-script are bound to the invocation and never outlive it.

Script bridges

Scripts never touch the Go context directly; the node exposes named globals through core/agent/bindings (plus three graph-layer bridges). This is the standard surface — a deployment replaces it wholesale with an agent.ScriptBindings provider (see "Script bindings" below). Its availability depends on the engine's deps:

Global Wired when Provides
board always read/write board vars and channels
expr always evaluate expr-lang expressions
host always publish/emit/interrupt/askUser/usage
run always read-only run identity
node always current graph node identity
tools tools dep call tools / list catalog
inference inference and/or router dep LLM generation, routing, streaming
stream always (needs a host with an event bus) subscribe to node stream deltas
parallel always (active during parallel waves) cancel sibling branches
fs workspace dep workspace file operations
shell sandbox dep sandboxed command execution
runtime always nested sub-script execution

Script bindings (agent.ScriptBindings)

The table above is the standard surface. Which globals a script execution gets is decided by one engine-level bindings provider, wired as the optional script_bindings dep:

resources:
  std:
    kind: agent.ScriptBindings
    impl: standard          # core's implementation of the table above
    deps:
      tools: tools          # each wired capability unlocks its global
      workspace: ws
      inference: infer

agents:
  assistant:
    engine:
      kind: agent.Engine
      impl: graph
      deps:
        script_runtime: js
        script_bindings: std
      settings:
        graph: {file: ./graphs/assistant.yaml}

The standard impl also carries the policy of the bridges whose shape the surface owns, so a deployment configures them without touching Go:

Setting Meaning
tools.allow the exact catalog tools the surface may call; every name must exist in the wired tool assembly, so a typo fails the build. An explicit empty list denies every tool
tools.allow_all expose the whole catalog (fully trusted scripts only); conflicts with allow
fs.max_read_bytes positive cap on one fs.read; omitting keeps the bridge default
fs.max_write_bytes positive cap on one fs.write; omitting keeps the bridge default
shell.allow commands shell.exec may run, matched as written and by base name; an explicit empty list denies every command, omitting leaves the sandbox policy in charge

A policy section requires the capability it configures (tools, workspace, sandbox) to be wired; asking for a policy without it fails the build. Omitting a section keeps the bridge default — notably, tools stays fail-closed: without tools.allow or tools.allow_all a script cannot call any tool. The fallback surface (no script_bindings dep) carries no document-level policy at all, so wiring the standard resource is how a deployment configures the script surface.

resources:
  std:
    kind: agent.ScriptBindings
    impl: standard
    deps: {tools: tools, workspace: ws}
    settings:
      tools: {allow: [search, fetch]}
      fs: {max_read_bytes: 65536}

Hosts extend the surface by registering their own impl of the kind:

// The provider is built once per deployment: per-execution state (board,
// node identity, executing runtime) arrives through the invocation.
type tokensProvider struct{}

func (tokensProvider) Bind(inv bindings.Invocation) ([]bindings.Binding, error) {
    tokens := bindings.Binding{
        Name: "tokens",
        Value: map[string]any{
            "estimate": func(text string) int { return len(text) / 4 },
            "nodeID":   func() string { return inv.NodeID },
        },
    }
    return []bindings.Binding{tokens}, nil
}

// The factory is registered on the host's resource registry.
func (tokensFactory) Spec() resource.Spec {
    return resource.Spec{
        Kind: bindings.ResourceKind, Impl: "tokens",
        // Optional: compose the deployment's other surfaces instead of
        // replacing them.
        Deps: []resource.DepSpec{
            {Name: "base", Type: bindings.ResourceKind},
        },
    }
}

func (tokensFactory) New(ctx context.Context, in resource.Input) (any, error) {
    extra := bindings.Provider(tokensProvider{})
    base, ok := in.Dep("base")
    if !ok {
        return extra, nil
    }
    surface, ok := base.(bindings.Provider)
    if !ok {
        return nil, errdefs.Validationf("tokens bindings: base is %T", base)
    }
    return bindings.Chain(surface, extra), nil
}

registry.Register(tokensFactory{})
resources:
  std: {kind: agent.ScriptBindings, impl: standard, deps: {tools: tools}}
  tok: {kind: agent.ScriptBindings, impl: tokens, deps: {base: std}}

agents:
  assistant:
    engine:
      deps: {script_runtime: js, script_bindings: tok}

A provider is built once per deployment and shared across runs, so it reads per-execution state from the invocation it is handed: the run's board, the host, the node identity, the executing agent.ScriptRuntime, the per-node stream emitter, and the run identity. bindings.Provider.Bind returns the ordinary globals; an optional bindings.LateProvider.BindLate runs after them and sees the built environment — that is how runtime captures the final bindings map so nested sub-scripts inherit the same surface. A name bound twice fails the execution instead of silently shadowing, and every global name must be an identifier that is neither a JavaScript nor a Lua keyword (tokens, db_query; not tokens.estimate, $helper, var, end) — a name a script cannot reference is rejected at assembly rather than installed as an unreachable property.

The surface is observable: the node span carries script.bindings.count and script.bindings.source (engine for a wired provider, standard for the fallback), and a debug log named "script bindings assembled" lists the node identity and the global names. That is what makes "the script says board is not defined" distinguishable from a typo in the script.

Custom node types (graph.NodeType)

Beyond the three built-ins, node types are deployment resources. A resource of kind graph.NodeType produces a value implementing graph.NodeTypeRegistrar; the graph engine factory registers every node_type dependency into the engine's registry before Build, so the graph definition can reference the type by name.

Because a custom node type is a resource, it participates in the normal resource DAG: it can declare its own deps (script runtime, tools, workspace, ...), is built exactly once, and can be shared by several agents. The engine accepts the deps under the node_type name or node_type.<suffix> keys (the Many dep form, like tool.* sources).

Script-backed impl (graph.NodeType/script)

The built-in script impl defines the node behaviour as an embedded script that runs on the bound agent.ScriptRuntime with exactly the same bridges as the built-in script node. The node's config in the graph definition is the script's config global (board references still resolve first), and the script writes/reads the board like any script node.

Settings field Meaning
type the node type name graphs reference (must not collide with built-ins or other mounted types)
source handler source: inline string or {file: ...} / {embed: ...} (required)
desc optional human-readable type description
reads / writes static I/O roles (`kind: var

Deps of graph.NodeType/script: script_runtime (required), plus optional tools, inference, router, workspace, sandbox enabling the same globals the built-in script node unlocks (tools, inference, fs, shell). When the engine wires a script_bindings dep, that engine-level provider serves the custom type too — the node type's own deps are the fallback.

resources:
  greet:
    kind: graph.NodeType
    impl: script
    deps:
      script_runtime: js
    settings:
      type: greet
      source: board.setVar("greeting", "hi " + config.name)
      writes:
        - kind: var
          name: greeting
          required: true

agent:
  asst:
    engine:
      kind: agent.Engine
      impl: graph
      deps:
        node_type.greet: greet

The graph can then use the type like any other:

{
  "name": "greeter",
  "entry": "hello",
  "nodes": [
    {
      "id": "hello",
      "type": "greet",
      "config": { "name": "${board:user}" }
    }
  ],
  "edges": []
}

Go-backed custom node types follow the same contract: a host or plugin registers a graph.NodeType resource factory whose value implements graph.NodeTypeRegistrar (typically by calling graph.RegisterType with a closure capturing the resource's deps). The engine wires them identically.

board

Direct read/write of the engine board.

Method Signature
board.getVar(key) any
board.setVar(key, value) —
board.deleteVar(key) —
board.getVars() map
board.hasVar(key) bool
board.resolve(str) any — typed ${board:*} expansion
board.resolveString(str) string — text ${board:*} expansion
board.channel(name) array of message objects (never null)
board.channelLen(name) number — message count
board.lastMessage(name) last message object, or null when the channel is empty
board.channelTail(name, count) array — the last count message objects, in channel order
board.setChannel(name, msgs) throws on validation errors
board.appendChannel(name, msg) throws on validation errors; msg is one message or an array
board.MAIN_CHANNEL the reserved default channel name (use it instead of the literal)

resolve / resolveString run the same ${board:*} expansion the engine applies to node config strings: missing references error unless a default is given, and resolveString renders non-string values to text.

Messages use the inference wire format: {role, content: {parts: [...]}} with parts like {"type": "text", "text": "..."} or {"type": "image", "source": {...}}. Decoding is strict, so typos surface as errors.

channel projects every message into a fresh object, so a node that only needs the size or the tail of a long conversation should read through the narrow accessors instead: channelLen is a count, lastMessage is the tail, channelTail is the last count messages. They touch the channel header and the tail alone. Reads are detached — editing what one returned never edits the board — and appendChannel copies what it appends, so a batch a script built stays its own.

lastMessage answers null on an empty channel, so guard the fields you read off it. The array form of appendChannel lands a whole batch under one lock — a concurrent reader sees all of it or none of it — and validates the batch before anything is appended, so a batch that fails lands none of it. An empty list is not an error either way: an empty batch appends nothing, and setChannel with an empty list clears the channel. Lua spells both as the empty table.

board.setVar("plan", "1. read files\n2. summarize");
board.appendChannel(board.MAIN_CHANNEL, {
  role: "user",
  content: { parts: [{ type: "text", text: "go" }] },
});

expr

Evaluates expr-lang against an environment map (compiled programs are LRU cached).

var ok = expr.eval("score > 0.5 && !done", { score: 0.8, done: false });

host

The script-side handle onto agent.Host. Every method returns nil / "" on a no-op host, so scripts can call them unconditionally.

Method Signature
host.publish(subject, payload) error — low-level escape hatch to any event subject
host.emit(type, payload) void — per-node stream delta
host.checkInterrupt() {cause, detail} | null
host.askUser({parts, schema, source, metadata}) {parts, metadata}
host.reportUsage({input, output, total}) error
host.drainSteer() array of wire messages — take-all, [] when empty

host.emit event types recognized by the graph script node:

Type Payload Resulting delta
token string (or any value, JSON-stringified) text part delta
tool_call JSON string or {id, name, arguments} tool call part delta
tool_result {tool_call_id, parts, is_error} tool result part delta
part canonical part wire object ({"type": ...}) arbitrary message.Part delta
finish {finish_reason, request_id?, response_id?} finish delta with typed fields
provider_outputs [{provider, extension, value}] provider_outputs delta
anything else any value passthrough delta; raw payload rides under payload

Emission is fire-and-forget: publish failures are dropped, not thrown. Payloads that do not decode into the type's required shape (e.g. a tool_call without id) are skipped instead of published as empty deltas. finish requires finish_reason; provider_outputs requires a non-empty array with provider / extension / value on every entry.

Steering a running turn. host.drainSteer() returns the messages the run's owner submitted while the run was in flight (the turn's steer queue) and empties it — [] when nothing is queued. The result uses the same wire shape board.appendChannel accepts, so the canonical steer node is:

var pending = host.drainSteer();
board.appendChannel(board.MAIN_CHANNEL, pending);

Binding the drain before appending is what keeps the node portable: in argument position a host.* call expands to both of its return values under Lua, where the binding reads the second one as an extra argument (expected 2 arguments, got 3). Appending the array lands the whole batch in one call, under one lock, so a concurrent reader sees all of the correction or none of it — a per-message loop can expose a prefix. An empty queue appends nothing and is not an error.

Under JS a rejected append throws. Under Lua the binding reports the same rejection as its return value instead — Lua has no exception to catch — so a Lua steer node that must not pass a bad batch silently checks it: local err = board.appendChannel(...); if err then error(err) end.

The document declares where steer text lands by placing such a node, just like any other step: the usual position is a round boundary (tools → steer → next round), which puts the message after the tool result and before the next inference call, so the next round reads a valid conversation. Nothing else drains the queue — the executor's interrupt checkpoints are not delivery points, and core never injects text mid-stream. Draining is destructive and not latching: a later call answers with whatever arrived since. host.drainSteer() throws errdefs.NotAvailable when the host cannot accept steer at all (same classification as stream.subscribe_node without a bus); the runtime installs the queue on every turn, so a graph executed outside a runtime session (tests, dry runs) is the case that sees the error. Messages still queued when the turn ends before the node ran are never delivered — the turn result's state carries session.pending_steer with the count. The event stream does not: the run-end envelope is published before the turn settles, so read that count from the turn result.

run

Read-only run identity, sourced from the ambient RunInfo. All getters return "" when unset, so scripts can branch on absence directly.

Method Returns
run.get_run_id() run id
run.get_task_id() task id
run.get_agent_id() agent id
run.get_context_id() conversation id
run.get_parent_run_id() parent run id (empty for top-level runs)

node

Per-step identity of the executing graph node (distinct from run, which is immutable across a whole run).

Method Returns
node.id() the node's ID in the definition
node.type() the registered node type name

tools

Script-callable facade over the tool dispatcher/catalog pair, with an allow-list set by the host (WithAllowedToolNames / WithToolAllowAll). By default no tool is callable.

Method Signature
tools.call(name, argumentsJSON) {parts, is_error, tool_call_id, name}
tools.callAll([{name, arguments, id?}, ...]) same shape per entry, plus name; batch via the dispatcher
tools.list() [names] the script is allowed to call
tools.definitions() wire-ready tool declarations to splice into a generate request

parts is the canonical part array ({"type":"text","text":"..."}, {"type":"image","source":{...}}, ...), never a flattened string, so a multimodal tool result reaches the script intact and can be passed back to tools.callAll or emitted unchanged.

Denied entries get an is_error result in place; the rest of the batch still runs. A model-issued id passed to callAll is forwarded verbatim, which is required when results feed back into an LLM turn.

inference

LLM generation from scripts. Requests/responses are the canonical GenerateRequest / GenerateResponse wire JSON; the bridge performs exactly one call per invocation, so multi-turn tool loops live in script-land (check finish_reason, call the message's tool parts via tools.callAll, continue with input.role = "tool").

Method Behavior
inference.generate(request) exact model (model key required) via the assembly
inference.route(request) router-selected target (no model key); response gains trace
inference.explain(request) preflight against one exact model, no provider I/O
inference.routeExplain(request) preflight through the router; returns {explanation, decision, limits}
inference.models() full catalog of model descriptors
inference.inspect(model) one model's descriptor
inference.embed(request) / inference.routeEmbed(request) embedding twins of generate/route
inference.explainEmbed(request) / inference.routeExplainEmbed(request) embedding twins of explain/routeExplain
inference.explainStream(request) / inference.routeExplainStream(request) local stream preflight without opening a provider stream
inference.stream(request) / inference.routeStream(request) streaming twins returning {next, result, close}
inference.transcribe(request) / inference.routeTranscribe(request) whole-audio transcription; route twin gains trace
inference.explainTranscribe(request) / inference.routeExplainTranscribe(request) transcription preflight (exact model vs router); route twin returns {explanation, decision, limits}
inference.transcribeSession(request) / inference.routeTranscribeSession(request) duplex session handle {send, next, result, interrupt, finish, close}; route twin attaches trace to the result

Stream handle: next() returns one event or null at EOF, result() returns the accumulated GenerateResponse after next() returned null, and close() abandons a stream early (idempotent).

Transcription session handle:

Method Behavior
session.send(chunk) feeds one media.AudioChunk wire JSON ({data, sequence}); fails once the session ended
session.next() one TranscriptionSessionEvent, or null at normal end; errors terminate the iteration
session.result() the accumulated TranscriptionResponse (same shape as transcribe); only after next() → null
session.interrupt() terminates abnormally (barge-in); surfaces from the next next() / result()
session.finish() explicit end-of-input (FinishInput); errors when the provider session lacks the capability
session.close() idempotent early exit

Extensions ride a bridge-level extensions array of {provider, id, fields}, resolved through decoders the host registered with WithExtensionDecoder; unregistered identities fail.

var resp = inference.route({
  context: board.channel(board.MAIN_CHANNEL).slice(0, -1),
  input: {
    role: "user",
    content: {
      content: "summarize the workspace",
      intent: { text: { tools: tools.definitions() } },
    },
  },
});
if (resp.finish_reason === "tool_calls") {
  // pull tool_call parts off resp.message, call tools.callAll, loop
}

stream

Node stream subscriptions over the run's event bus. Subscriptions are scoped to the script invocation and closed automatically when it ends or when every subscribed node has terminated.

Method Signature
stream.subscribe_node({node_id | node_ids, run_id?, buffer_size?}) {next, next_timeout_ms, current, close}
iter.next() bool — blocks for the next matching event
iter.next_timeout_ms(ms) (bool, error) — bounded wait; timeout returns false without closing
iter.current() latest event map, or null
iter.close() idempotent early close

Options: node_id and node_ids are mutually exclusive; run_id must match the current run (overrides are rejected); buffer_size is 1..4096, default 256, with DropOldest backpressure so slow scripts still see terminal lifecycle events.

Each event is a map with event, envelope_id, id, subject, time, run_id, node_id, agent_id, plus payload fields. Lifecycle events are step.started / step.ended (status success or error) / step.skipped; stream deltas arrive as event: "stream.delta" with type / part / speculative / branch_id fields, plus payload carrying the raw value of forward-compatible custom emit types.

var iter = stream.subscribe_node({ node_id: "planner" });
while (iter.next_timeout_ms(5000)) {
  var ev = iter.current();
  if (ev.event === "step.ended") break;
}
iter.close();

parallel

Parallel-fork controls. The controller exists only while a parallel wave is in flight; outside one cancelNode returns false (fail open).

Method Signature
parallel.cancelNode(nodeID, reason) bool — true if a branch was cancelled

fs

Workspace file operations (wired when the engine has a workspace dep; the workspace's own scoping/deny rules apply).

Method Signature
fs.read(path) (string, error)
fs.write(path, content) error
fs.exists(path) bool
fs.delete(path) error

shell

Sandboxed command execution (wired when the engine has a sandbox dep). Returns a result map instead of throwing on non-zero exits.

Method Signature
shell.exec(cmd, args...) {exit_code, stdout, stderr}

The host may restrict commands with WithAllowedCommands; a rejected command returns exit_code: -1 with an error in stderr.

runtime

Nested script execution. Child scripts inherit the parent's final bindings; the child's config global is the second argument.

Method Signature
runtime.execScript(source, config) (signal, error) — error signals throw in the parent

Nested execution is a runtime capability, not a guarantee: a pool with one busy VM degrades to a not_available signal instead of panicking.

Example

{
  "name": "assistant",
  "entry": "chat",
  "nodes": [
    {
      "id": "chat",
      "type": "inference",
      "config": {
        "messages_channel": "__main_channel",
        "tool_pending_key": "tool_pending",
        "tools": ["read_file", "list_dir", "grep"]
      }
    },
    {
      "id": "tools",
      "type": "tool",
      "config": {
        "messages_channel": "__main_channel",
        "results_key": "tool_results"
      }
    },
    {
      "id": "finalize",
      "type": "script",
      "config": {
        "runtime": "js",
        "source": { "file": "./nodes/finalize.js" }
      }
    }
  ],
  "edges": [
    { "from": "chat", "to": "tools", "condition": "tool_pending == true" },
    { "from": "tools", "to": "chat" },
    { "from": "chat", "to": "finalize", "condition": "tool_pending == false" },
    { "from": "finalize", "to": "__end__" }
  ]
}
// nodes/finalize.js
var last = board.lastMessage(board.MAIN_CHANNEL);
if (last) board.setVar("summary", last.content.parts[0].text);
host.emit("token", "done: " + run.get_run_id());

Sources of truth

core/graph/definition.go, core/graph/graph.go, core/graph/execute.go, core/graph/nodes/inference.go, core/graph/nodes/tool.go, core/graph/nodes/script/, core/graph/resource/resource.go, core/agent/bindings/.