Node catalog
Every node name the canonical agent graph may register, grouped by lattice stage — the surface the `audit:graph-stages` script knows about.
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. Conceptual framing lives in Graph; the node-shape contract lives in Nodes.
@pleach/core/graphSourcesrc/graph/topology.tsReading 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
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
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
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. |
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 for the
filter substrate they now run on.
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
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 for the seam contract.
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
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.
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
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.
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:
stageIdmust be one of the four lattice stages. A node declaring a stage that's not inALLOWED_EDGE_PATTERNSfailsaudit:graph-stagesbefore the registration takes effect.namemust be unique across all registrations — including the canonical 44-name registry. A collision throws at compile time.acceptsSeamis eithernullor one ofutility/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 for the full HarnessPlugin surface and the contributePostToolTier slot specifically for post-tool enrichment nodes.
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.
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
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/:
| 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).
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:
- The node factory in its own file under
src/graph/nodes/— one node per file, exporting the factory and its*_NODE_METADATAconstant. - The
NODE_STAGE_MAPentry intopology.ts— pairing the name with itsstageId. - The
addNode(...)call in the matchingwiring/register<Stage>Nodes.tsregistrar — registration lives inwiring/, not in thedefaultAgentGraph.tsorchestrator. - A wire-check entry in
seamWireCheck.test.tswhenacceptsSeam !== 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.
Where to go next
Edge catalog
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.
Graph
How the registered nodes compose into a StateGraph — the lattice as a whole.
Nodes
The node-shape contract — metadata, seam consumption, determinism rule.
Audit gates
The audit:graph-stages script and the rest of the pre-merge gate set.
Channels
The six channel kinds — LastValue, BinaryOperatorAggregate, Topic, EphemeralValue, NamedBarrier, DataChannel — with their concurrent-write semantics and reducers.
Edge catalog
The nine allowed and seven forbidden cross-stage edge patterns, the chain skeleton, and the two conditional routers that wire registered nodes together.