# Nodes (/docs/nodes)



A node is an async function plus typed metadata. The function takes
the current state, returns a partial state update, and the metadata
tells the runtime three things: which lattice stage the node belongs
to, which LLM seam (if any) it consumes, and which channels it reads
and writes. The reactive engine reads that metadata to schedule
nodes; the lattice gate reads it to refuse out-of-stage edges.

Nodes are 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.

This page covers the node-level surface. See [Graph](/docs/graph)
for how nodes compose into a `StateGraph`, and
[Channels](/docs/channels) for the reactive state slots nodes flow
through.

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

## The node shape [#the-node-shape]

```typescript
import type { CallClass } from "@pleach/core/modelfamily"

// The node-metadata shape you author for each node. The channel-wiring
// fields mirror the `*_NODE_METADATA` consts in @pleach/core's graph nodes.
interface NodeMetadataShape {
  stageId:     "anchor-plan" | "tool-loop" | "synthesize" | "post-turn"
  acceptsSeam: CallClass | null
  subscribes?: string[]
  writes?:     string[]
}
```

| Field         | Purpose                                                                                                    |
| ------------- | ---------------------------------------------------------------------------------------------------------- |
| `stageId`     | Lattice membership. One of the four stages. `audit:graph-stages` enforces it.                              |
| `acceptsSeam` | `CallClass` literal if the node consumes an LLM seam; `null` if seam-free state transform.                 |
| `subscribes`  | Channels the node READS. When one of these advances, the engine schedules this node in the next superstep. |
| `writes`      | Channels the node UPDATES. The engine uses this to track downstream subscribers.                           |

A minimal registration:

```typescript
graph.addNode(
  "intentDetector",
  async (state) => ({ intent: classify(state.messages) }),
  {
    stageId: "anchor-plan",
    acceptsSeam: "utility",
    subscribes: ["messages"],
    writes: ["intent"],
  },
)
```

<Callout type="info" title="Coming from LangGraph?">
  LangGraph nodes typically return `new Command({ update, goto, graph: Command.PARENT })` — a sum type that collapses "edge decision + state update + cross-graph routing" into one return value. Pleach moves all three concerns to **declaration time**, not return time:

  * **State updates** — return a partial state object (LangGraph: `update` field).
  * **Edge routing** — declare with `subscribes` / `writes` metadata. The reactive engine schedules subscribers automatically (LangGraph: `goto` field).
  * **Cross-stage transitions** — lattice-gated by `stageId`. The four-stage lattice admits nine `(from-stage, to-stage)` edge patterns (five cross-stage transitions + four intra-stage chains); `audit:graph-stages` refuses out-of-stage edges at lint time (LangGraph: `graph: Command.PARENT`).
  * **Cross-subgraph routing** — subagents flow back to the parent turn via `SpawnTreeState` rather than a routing primitive.

  The two approaches converge: pleach trades runtime flexibility for static-analyzability (a graph linter can see the topology without running the nodes). If you need LangGraph's runtime routing for a specific pattern, return a state field the next stage's nodes subscribe to.
</Callout>

## Stage membership [#stage-membership]

Every node declares `stageId`. The lattice is one-way; the only
backward edge is the `messageId`-guarded `synthesize → synthesize`
retry:

```
anchor-plan  →  tool-loop  →  synthesize  →  post-turn
                   ↘ post-turn   (recovery dispatch)
```

A node whose declared stage doesn't appear in this lattice — or
whose outgoing edges cross it — fails `npm run audit:graph-stages`
before CI green. The four stages are structural: they pin cost
allocation, time-travel rollback points, and per-stage budget
gates. See [Architecture — Stage lattice](/docs/architecture#1-stage-lattice)
for the lattice's role in the broader substrate.

## Seam membership [#seam-membership]

`acceptsSeam` is `null` for seam-free nodes and a `CallClass`
literal (`utility`, `reasoning`, `converse`, `synthesize`) for
nodes that consume an LLM seam. The literal is lint-restricted to
the seam factories; outside the seams, a node resolves its model
through `AgentAdapter.resolveModel<C>()` and the locked call class
threads through statically.

Use `acceptsSeam: null` for pure state transforms, anchor builders,
context projectors, and any node whose work is computation over
existing state. Use a `CallClass` literal when the node fires a
seam. See [Architecture — Seams](/docs/architecture#3-seams).

## Reactive scheduling [#reactive-scheduling]

`subscribes` is what triggers the node. When a channel a node
subscribes to advances its version, the engine schedules that node
in the next superstep. `writes` is the inverse — the engine uses
it to compute downstream subscribers for the channels this node
updates.

Without explicit metadata, the engine falls back to subscribing
the node to **every** channel in the schema (and marking it as
writing every channel). That conservative default keeps a
metadata-less node correct but over-triggers it; declare
explicitly when the read/write set matters for documentation,
plugin-registered nodes, or any node whose subscription contract
is load-bearing. See
[Channels](/docs/channels) for the six channel kinds and their
concurrent-write semantics.

## The default node catalogue [#the-default-node-catalogue]

`buildDefaultAgentGraph` registers up to 44 nodes across the four
stages — the [Node catalog](/docs/graph-node-catalog) enumerates
every name and its `gated` condition. A representative slice per
stage:

### `anchor-plan` (10 names) [#anchor-plan-10-names]

* `sessionAnchor` — boots the turn with system primer + session-level context
* `intentDetector` — classifies user intent
* `planGenerator` — emits the per-turn plan
* `planReconciler` — handles plan drift mid-turn
* `skillActivation` — picks active skills for the turn
* `costRouter` — model resolution + cost-aware routing
* `contextProjection` — projects relevant history into the active context

### `tool-loop` (26 names) [#tool-loop-26-names]

* `llm` — the per-turn LLM decision
* `tools` — dispatches tool calls
* `subagent` — spawns subagent runtimes
* `safetyReview` — pre-synthesis safety gate
* `creditBudget` — per-turn budget gate
* `eventLogger` — logs completed tool-execution events
* `repetitionGuard` — detects loops in tool selection
* `hallucination` — narration-vs-execution analyzer
* `fabrication` — unsourced-claim guard
* `forceSynthesizer` — escape hatch when the loop won't converge — the `tool-loop → synthesize` transition
* `continuation` — depth-zero continuation gate

### `synthesize` (1 name) [#synthesize-1-name]

* `synthesizer` — the singleton synthesizer seam call; owns the final user-visible content

`synthesize` is a true singleton — `synthesizer` is the only node
the lattice admits in this stage, enforced by `SINGLETON_NODE_NAMES`.
The recovery siblings earlier revisions placed here (`refusalHint`,
`retryNarration`, `garbleRecovery`, `recovery`) retired from the
lattice; they fire now as post-turn stream filters, not graph nodes.

### `post-turn` (7 names) [#post-turn-7-names]

* `citation` — citation injection back to source data
* `contextSummarizer` — context-window summarizer for next-turn insertion
* `memoryExtraction` — long-term memory extraction
* `consolidation` — turn consolidation before the memory write
* `sessionMemoryWrite` — session-level memory persistence
* `costRollup` — per-turn token/cost rollup
* `answerSufficiency` — post-synthesis utility-seam judge verdict (D-NC-6)

See [`src/graph/nodes/`](https://github.com/pleachhq/core/tree/main/src/graph/nodes)
for the full set, and the [Node catalog](/docs/graph-node-catalog)
for the audit-gated registry.

See [`src/graph/nodes/`](https://github.com/pleachhq/core/tree/main/src/graph/nodes)
for the full set.

## New-node checklist [#new-node-checklist]

When adding a graph node to the canonical builder (or to a plugin's
`extraGraphNodes()`), the following five steps are required for the
audit gate to pass:

1. Declare `stageId` in the node-level `*_NODE_METADATA` constant
   the node file exports.
2. Declare `acceptsSeam: CallClass | null` — the literal for nodes
   that reserve a seam, `null` for pure state transforms.
3. Extend `NODE_STAGE_MAP` in
   [`src/graph/topology.ts`](https://github.com/pleachhq/core/tree/main/src/graph/topology.ts)
   with `<nodeName>: "<stageId>"`. The audit reports
   `missing-stage` distinctly from `forbidden-edge`, so an unmapped
   node fails clearly.
4. If the node consumes a seam (`acceptsSeam !== null`), add a
   wire-check entry in `seamWireCheck.test.ts` asserting the
   binding resolves to the expected `ProviderSeam<C>`.
5. Run `npm run audit:graph-stages` — the canonical graph has 28
   nodes / 61 edges today; the count must update in the same PR
   with a documented rationale (or stay byte-identical when the
   node is plugin-registered).

See [Audit gates](/docs/audit-gates) for the full pre-merge gate
set and the `ci:graphnoderef` bundle the canonical graph runs
under.

## Post-tool tier — agnostic by injection [#post-tool-tier--agnostic-by-injection]

The post-tool nodes (`enrichment`, `safetyReview`, `quality`,
`citation`) are wired into the lattice but their bodies are
**domain-free**. Host runtimes supply the domain logic at runtime
through `buildDefaultAgentGraph(config)`:

| Config field           | Node it wires  |
| ---------------------- | -------------- |
| `enrichmentExecutor`   | `enrichment`   |
| `safetyReviewExecutor` | `safetyReview` |
| `qualityEvaluator`     | `quality`      |
| `citationExtractor`    | `citation`     |

The factory only registers the matching node when the executor is
provided; absent executor means absent node. This is pure
dependency inversion — the graph layer carries no hardcoded host
logic for biosecurity scanning, citation extraction, domain
quality thresholds, or enrichment passes. A host runtime stays
in control of every domain decision through the executor it
supplies.

## Authoring a custom node [#authoring-a-custom-node]

A custom node is an async function returning `Partial<State>`,
registered with metadata. The example below summarises completed
tool calls into a single string — no seam consumed, so
`acceptsSeam` is `null`.

```typescript
import type { ToolResult } from "@pleach/core"

const toolSummaryMetadata = {
  stageId: "tool-loop",
  acceptsSeam: null,
  subscribes: ["completedTools"],
  writes: ["toolSummary"],
}

async function toolSummary(state: { completedTools: ToolResult[] }) {
  const lines = state.completedTools.map(
    (t) => `${t.success ? "ok" : "error"}: ${t.message ?? ""}`,
  )
  return { toolSummary: lines.join("\n") }
}

graph.addNode("toolSummary", toolSummary, toolSummaryMetadata)
```

The metadata is the contract. The runtime reads `subscribes` to
know when to schedule the node, `writes` to know who's downstream,
`stageId` to refuse an out-of-lattice edge at compile time, and
`acceptsSeam` to know the node doesn't consume an LLM.

## Plugin-registered nodes [#plugin-registered-nodes]

A plugin contributes nodes via `HarnessPlugin.extraGraphNodes()`,
which returns `PluginGraphNodeRegistration[]`:

```typescript
interface PluginGraphNodeRegistration {
  name:      string
  factory:   (ctx: PluginGraphNodeContext) => GraphNode
  metadata?: StateGraphNodeMetadata
}
```

The graph builder calls each factory once at compile time, passing
a `PluginGraphNodeContext` with shared resources (storage adapter,
event log writer, registries). This is the path for
domain-specific nodes that shouldn't live in the substrate's
generic builder — a retrieval node bound to a particular vector
store, a tenant-specific safety pass, an integration-specific
post-turn writer. See [Plugin contract](/docs/plugin-contract) for
the full surface.

## Determinism contract [#determinism-contract]

A node function MUST be deterministic given the same state input.
The function is `async` because real nodes do I/O — a tool call,
a storage read, a seam invocation — but the OUTPUT must be a
function of the input state. No module-level RNG. No wall-clock
reads outside the captured-at-turn-start values the runtime
threads through. No reads from singletons that mutate between
turns.

Replay determinism depends on it. A node that captures `Date.now()`
mid-body produces a different partial state on replay than on
record, the engine schedules a different next superstep, and the
diff harness flags the node as the divergence source. See
[Determinism](/docs/determinism) for the five substrate-level
contracts the node-level rule sits inside.

## What CI checks when you add a node [#what-ci-checks-when-you-add-a-node]

A node you add — to the canonical builder or to a plugin's
`extraGraphNodes()` — has to satisfy two gates before CI goes green:

* `audit:graph-stages` — the node has a stage and its edges stay in
  the lattice. An unstaged node fails with the node named:
  `[graph-stages] Node "<name>" missing stageId in NODE_STAGE_MAP`.
* `audit:edge-inventory-completeness` — every cross-stage edge
  resolves to the documented edge inventory.

Run both, plus the graph tests, with `npm run ci:graphnoderef`. The
node is one row in the [extension map](/docs/extending), and the
failure strings map to fixes on
[Gate failures → fixes](/docs/gate-failures#you-added-a-graph-node).

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

<Cards>
  <Card title="Graph" href="/docs/graph" description="How nodes compose into a StateGraph — edges, compile, the lattice as a whole." />

  <Card title="Channels" href="/docs/channels" description="The six channel kinds nodes subscribe to and write." />

  <Card title="Architecture" href="/docs/architecture" description="Stage lattice, seams, audit ledger — the substrate the node shape sits inside." />

  <Card title="Plugin contract" href="/docs/plugin-contract" description="extraGraphNodes and the rest of the HarnessPlugin surface." />
</Cards>
