pleach
Architecture

Nodes

The node shape — an async function plus typed metadata declaring stage membership, seam consumption, and the channels read and written.

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 for the cluster framing.

This page covers the node-level surface. See Graph for how nodes compose into a StateGraph, and Channels for the reactive state slots nodes flow through.

Subpath@pleach/core/graphSourcesrc/graph/nodes/

The node shape

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[]
}
FieldPurpose
stageIdLattice membership. One of the four stages. audit:graph-stages enforces it.
acceptsSeamCallClass literal if the node consumes an LLM seam; null if seam-free state transform.
subscribesChannels the node READS. When one of these advances, the engine schedules this node in the next superstep.
writesChannels the node UPDATES. The engine uses this to track downstream subscribers.

A minimal registration:

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

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.

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 for the lattice's role in the broader substrate.

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.

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 for the six channel kinds and their concurrent-write semantics.

The default node catalogue

buildDefaultAgentGraph registers up to 44 nodes across the four stages — the Node catalog enumerates every name and its gated condition. A representative slice per stage:

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)

  • 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)

  • 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)

  • 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/ for the full set, and the Node catalog for the audit-gated registry.

See src/graph/nodes/ for the full set.

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 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 for the full pre-merge gate set and the ci:graphnoderef bundle the canonical graph runs under.

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 fieldNode it wires
enrichmentExecutorenrichment
safetyReviewExecutorsafetyReview
qualityEvaluatorquality
citationExtractorcitation

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

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.

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

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

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 for the full surface.

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 for the five substrate-level contracts the node-level rule sits inside.

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, and the failure strings map to fixes on Gate failures → fixes.

Where to go next

On this page