Graph
The declarative topology that drives a turn — Annotation channel schema, StateGraph builder, the Send envelope, compile() runner, and the four-stage lattice gate that keeps every node attributable.
The runtime substrate is a declarative graph. Nodes consume from typed
channels and write to typed channels; the engine schedules nodes
reactively based on what advanced since the last superstep. compile()
returns the runner that SessionRuntime.executeMessage drives. The
six-pieces diagram in Architecture
names the graph as the second piece; this page documents the API
surface.
Graph is one of three concepts in the execution-graph cluster — graph, nodes, channels. See Architecture → the execution-graph cluster for the cluster framing; the other two pages cover the same substrate from the node and channel sides.
Channel kinds (the typed state slots nodes read and write) live at Channels. The node-level metadata shape — stage membership, seam declaration, authoring a custom node — lives at Nodes. This page is about the graph as a whole.
@pleach/core/graphSourcesrc/graph/import {
StateGraph,
CompiledGraph,
Annotation,
AnnotationRoot,
Send,
isSend,
} from "@pleach/core"
import type { StreamEvent } from "@pleach/core"
import type { DefaultAgentState } from "@pleach/core/graph/defaultAgentState"
import { buildDefaultAgentGraph } from "@pleach/core/graph/defaultAgentGraph"Six graph symbols have no published import path
START, END, DEFAULT_TURN_FLAGS, StateGraphNodeMetadata,
AnnotationSchema, and InferAnnotationState are declared in
@pleach/core and re-exported from its internal graph/index.ts, but
./graph is not a key in the published exports map — so
from "@pleach/core/graph" throws ERR_PACKAGE_PATH_NOT_EXPORTED on
a fresh install, and the named graph/* entries don't carry them
either. Until a barrel lands
(pleachhq/core), use the
string literals "__start__" / "__end__" that START and END
alias.
Three pieces
Annotation — channel schema
Annotation<T> declares one channel's default and reducer.
Annotation.Root(schema) collects them into a typed schema — it's
the factory; AnnotationRoot is the class it constructs, not a
callable. The engine reads the schema at compile time to
instantiate the underlying Channel<T> instances — the same six
kinds documented at Channels.
import { Annotation, messagesReducer } from "@pleach/core"
import type { Message } from "@pleach/core"
const StateSchema = Annotation.Root({
messages: Annotation<Message[]>({
reducer: messagesReducer,
default: () => [],
}),
intent: Annotation<string>({
default: () => "unknown",
}),
})
type State = InferAnnotationState<typeof StateSchema>The schema is the typed contract every node reads against. A node
that declares subscribes: ["messages"] in its addNode metadata
is scheduled whenever that channel advances; writes declares the
channels its returned patch may touch.
StateGraph — declarative builder
StateGraph is the builder. .addNode(name, fn, metadata?) registers
a node; .addEdge(from, to) wires a static edge;
.addConditionalEdges(from, routeFn, destinations) wires a branching
edge — all three arguments required; .compile() returns a
CompiledGraph.
The metadata argument is NodeMetadata —
{ subscribes?, writes?, retryPolicy?, passiveProjector? }. The
stageId / acceptsSeam declarations documented at
Nodes live on the separate *_NODE_METADATA
constants the lattice gate reads, not on this argument.
const graph = new StateGraph(StateSchema)
.addNode("agent", agentNode, {
subscribes: ["messages"],
writes: ["messages"],
})
.addNode("tools", toolsNode, {
subscribes: ["messages"],
writes: ["messages"],
})
.addEdge(START, "agent")
.addConditionalEdges("agent", routeAfterAgent, {
call_tools: "tools",
done: END,
})
.addEdge("tools", "agent")
.compile()The two-node loop above is the canonical agent-plus-tools shape.
The agent node decides; the route function reads the latest
message and returns call_tools or done; the tools node
executes pending tool calls and writes results back; control
returns to agent. The loop exits when the agent emits a final
message with no tool calls.
Send — a fan-out envelope, not yet engine-wired
Send names a target node plus a state slice, and isSend is its
runtime guard. Both are exported from the root barrel — but the
engine does not consume them. The core barrel says so itself:
conditional-edge route functions are typed
(state) => string | string[], so a returned Send cannot reach
routing, and isSend has no production call sites.
import { Send, isSend } from "@pleach/core"
// Consumer-composable only: build Send envelopes inside YOUR node
// body (e.g. paired with dispatchPerTool output) and dispatch them
// yourself. Returning them from a route function does nothing.
const envelopes = pendingCalls.map((call) => new Send("tool_runner", { call }))The engine dispatches one dynamic instance of the target node per
Send, inside the producing superstep. Each instance sees its
per-Send input as a scoped overlay on the frozen channel snapshot —
never a channel write — with the raw payload riding __sendInput.
Only what the target returns folds into state, through its declared
channel reducers, in Send-index order rather than completion order.
That fold order is what keeps fan-out replay-deterministic at any
concurrency.
Three constraints, each enforced with a hard error instead of a silent drop:
- Same-stage only, and only in
anchor-planandtool-loop. ASendbypasses subscription routing, so cross-stage fan-out is refused.synthesizetargets are refused too — dynamic instantiation would bypass the singleton per-turn cap — and so arepost-turntargets, because that stage holds durable-side-effect writers that fan-out would double-write. Graphs whose nodes carry no stage mapping on either side are admitted; the engine stays agnostic for synthetic graphs. - Capped per superstep.
maxSendsPerSuperstep(default 16, theSEND_FANOUT_CAP_ENGINE_DEFAULTconstant) bounds total dynamic instantiations in one superstep. A target's ownSends join the same superstep's queue, so a Send loop hits the cap and throws rather than spinning. - Unwired paths fail loud. A
Send[]returned on the sequential fallback path (metadata-less graphs) throws, and aSend[]returned by a post-terminal node is refused with an error naming the producing node.Commandrouting remains detection-only — not wired into the engine.
isSend(value) is the runtime guard. Use it when a route function
returns a heterogeneous union (a string for static targets, a
Send array for fan-out) and downstream code needs to discriminate.
Edges
Two edge kinds wire the topology together:
| Edge kind | Builder call | Picks the next node by |
|---|---|---|
| Static | .addEdge(from, to) | Always to after from completes |
| Conditional | .addConditionalEdges(from, routeFn, destinations) | The route function's return value, mapped through destinations |
START and END are the implicit endpoints. addEdge(START, "agent") declares the entry node; addEdge("done", END) (or
returning END from a route function) terminates the graph for
that turn.
The route function signature is (state) => string | string[]
(PathFn in StateGraph.ts — no Send overload). The third
argument, destinations: Record<string, string>, is required: it
translates the returned label to a node name, and that indirection
keeps route functions testable without holding the graph
reference.
graph
.addEdge(START, "anchor")
.addEdge("anchor", "agent")
.addConditionalEdges(
"agent",
(state) => (state.messages.at(-1)?.tool_calls ? "tools" : "synth"),
{ tools: "tools", synth: "synth" },
)
.addEdge("tools", "agent")
.addEdge("synth", "post")
.addEdge("post", END)The lattice admits nine (from-stage, to-stage) edge patterns:
the four happy-path transitions (anchor-plan → tool-loop,
tool-loop → synthesize, synthesize → post-turn, and the
post-turn → anchor-plan next-turn rollover), the
tool-loop → post-turn recovery-dispatch edge, the bounded
synthesize → tool-loop re-entry (at most once per turn), and the
three intra-stage chains — anchor-plan, tool-loop,
post-turn. synthesize is the only stage with no intra-stage
edge: the synthesize → synthesize retry self-loop is retired and
forbidden. Every other pair is forbidden. See
Architecture § stage lattice
for the structural constraint and the audit gate that enforces it.
CompiledGraph
.compile() returns a CompiledGraph — the runner. It carries the
schedule (which node fires when which channel advances) plus the
instantiated channel set, accepts a per-turn input, streams
StreamEvents, and returns when a terminal node edges to END.
You usually don't invoke a CompiledGraph directly.
SessionRuntime.executeMessage owns it and drives the iteration —
see Turn lifecycle for the call arc. The
compiled graph is exposed on the runtime for tooling that inspects
the topology (devtools, the audit:graph-stages script, replay
harnesses).
Engine guarantees
The runner under CompiledGraph is SuperstepRunner, exported —
with RetryPolicy and SuperstepNode — from @pleach/core/engine.
Four guarantees ride on it:
- Convergence detection. The runner tracks writes per superstep and stops when the graph stabilizes — nodes fire but produce no new writes — rather than only at the superstep cap.
- Termination channels. A configurable channel set (default
shouldContinue) is checked after each superstep; afalsesignal ends the run early. - Per-node retry. A node's
SuperstepNode.retryPolicyoverrides the global policy, with exponential backoff between attempts (baseDelayMs × backoffMultiplier^attempt). A node that exhausts its attempts logs a[SuperstepRunner] Node FAILEDbreadcrumb naming its subscriptions and declared writes — the failure is attributable, not silent. - Write-filtered skipping. A node whose subscribed channels produced no meaningful writes doesn't fire; an empty-object return from a node doesn't bump channel versions, so a no-op can't retrigger its subscribers.
These are properties of the TypeScript reference engine. The multi-language wire contract specifies the shapes on the wire, not the scheduler — an independent runtime may converge differently.
The default agent graph
buildDefaultAgentGraph(config) returns the pre-wired topology that
covers the four-stage lattice end to end: intent detection,
planning, the tool-loop, synthesis, and post-turn cleanup. The
matching state shape is DefaultAgentState; the frozen per-turn
flag baseline is DEFAULT_TURN_FLAGS. Pass the factory's return
value to SessionRuntime and you get a working agent without
authoring the graph yourself.
The factory's contract is dependency injection. You bring the
executors — LlmExecutor for seam-bound LLM calls,
ToolExecutor for tool dispatch, SubagentExecutor for spawned
sub-runtimes, and the enrichment hooks for plan generation,
intent detection, and quality scoring. The factory wires each
executor into the right node with the right seam. Omit an
executor and the node short-circuits to its default pass-through —
the graph still runs, that stage's enrichment is just absent.
The post-tool tier nodes (enrichment, safetyReview, quality,
citation) are agnostic-by-injection. The node bodies are
domain-free; host runtimes supply the domain logic through
config.{enrichmentExecutor, safetyReviewExecutor, qualityEvaluator, citationExtractor}. The factory only registers
the node when the matching executor is provided, so absent
executors mean absent nodes — pure dependency inversion, no
hardcoded host logic in the graph layer.
import { buildDefaultAgentGraph } from "@pleach/core/graph/defaultAgentGraph"
import type { DefaultAgentState } from "@pleach/core/graph/defaultAgentState"
const graph = buildDefaultAgentGraph({
llmExecutor,
toolExecutor,
subagentExecutor,
// intentDetector, planGenerator, qualityScorer all optional
})The node-level shape — what each node's metadata declares, how the seam binding works, what an authored node looks like — is at Nodes.
The lattice gate
Every node declares stageId: "anchor-plan" | "tool-loop" | "synthesize" | "post-turn" in its
metadata. npm run audit:graph-stages parses the default agent
graph at CI time and fails on out-of-lattice edges. The lattice is
what lets per-stage cost rollup, observability slicing, and
time-travel be structural rather than convention — a node that
fires without a stage can't ship.
See Architecture § stage lattice
for the four legal cross-stage transitions and the
SELECT stage_id, SUM(token_cost) FROM harness_auditable_calls
rollup shape that depends on the gate. See
Audit gates for the script's invocation and
the rest of the pre-merge gate set.
Structural pins
The lattice (D-36) is one-way; the only backward edge is the
bounded synthesize → tool-loop re-entry (at most once per turn,
same messageId — a post-synthesis validator that finds
unaddressed planned work sets needsMoreToolWork, which the
tool-loop entry node consumes):
anchor-plan → tool-loop → synthesize → post-turn → (next turn) anchor-plan
↘ post-turn (recovery dispatch)
↖ synthesize (bounded re-entry, ≤1/turn)ALLOWED_EDGE_PATTERNS enumerates nine legal
(from-stage, to-stage) pairs: six cross-stage transitions
(anchor-plan → tool-loop, tool-loop → synthesize,
synthesize → post-turn, the tool-loop → post-turn
recovery-dispatch edge, the bounded synthesize → tool-loop
re-entry, and the post-turn → anchor-plan rollover) and three
intra-stage chains (anchor-plan, tool-loop, post-turn — the
synthesize → synthesize retry self-loop is retired and now
forbidden). The remaining seven pairs of the 4×4 cross-product
sit in FORBIDDEN_EDGE_PATTERNS; the audit reports the exact
violation pattern.
The NODE_STAGE_MAP registry in topology.ts carries 42 names —
the upper bound on what a canonical graph can register. A real
compile under a given buildDefaultAgentGraph(config) wires the
subset whose executor or hook the host supplied; an absent
executor means an absent node. npm run audit:graph-stages
asserts the compiled node and edge counts are byte-identical
pre- and post-change for the same config — a structural pin
that catches both silent node additions and edge drift. New
nodes ship with a paired NODE_STAGE_MAP entry in topology.ts
and a documented edge-count ratchet; the audit is the
regression-detection surface. The two reference catalogs —
Node catalog and
Edge catalog — enumerate the 42-name
registry and the nine-pattern allowed edge table the gate
enforces.
The forbidden set is enumerated, not implicit. Skipping the
tool-loop (anchor-plan → synthesize, anchor-plan → post-turn),
re-planning mid-turn (tool-loop → anchor-plan), post-synthesis
re-planning (synthesize → anchor-plan), the retired
synthesize → synthesize self-loop, and post-turn re-entering an
active turn (post-turn → tool-loop, post-turn → synthesize)
all fail at CI. Two pairs that look forbidden aren't:
tool-loop → post-turn is the recovery-dispatch edge, and
synthesize → tool-loop is the bounded re-entry — both sit in the
allowed table above.
Subscription-derived transitions (G-14)
The edge table is not the only routing surface. The engine
schedules nodes by subscription: a node whose subscribes list
includes a channel written by a later-stage node is scheduled by
that write, producing a runtime stage transition that never appears
as an addEdge call. audit:graph-stages rule G-14 closes that
gap — it derives the writer → subscriber stage pairs statically
from each node's declared subscribes/writes and validates them
against the same allowed-pattern table the edges answer to.
Deliberate backward subscriptions are exempted in
ACCEPTED_SUBSCRIPTION_TRANSITIONS in topology.ts — one entry
per (subscriber, channel), scoped to a named writer or writer
stage, each carrying a written rationale. Every entry must match at
least one live derived transition; the gate fails on a stale entry,
so a retired subscription can't leave a permanent hole in the
lattice. A novel backward subscription therefore fails CI instead
of hiding behind a blanket allowance.
acceptsSeam reservation
Every node declares acceptsSeam: CallClass | null in its
metadata. The literal is the seam the node reserves — the
utility, reasoning, converse, or synthesize call class
the node consumes at invocation time. null is for pure state
transforms: anchor builders, context projectors, deterministic
reducers, post-stream detectors.
The reservation is what lets future LLM-bearing growth attach
without re-typing the lattice. A pure transform that later wants
to call a model flips its acceptsSeam: null to a CallClass
literal, and the seam binding routes through the existing
holder + cap machinery — no edge surgery, no audit drift.
Singleton synthesize seam
Exactly one ProviderSeam<"synthesize"> per SessionRuntime,
served by SynthesizeSeamHolder (D-38, D-50). The holder is
per-runtime, not module-global — a single Node.js process runs
many SessionRuntime instances concurrently, and module-global
state would either throw on a second runtime's init or clobber the
first runtime's adapter/counter binding and mis-attribute audit
rows.
createSynthesizerNode — and the recovery path that stands in for
it — consume the synthesize seam identity through the holder. The
tool-loop's createLlmDecisionNode is utility-class and consumes a
separate seam, so the singleton is per call class (D-72): only the
synthesizer reaches the synthesize seam. The cap
(TurnSynthesizeCounter) is idempotent on messageId (D-37) —
the synthesize-self-loop in the lattice is guarded by message-id
equality so a retry produces one row, not two.
The wire-check enforces both invariants from the test layer:
npm run test:graphnoderef-wire-checkIt compiles the default graph, asserts the holder is initialized
exactly once per runtime, and asserts the synthesizer + the
decision node observe the same ProviderSeam<"synthesize">
reference.
Determinism
The graph engine is reactive but deterministic. Same channel
versions feeding the same superstep produce the same firing order;
two nodes that race on a channel resolve through the channel's
reducer, which is required to be commutative and associative.
Stream observers are sync-only — no Promise<Verdict> overload —
so replay matches record on the first observation.
Annotations expose checkpoint() and restore() per channel, so a
session can rewind to any prior superstep and re-fire the graph
from that point. See Determinism for the full
property set and Checkpointing for the
per-channel snapshot contract.
Plugins and extra nodes
HarnessPlugin.extraGraphNodes() returns
PluginGraphNodeRegistration[] — { name, factory, metadata? }
entries. The graph builder calls each factory once at compile time
and adds the returned node to the topology under the plugin's
namespace. Domain-specific nodes (a job-silo dispatcher, a sandbox
workspace index, a custom enrichment pass) register here rather
than living in the substrate's generic builder.
The plugin can't add an out-of-lattice edge or bypass the
singleton synthesize seam — both fail
audit:graph-stages at CI before the registration takes effect.
See Plugin contract for the full
extension surface.
Tuning shadow (experimental)
@pleach/core/graph/tuning ships shadow-mode tuning over five
tool-loop constants — the knobs in the TuningKnobId union:
loopTerminatedPostExhaustLimit, delegationMinRemainingSteps,
delegationMinDistinctTools, substantiveAnswerMinChars, and
answerSufficiencyMinConfidentDraftLen.
The loop has three parts:
- Derive. From event-ledger observations,
buildTuningShadowSnapshotderives one proposed value per knob — the nearest-rank quantile of the observed distribution, clamped to a pre-approved envelope. The per-knob policy is aKnobDerivationPolicy:minObservations,quantile,clamp,roundTo. Derivation is pure — no DB, clock, or graph-state reads; observations arrive as plain numbers. - Install. The derived
TuningShadowSnapshotinstalls into a process-wide holder —installTuningShadowSnapshotto set,readTuningShadowSnapshotto read.installTuningShadowSnapshotFromEnvreads theTUNING_SHADOW_SNAPSHOTenv var as the path to a JSON snapshot file (Node-only; a bad path or malformed file warns and installs nothing). - Compare. At each knob's live read site, one
[tuning:shadow] knob-deltatelemetry line compares the decision the live constant just made against the decision the derived value would have made.diverged: trueis the line worth grepping.
The design contract: routing is never touched. A derived value
never changes a decision — promotion to a live constant is a human
edit at the constant's declaration. A knob whose observations are
shaped by its own gating carries selfInfluenced: true on every
verdict, so a consumer can refuse to auto-promote it. Everything is
fail-open — a malformed snapshot costs a telemetry line, never a
turn.
Two reading rules:
- A capture with zero
[tuning:shadow]lines means no snapshot was installed — never "no divergence". - Graph read sites import the holder and emit modules directly, never
the barrel. The barrel re-exports live constants from graph modules
(
knobCurrents), so a node or predicate importing it would create an import cycle. The barrel is for scripts, tests, and hosts.
Where to go next
Nodes
The node-level metadata shape — stageId, acceptsSeam, subscribes/writes — and how to author a custom node.
Channels
The six channel kinds the graph engine schedules nodes against.
Architecture
How the graph composes with seams, the audit ledger, and the event log.
Plugin contract
The HarnessPlugin surface — extraGraphNodes is one hook of several.
Defensive patterns
Six design rules the runtime holds itself to — orthogonal outcome reporting, explicit default resolution, branded ids, load-time config failure, teardown quiescence, and contained plugin dispatch — and the shipped API behind each.
Nodes
The node shape — an async function plus typed metadata declaring stage membership, seam consumption, and the channels read and written.