Skip to content
pleach
Architecture

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 core name the audit script will accept — is fixed at 42 entries spread across the four lattice stages. (Plugin-contributed nodes self-declare their stage through the plugin overlay and are not part of this core registry — see the Plugin path section below.)

This page enumerates that registry. The companion page is Edge catalog. Conceptual framing lives in Graph; the node-shape contract lives in Nodes.

Subpath@pleach/core/graphSourcesrc/graph/topology.ts

Reading the table

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

ColumnMeaning
nameThe string passed to graph.addNode("..."). Stable public identifier — renamed only via a Changed changelog row.
acceptsSeamThe CallClass the node consumes — utility, reasoning, converse, or synthesize — or null for pure state transforms.
gatedalways 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.
purposeOne-line summary of what the node does.

The gated column is the load-bearing one. The full 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 every registered node.

Stage 1 — anchor-plan

Nine 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.

nameacceptsSeamgatedpurpose
sessionAnchorreasoningalwaysBuilds the session-scoped anchor string consumed by synthesis.
contextProjectionnullalwaysProjects the data channel into the system prompt as a relevance-ranked summary.
planReconcilerreasoningalwaysReconciles the active plan against completed tools; writes the next planning step.
planGeneratorutilityconfig.planGeneratorGenerates a multi-step tool-execution plan.
intentDetectorutilityconfig.intentDetectorExecutorClassifies the user's intent for the turn.
fileIntentDetectorutilityconfig.fileIntentDetectorExecutorDetects file-upload intent and extracts file context.
costRouterutilityconfig.costRouterExecutorRoutes to the cost-optimal model given the classified intent.
skillActivationutilityconfig.skillActivationExecutorActivates the skill modules relevant to the turn.
depthZeroNudgeutilityalwaysNudges the model when the previous turn produced zero tool calls despite a plan.

Stage 2 — tool-loop

Twenty-three 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.

nameacceptsSeamgatedpurpose
llmconversealwaysThe per-turn LLM decision call. Decides whether to dispatch tools or terminate the loop.
llmDecisionutilityconfig.llmDecisionExecutorStreaming variant of the decision call with a utility seam threaded through — separate from the synthesize seam, so it can't bump the synthesize counter.
toolsnullalwaysDispatches pending tool calls in parallel.
subagentnullalwaysDelegates to a subagent runtime; routes results back into the parent turn.
guardnullalwaysGuards against invalid tool execution chains.
hallucinationnullalwaysDetects hallucinated tool calls — zero-tool loops, narration-as-execution.
dataSilonullalwaysSilos large tool results into the data channel and replaces them with refs.
skillRearmnullconfig.skillActivationExecutorMid-loop skill re-arm — same node closure as skillActivation, registered in tool-loop so its completedTools subscription stays on the lattice; re-matches skills when loop progress changes what the turn needs (hard per-turn activation ceiling).
enrichmentutilityconfig.enrichmentExecutorEnriches tool results with host-supplied context.
safetyReviewutilityconfig.safetyReviewExecutorPre-synthesis safety review of accumulated tool results.
qualityutilityconfig.qualityEvaluatorScores tool result quality.
continuationconverseconfig.continuationResolverDecides whether to continue looping or escalate to synthesis.
couplingutilityconfig.couplingResolverSuggests coupled tool invocations the host can pre-bundle.
fabricationnullconfig.fabricationCheckerDetects and remediates fabricated tool outputs.
diagnosticsutilityconfig.diagnosticsAnalyzerLogs per-turn diagnostic info.
asyncTaskDispatchutilityconfig.asyncTaskDispatchExecutorDispatches long-running tasks for out-of-band execution.
creditBudgetnullconfig.creditBudgetExecutorEnforces a per-turn credit or cost budget.
eventLoggernullconfig.eventLoggerLogs completed tool execution events.
syntheticExpansionDetectornullalwaysPost-stream: catches the synthetic "loading additional tools" literal and sets reResolveTools so the runtime re-resolves tools instead of treating it as a leak.
forceSynthesizernullalwaysRoutes a forced synthesis request — the tool-loop → synthesize transition.
dsmlDetectornullalwaysDetects and redacts domain-specific markup artifacts.
multiInvocationTrackernullalwaysTracks repeated synthesis invocations per message.
loopTerminatedAnnotatornullalwaysAnnotates 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.

nameacceptsSeamgatedpurpose
synthesizersynthesizeconfig.synthesizerThe 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

Nine registered names. Read-only side effects and answer inspection after the LLM decided the turn outcome: memory writes, cost rollups, citation finalization, context summary, the post-synthesis answer-sufficiency judge, plus the two content-safety guards that inspect the composed answer.

nameacceptsSeamgatedpurpose
citationnullconfig.citationExtractorExtracts citations from the final content back to source data.
garbledContentGuardnullalwaysDetects coherence failures in the composed answer; writes its verdict channel only.
repetitionGuardnullalwaysDetects phrase-repetition loops in the composed answer; writes its verdict channels only.
contextSummarizernullconfig.contextSummarizerSummarizes the turn for next-turn insertion.
memoryExtractionutilityconfig.memoryExtractionExecutorExtracts facts for long-term memory storage.
consolidationnullconfig.consolidationExecutorConsolidates extracted facts before the memory write.
sessionMemoryWritenullalwaysAsync write of the turn record to session memory.
costRollupnullalwaysSummarizes per-turn token and cost totals.
answerSufficiencyutilityalwaysJudges whether the answer covers the turn's pending steps, on a utility seam (D-NC-6). Reads the synthesized finalContent when it exists and the provisional draftContent otherwise, so the verdict lands while the tool loop can still act on it.

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:

PathWhat you ownWhat you can doEdge wiring
Config omissionThe config object passed to buildDefaultAgentGraphRemove any gated node by not supplying its executorAuto — chains .filter(hasNode)
Plugin pathA HarnessPlugin with extraGraphNodes()Add new nodes alongside the canonical setAuto for chain-relative wiring; manual for new edges
Custom builderA raw StateGraph you compose from scratchAnything within the lattice — add, remove, rewireManual 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:

  • 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 42-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 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, AnnotationRoot, Annotation } from "@pleach/core"

// `START` / `END` have no published import path yet — they alias the
// string literals below. See Graph § the seven unpublished symbols.
const START = "__start__"
const END = "__end__"

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 / fileHoldsShape
nodes/Every node factoryOne file per node — 59 files (createCitationNode.ts, ContextProjectionNode.ts, …)
nodes/shared/Cross-node helpers8 files (dedup, tool predicates, completed-tool hints, post-turn fan-out, …)
wiring/The addNode / addEdge registration, split by stage8 files — registerAnchorPlanNodes.ts, registerToolLoopNodes.ts, registerSynthesizeNodes.ts, registerPostTurnNodes.ts, wireStageTransitionEdges.ts, …
topology.tsNODE_STAGE_MAP + the allowed/forbidden edge tablesThe lattice layer — readable on its own
seams/Per-call-class seam holders + registry17 files
predicates/Route functions (shouldContinue, afterHallucination, …)One file per predicate + barrel — 7 files
strategies/Module-state holders + per-strategy triads6 files
state/reducers.tsThe library reducers channels shareOne 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:

  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.

Where to go next