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

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

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.
structurePrefetchEmitternullalwaysEmits a structural prefetch hint into the channel set.
depthZeroNudgeutilityalwaysNudges 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.

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.
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.
jobSilonullalwaysSilos pending job state into a side channel.
workspaceIndexnullalwaysMaintains a workspace-index channel for downstream nodes.
forceSynthesizernullalwaysRoutes a forced synthesis request — the tool-loop → synthesize transition.
toolLoopStreamnullconfig.toolLoopStreamExecutorProvider-call streaming node for decision generation.
garbledContentGuardnullalwaysDetects coherence failures in the streamed decision output.
repetitionGuardnullalwaysDetects phrase-repetition loops in streamed output.
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 filtersrefusalHintFilter (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.

nameacceptsSeamgatedpurpose
citationnullconfig.citationExtractorExtracts citations from the final content back to source data.
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.
answerSufficiencyutilityalwaysPost-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:

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 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 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 / 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

On this page