# Graph (/docs/graph)



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](/docs/architecture#tldr--six-pieces-one-substrate)
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](/docs/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](/docs/channels). The node-level metadata shape — stage
membership, seam declaration, authoring a custom node — lives at
[Nodes](/docs/nodes). This page is about the graph as a whole.

<SourceMeta subpath="@pleach/core/graph" source="{ label: &#x22;src/graph/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/src/graph&#x22; }" />

```typescript
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 [#three-pieces]

### `Annotation` — channel schema [#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](/docs/channels).

```typescript
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--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`.

```typescript
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--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.

```typescript
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 [#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.

```typescript
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](/docs/architecture#1-stage-lattice)
for the structural constraint and the audit gate that enforces it.

## `CompiledGraph` [#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
`StreamEvent`s, 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](/docs/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 [#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.

```typescript
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](/docs/nodes).

## The lattice gate [#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](/docs/architecture#1-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](/docs/audit-gates) for the script's invocation and
the rest of the pre-merge gate set.

### Structural pins [#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](/docs/graph-node-catalog) and
[Edge catalog](/docs/graph-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 [#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 [#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:

```bash
npm run test:graphnoderef-wire-check
```

It 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 [#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](/docs/determinism) for the full
property set and [Checkpointing](/docs/checkpointing) for the
per-channel snapshot contract.

## Plugins and extra nodes [#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](/docs/plugin-contract) for the full
extension surface.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Nodes" href="/docs/nodes" description="The node-level metadata shape — stageId, acceptsSeam, inputs/outputs — and how to author a custom node." />

  <Card title="Channels" href="/docs/channels" description="The six channel kinds the graph engine schedules nodes against." />

  <Card title="Architecture" href="/docs/architecture" description="How the graph composes with seams, the audit ledger, and the event log." />

  <Card title="Plugin contract" href="/docs/plugin-contract" description="The HarnessPlugin surface — extraGraphNodes is one hook of several." />
</Cards>
