Graph
The declarative topology that drives a turn — Annotation channel schema, StateGraph builder, Send fan-out, 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,
START,
END,
CompiledGraph,
Annotation,
AnnotationRoot,
Send,
isSend,
DefaultAgentState,
buildDefaultAgentGraph,
DEFAULT_TURN_FLAGS,
} from "@pleach/core/graph"
import type {
StreamEvent,
StateGraphNodeMetadata,
AnnotationSchema,
InferAnnotationState,
} from "@pleach/core/graph"Three pieces
Annotation — channel schema
Annotation<T> declares one channel's default and reducer.
AnnotationRoot collects them into a typed schema. The engine reads
the schema at compile time to instantiate the underlying
Channel<T> instances — the same six kinds documented at
Channels.
import { Annotation, AnnotationRoot, messagesReducer } from "@pleach/core"
import type { Message } from "@pleach/core"
const StateSchema = AnnotationRoot({
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 inputs: ["messages"] in its metadata sees State
narrowed to { messages: Message[] } at the call site, and writing
to a channel not in the schema is a compile error.
StateGraph — declarative builder
StateGraph is the builder. .addNode(name, fn, metadata) registers
a node; .addEdge(from, to) wires a static edge;
.addConditionalEdges(from, routeFn, edgeMap) wires a branching
edge; .compile() returns a CompiledGraph.
const graph = new StateGraph(StateSchema)
.addNode("agent", agentNode, {
stageId: "tool-loop",
acceptsSeam: "reasoning",
inputs: ["messages"],
outputs: ["messages"],
})
.addNode("tools", toolsNode, {
stageId: "tool-loop",
acceptsSeam: null,
inputs: ["messages"],
outputs: ["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 — conditional fan-out
Send is the return value from a conditional-edge function that
dispatches the same target node multiple times with different
state slices. It's the fan-out primitive: tool-batch execution,
parallel subagent spawn, map-reduce over a doc set.
import { Send, isSend } from "@pleach/core"
function dispatchTools(state: State): Send[] {
const pending = state.messages.at(-1)?.tool_calls ?? []
return pending.map((call) => new Send("tool_runner", { call }))
}
graph.addConditionalEdges("agent", dispatchTools)The engine schedules each Send as a concurrent invocation of the
target node with its own state slice. Writes land through the
channel reducers — messagesReducer for the message accumulator,
appendReducer for a Topic, and so on — so fan-out is
deterministic when the reducers are commutative.
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
Three 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, edgeMap?) | The route function's return value |
| Fan-out | .addConditionalEdges(from, routeFn) returning Send[] | One target invocation per Send |
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 | Send | Send[].
When it returns a string, the optional edgeMap translates the
label to a node name — 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",
)
.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, and the four
intra-stage chains — anchor-plan,
tool-loop, post-turn, plus the synthesize → synthesize retry
self-loop. 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).
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, DefaultAgentState } from "@pleach/core/graph"
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
messageId-guarded synthesize → synthesize retry:
anchor-plan → tool-loop → synthesize → post-turn → (next turn) anchor-plan
↘ post-turn (recovery dispatch)ALLOWED_EDGE_PATTERNS enumerates nine legal
(from-stage, to-stage) pairs: five cross-stage transitions
(anchor-plan → tool-loop, tool-loop → synthesize,
synthesize → post-turn, the tool-loop → post-turn
recovery-dispatch edge, and the post-turn → anchor-plan
rollover) and four intra-stage chains (anchor-plan, tool-loop,
post-turn, plus the synthesize → synthesize retry). 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 44 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 44-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
tool dispatch or re-planning (synthesize → tool-loop,
synthesize → anchor-plan), and post-turn re-entering an active
turn (post-turn → tool-loop, post-turn → synthesize) all fail
at CI. tool-loop → post-turn is not forbidden — it's the
recovery-dispatch edge in the allowed table.
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.
Where to go next
Nodes
The node-level metadata shape — stageId, acceptsSeam, inputs/outputs — 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.
Language-agnostic contract
Which shapes are the contract — the wire shapes, checkpoint envelope, audit row, sync vectors, event log — versus implementation details. What an independent client must implement.
Nodes
The node shape — an async function plus typed metadata declaring stage membership, seam consumption, and the channels read and written.