# Node catalog (/docs/graph-node-catalog)



The default agent graph registers nodes through `buildDefaultAgentGraph(config)`. Which nodes wire in for a given compile depends on the executors and hooks the host supplies; the *registry* — every name the audit script will accept — is fixed at 44 entries spread across the four lattice stages.

This page enumerates that registry. The companion page is [Edge catalog](/docs/graph-edge-catalog). Conceptual framing lives in [Graph](/docs/graph); the node-shape contract lives in [Nodes](/docs/nodes).

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

## Reading the table [#reading-the-table]

Each row is a node name the host may register. The columns:

| Column        | Meaning                                                                                                                                                                                             |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`        | The string passed to `graph.addNode("...")`. Stable public identifier — renamed only via a `Changed` changelog row.                                                                                 |
| `acceptsSeam` | The `CallClass` the node consumes — `utility`, `reasoning`, `converse`, or `synthesize` — or `null` for pure state transforms.                                                                      |
| `gated`       | `always` if the node wires unconditionally; `config.<field>` if registration requires the host to supply that executor or hook. An absent executor means an absent node — the graph still compiles. |
| `purpose`     | One-line summary of what the node does.                                                                                                                                                             |

The `gated` column is the load-bearing one. The 44-name registry is the *upper bound*; a real compile under a minimal config registers a much smaller subset. The `audit:graph-stages` script asserts the compiled count is byte-identical PR-to-PR *for the same config*, not that every PR compiles 44 nodes.

## Stage 1 — `anchor-plan` [#stage-1--anchor-plan]

Ten registered names. Pre-LLM context shaping: anchor strings, intent classification, plan generation, model routing, skill activation. No tool dispatch and no user-visible content at this stage.

| name                       | acceptsSeam | gated                               | purpose                                                                            |
| -------------------------- | ----------- | ----------------------------------- | ---------------------------------------------------------------------------------- |
| `sessionAnchor`            | `reasoning` | always                              | Builds the session-scoped anchor string consumed by synthesis.                     |
| `contextProjection`        | `null`      | always                              | Projects the data channel into the system prompt as a relevance-ranked summary.    |
| `planReconciler`           | `reasoning` | always                              | Reconciles the active plan against completed tools; writes the next planning step. |
| `planGenerator`            | `utility`   | `config.planGenerator`              | Generates a multi-step tool-execution plan.                                        |
| `intentDetector`           | `utility`   | `config.intentDetectorExecutor`     | Classifies the user's intent for the turn.                                         |
| `fileIntentDetector`       | `utility`   | `config.fileIntentDetectorExecutor` | Detects file-upload intent and extracts file context.                              |
| `costRouter`               | `utility`   | `config.costRouterExecutor`         | Routes to the cost-optimal model given the classified intent.                      |
| `skillActivation`          | `utility`   | `config.skillActivationExecutor`    | Activates the skill modules relevant to the turn.                                  |
| `structurePrefetchEmitter` | `null`      | always                              | Emits a structural prefetch hint into the channel set.                             |
| `depthZeroNudge`           | `utility`   | always                              | Nudges the model when the previous turn produced zero tool calls despite a plan.   |

## Stage 2 — `tool-loop` [#stage-2--tool-loop]

Twenty-six registered names. The iterative decide → dispatch → inspect loop. The *decision* call here is `utility`-class and runs on a separate utility seam — it decides whether to keep looping, never owns the final user-visible synthesis, and can't reach the synthesize seam or its per-turn counter.

| name                      | acceptsSeam | gated                              | purpose                                                                                                                                                     |
| ------------------------- | ----------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `llm`                     | `converse`  | always                             | The per-turn LLM decision call. Decides whether to dispatch tools or terminate the loop.                                                                    |
| `llmDecision`             | `utility`   | `config.llmDecisionExecutor`       | Streaming variant of the decision call with a `utility` seam threaded through — separate from the synthesize seam, so it can't bump the synthesize counter. |
| `tools`                   | `null`      | always                             | Dispatches pending tool calls in parallel.                                                                                                                  |
| `subagent`                | `null`      | always                             | Delegates to a subagent runtime; routes results back into the parent turn.                                                                                  |
| `guard`                   | `null`      | always                             | Guards against invalid tool execution chains.                                                                                                               |
| `hallucination`           | `null`      | always                             | Detects hallucinated tool calls — zero-tool loops, narration-as-execution.                                                                                  |
| `dataSilo`                | `null`      | always                             | Silos large tool results into the data channel and replaces them with refs.                                                                                 |
| `enrichment`              | `utility`   | `config.enrichmentExecutor`        | Enriches tool results with host-supplied context.                                                                                                           |
| `safetyReview`            | `utility`   | `config.safetyReviewExecutor`      | Pre-synthesis safety review of accumulated tool results.                                                                                                    |
| `quality`                 | `utility`   | `config.qualityEvaluator`          | Scores tool result quality.                                                                                                                                 |
| `continuation`            | `converse`  | `config.continuationResolver`      | Decides whether to continue looping or escalate to synthesis.                                                                                               |
| `coupling`                | `utility`   | `config.couplingResolver`          | Suggests coupled tool invocations the host can pre-bundle.                                                                                                  |
| `fabrication`             | `null`      | `config.fabricationChecker`        | Detects and remediates fabricated tool outputs.                                                                                                             |
| `diagnostics`             | `utility`   | `config.diagnosticsAnalyzer`       | Logs per-turn diagnostic info.                                                                                                                              |
| `asyncTaskDispatch`       | `utility`   | `config.asyncTaskDispatchExecutor` | Dispatches long-running tasks for out-of-band execution.                                                                                                    |
| `creditBudget`            | `null`      | `config.creditBudgetExecutor`      | Enforces a per-turn credit or cost budget.                                                                                                                  |
| `eventLogger`             | `null`      | `config.eventLogger`               | Logs completed tool execution events.                                                                                                                       |
| `jobSilo`                 | `null`      | always                             | Silos pending job state into a side channel.                                                                                                                |
| `workspaceIndex`          | `null`      | always                             | Maintains a workspace-index channel for downstream nodes.                                                                                                   |
| `forceSynthesizer`        | `null`      | always                             | Routes a forced synthesis request — the `tool-loop → synthesize` transition.                                                                                |
| `toolLoopStream`          | `null`      | `config.toolLoopStreamExecutor`    | Provider-call streaming node for decision generation.                                                                                                       |
| `garbledContentGuard`     | `null`      | always                             | Detects coherence failures in the streamed decision output.                                                                                                 |
| `repetitionGuard`         | `null`      | always                             | Detects phrase-repetition loops in streamed output.                                                                                                         |
| `dsmlDetector`            | `null`      | always                             | Detects and redacts domain-specific markup artifacts.                                                                                                       |
| `multiInvocationTracker`  | `null`      | always                             | Tracks repeated synthesis invocations per message.                                                                                                          |
| `loopTerminatedAnnotator` | `null`      | always                             | Annotates the channel state when the tool loop terminates.                                                                                                  |

## Stage 3 — `synthesize` [#stage-3--synthesize]

One registered name. The synthesis stage is a true singleton: `synthesizer` is the only node the lattice admits here, enforced by `SINGLETON_NODE_NAMES`. A PR that registers a second synthesize-stage node fails `audit:graph-stages` structurally.

| name          | acceptsSeam  | gated                | purpose                                                                   |
| ------------- | ------------ | -------------------- | ------------------------------------------------------------------------- |
| `synthesizer` | `synthesize` | `config.synthesizer` | The singleton synthesizer seam call. Owns the final user-visible content. |

<Callout title="Recovery shaping moved out of synthesize">
  Earlier revisions registered four recovery siblings in this stage —
  `recovery`, `refusalHint`, `retryNarration`, `garbleRecovery`.
  An earlier revision reclassified recovery shaping as *post-turn*
  work, not final-content synthesis, and retired all four graph
  nodes. Recovery dispatch is now a collection of post-stage
  **stream filters** — `refusalHintFilter` (priority 100),
  `retryNarrationFilter` (200), `garbleRecoveryFilter` (300), and
  `standardRecoveryFilter` (400) — that fire at stage completion via
  the `StreamObserverRegistry` rather than as nodes on the lattice.
  The `audit:recovery-dispatch-single-surface` gate locks that single
  dispatch surface; restoring the synthesize-stage singleton property
  is what lets a sixth synthesize sibling fail the lint structurally.
  See [Stream observers](/docs/plugins/stream-observers) for the
  filter substrate they now run on.
</Callout>

## Stage 4 — `post-turn` [#stage-4--post-turn]

Seven registered names. Read-only side effects after the user has seen the answer: memory writes, cost rollups, citation finalization, context summary, plus the post-synthesis answer-sufficiency judge.

| name                 | acceptsSeam | gated                             | purpose                                                                                                              |
| -------------------- | ----------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `citation`           | `null`      | `config.citationExtractor`        | Extracts citations from the final content back to source data.                                                       |
| `contextSummarizer`  | `null`      | `config.contextSummarizer`        | Summarizes the turn for next-turn insertion.                                                                         |
| `memoryExtraction`   | `utility`   | `config.memoryExtractionExecutor` | Extracts facts for long-term memory storage.                                                                         |
| `consolidation`      | `null`      | `config.consolidationExecutor`    | Consolidates extracted facts before the memory write.                                                                |
| `sessionMemoryWrite` | `null`      | always                            | Async write of the turn record to session memory.                                                                    |
| `costRollup`         | `null`      | always                            | Summarizes per-turn token and cost totals.                                                                           |
| `answerSufficiency`  | `utility`   | always                            | Post-synthesis judge (D-NC-6) — scores whether the final answer covers the turn's pending steps on a `utility` seam. |

## The singleton invariant [#the-singleton-invariant]

`synthesizer` is the only entry in `SINGLETON_NODE_NAMES`, and — since the recovery siblings relocated to post-turn stream filters — the only node the lattice admits in the `synthesize` stage at all. The graph linter (gate G-13) refuses a compile that registers it more than once, and the `seamWireCheck` test asserts the synthesizer observes the singleton `ProviderSeam<"synthesize">` reference at runtime while the `llmDecision` node observes a *separate* `ProviderSeam<"utility">` — the singleton is per call class, so the decision node can't reach the synthesize seam. The stage is exactly-one by two independent locks: the `SINGLETON_NODE_NAMES` membership and the now-empty rest of the stage.

See [Graph § singleton synthesize seam](/docs/graph#singleton-synthesize-seam) for the seam contract.

## Adding or removing nodes — three paths [#adding-or-removing-nodes--three-paths]

A developer extending the graph picks the path that matches what they own. The three paths from least to most invasive:

| Path                | What you own                                           | What you can do                                     | Edge wiring                                          |
| ------------------- | ------------------------------------------------------ | --------------------------------------------------- | ---------------------------------------------------- |
| **Config omission** | The `config` object passed to `buildDefaultAgentGraph` | Remove any gated node by not supplying its executor | Auto — chains `.filter(hasNode)`                     |
| **Plugin path**     | A `HarnessPlugin` with `extraGraphNodes()`             | Add new nodes alongside the canonical set           | Auto for chain-relative wiring; manual for new edges |
| **Custom builder**  | A raw `StateGraph` you compose from scratch            | Anything within the lattice — add, remove, rewire   | Manual everywhere                                    |

### Config omission — remove a gated node [#config-omission--remove-a-gated-node]

The `gated` column in the stage tables above tells you which nodes you can remove without forking. Any row whose `gated` cell reads `config.<field>` is opt-in — leave the field unset and the builder skips the `addNode(...)` call. The chain literals (`preLlmChain`, `postToolChain`, `terminalChain`) all run through `.filter(hasNode)`, so the skip is silent.

```typescript
const graph = buildDefaultAgentGraph({
  llmExecutor,
  toolExecutor,
  // intentDetector omitted — the anchor-plan chain
  // collapses START → costRouter → skillActivation → llm
  // safetyReview omitted — the post-tool chain skips it
})
```

A row whose `gated` cell reads `always` is structural — removing it requires the custom-builder path. The builder hardcodes the `addNode(...)` call for those nodes; there is no config field to suppress.

### Plugin path — add a node alongside the canonical set [#plugin-path--add-a-node-alongside-the-canonical-set]

`HarnessPlugin.extraGraphNodes()` returns `PluginGraphNodeRegistration[]`. The builder calls each registration's `factory(ctx)` once at compile time and registers the returned node under the plugin's namespace. 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, a post-turn writer that targets a specific event-log destination.

```typescript
import type { HarnessPlugin } from "@pleach/core"
import type {
  PluginGraphNodeRegistration,
  PluginGraphNodeContext,
} from "@pleach/core/plugins"

const myCustomNodeMetadata = {
  stageId: "post-turn",
  acceptsSeam: null,
  subscribes: ["messages", "completedTools"],
  writes: ["customSideTable"],
}

export const myPlugin: HarnessPlugin = {
  name: "my-custom-writer",
  version: "0.1.0",
  extraGraphNodes(): PluginGraphNodeRegistration[] {
    return [
      {
        name: "customSideTableWriter",
        factory: (ctx: PluginGraphNodeContext) =>
          async (state) => {
            await writeSideTable(state.completedTools, ctx.dataChannel)
            return { customSideTable: { written: true } }
          },
        metadata: myCustomNodeMetadata,
      },
    ]
  },
}
```

Three constraints hold for every plugin-registered node:

* **`stageId` must be one of the four lattice stages.** A node declaring a stage that's not in `ALLOWED_EDGE_PATTERNS` fails `audit:graph-stages` before the registration takes effect.
* **`name` must be unique** across all registrations — including the canonical 44-name registry. A collision throws at compile time.
* **`acceptsSeam` is either `null` or one of `utility` / `reasoning` / `converse` / `synthesize`.** When non-null, the seam binding routes through the existing holder + cap machinery — no plugin-level seam construction.

The plugin cannot bypass the singleton synthesize seam, add an out-of-lattice edge, or replace a registered node — the registration is additive. See [Plugin contract](/docs/plugin-contract) for the full `HarnessPlugin` surface and the `contributePostToolTier` slot specifically for post-tool enrichment nodes.

### Custom builder — full control [#custom-builder--full-control]

When the canonical builder's shape doesn't fit — a fundamentally different lattice traversal, a topology that registers neither `llm` nor `llmDecision`, an agent that needs to skip the tool loop entirely — compose a `StateGraph` directly. The lattice gate still applies (every node's `stageId` must be one of the four, every edge must match an `ALLOWED_EDGE_PATTERNS` row), but you own every `addNode` and `addEdge` call.

```typescript
import { StateGraph, START, END, AnnotationRoot, Annotation } from "@pleach/core/graph"

const StateSchema = AnnotationRoot({
  messages: Annotation<Message[]>({ reducer: messagesReducer, default: () => [] }),
})

// Author each node's metadata as a standalone const — the same shape the
// `*_NODE_METADATA` exports in @pleach/core's graph nodes use.
const sessionAnchorMeta = {
  stageId: "anchor-plan",
  acceptsSeam: "reasoning",
  subscribes: ["messages"],
  writes: ["messages"],
}
const llmMeta = {
  stageId: "tool-loop",
  acceptsSeam: "converse",
  subscribes: ["messages"],
  writes: ["messages"],
}

const graph = new StateGraph(StateSchema)
  .addNode("sessionAnchor", sessionAnchorFn, sessionAnchorMeta)
  .addNode("llm", llmFn, llmMeta)
  .addEdge(START, "sessionAnchor")
  .addEdge("sessionAnchor", "llm")
  .addEdge("llm", END)
  .compile()
```

The custom path is the only one that lets you *remove* a non-gated node from the canonical set. The trade-off is that you give up the canonical builder's chain-relative wiring, the post-tool-tier enrichment slots, and every gated node — the four-stage lattice is the only thing you inherit.

## How the substrate source is organized [#how-the-substrate-source-is-organized]

The canonical builder is split so a contributor can find any node — or any wiring decision — in one file. `buildDefaultAgentGraph` is a thin orchestrator (`defaultAgentGraph.ts`, kept under 1000 LoC by the `audit:graph-wiring-file-loc-ceiling` gate); the bodies live in concern-sorted directories under [`src/graph/`](https://github.com/pleachhq/core/tree/main/src/graph):

| Directory / file    | Holds                                                       | Shape                                                                                                                                                          |
| ------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `nodes/`            | Every node factory                                          | One file per node — 59 files (`createCitationNode.ts`, `ContextProjectionNode.ts`, …)                                                                          |
| `nodes/shared/`     | Cross-node helpers                                          | 8 files (dedup, tool predicates, completed-tool hints, post-turn fan-out, …)                                                                                   |
| `wiring/`           | The `addNode` / `addEdge` registration, split by stage      | 8 files — `registerAnchorPlanNodes.ts`, `registerToolLoopNodes.ts`, `registerSynthesizeNodes.ts`, `registerPostTurnNodes.ts`, `wireStageTransitionEdges.ts`, … |
| `topology.ts`       | `NODE_STAGE_MAP` + the allowed/forbidden edge tables        | The lattice layer — readable on its own                                                                                                                        |
| `seams/`            | Per-call-class seam holders + registry                      | 17 files                                                                                                                                                       |
| `predicates/`       | Route functions (`shouldContinue`, `afterHallucination`, …) | One file per predicate + barrel — 7 files                                                                                                                      |
| `strategies/`       | Module-state holders + per-strategy triads                  | 6 files                                                                                                                                                        |
| `state/reducers.ts` | The library reducers channels share                         | One file                                                                                                                                                       |

The split means `grep addNode src/graph/wiring/` returns every registration site in one sitting, and a node's logic, metadata, and tests sit together rather than buried in a multi-thousand-line builder. A few substrate helpers a node author reaches directly: `src/engine/loopBounds.ts` (named superstep-bound constants instead of magic numbers), `src/engine/timers.ts` (`withTimer` / `withInterval`), `src/instrumentation/graphLog.ts` (the prefix taxonomy for graph console output), and `src/plugins/namespaces.ts` (the nine [contribution namespaces](/docs/plugins/namespaces)).

## Adding a node to the upstream canonical builder [#adding-a-node-to-the-upstream-canonical-builder]

When the new node belongs in the substrate itself — not a plugin, not a custom build — the upstream-contributor path applies. Four places land in the same change:

1. The node factory in its own file under `src/graph/nodes/` — one node per file, exporting the factory and its `*_NODE_METADATA` constant.
2. The `NODE_STAGE_MAP` entry in `topology.ts` — pairing the name with its `stageId`.
3. The `addNode(...)` call in the matching `wiring/register<Stage>Nodes.ts` registrar — registration lives in `wiring/`, not in the `defaultAgentGraph.ts` orchestrator.
4. A wire-check entry in `seamWireCheck.test.ts` when `acceptsSeam !== null`.

`npm run audit:graph-stages` reports `missing-stage` distinctly from `forbidden-edge`, so an unmapped node fails clearly. The full new-node checklist lives at [Nodes § new-node checklist](/docs/nodes#new-node-checklist).

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

<Cards>
  <Card title="Edge catalog" href="/docs/graph-edge-catalog" description="The nine allowed and seven forbidden cross-stage edge patterns, plus the chain skeleton and the two conditional routers that wire the registered nodes together." />

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

  <Card title="Nodes" href="/docs/nodes" description="The node-shape contract — metadata, seam consumption, determinism rule." />

  <Card title="Audit gates" href="/docs/audit-gates" description="The audit:graph-stages script and the rest of the pre-merge gate set." />
</Cards>
