Extending Pleach
The gate-paired authoring map — each extension point (node, channel, event type, scrubber, plugin hook, prompt block, audit field) paired with the interface you implement, how you register it, and the CI gate that fails if you get it wrong.
The audit gates assume you'll extend the substrate. Every page that calls a gate "structural" is also saying: when you add a node, a writer, or a scrubber for your own use case, this gate is the spec your addition has to satisfy. This page is the map from extension point to that spec.
It pairs each thing you can add with three facts: the interface you implement, the call that registers it, and the named audit gate that turns red when the addition is wrong. Treat the gate as the checklist — it names the file and line to fix in its failure output.
Two extension surfaces — know which one you're on
Host extension — you add a plugin, a prompt block, a tool, or a
runtime strategy from your repo against the published @pleach/*
surface. You edit no substrate source. The gates already ran upstream
to keep that surface honest; the ones below fire in your CI only if
you mirror them (see Mirroring gates).
Upstream contribution — you add a node to the canonical builder,
a member to EventLogInput, or a sibling @pleach/* SKU. You edit
substrate source, so the gates run against your PR directly. The
contributing flow covers this path.
Most consumer work is host extension. The gate column below tells you which surface each gate belongs to.
The extension map
| You're adding | Interface | Register via | Gate that fails if wrong | Check locally |
|---|---|---|---|---|
| A graph node | StateGraphNodeMetadata + NodeFn (nodes) | graph.addNode(name, fn, meta), or a plugin's extraGraphNodes() | audit:graph-stages (declares a stageId, edges stay in the lattice), audit:edge-inventory-completeness | npm run ci:graphnoderef |
| A state channel | Channel<T> (channels) | a node's subscribes / writes metadata — there is no contributeChannels hook | reached through a node, so audit:graph-stages covers it; reducer commutativity is a determinism contract, not gate-checked | npm run audit:graph-stages |
| An event type / writer | PluginEventDefinition (event log) | contributeEventTypes() | audit:c8-union-member-has-producer (has a producer), audit:c8-event-type-allowlist-coverage (has a scrubber entry) | npm run audit:c8-event-type-allowlist-coverage |
| A scrubber (the "clearer") | Scrubber (scrubbers) | contributeScrubbers() | audit:c8-event-type-allowlist-coverage — your new event type needs a gate, even a pass-through one | npm run audit:c8-event-type-allowlist-coverage |
| A plugin hook (sibling SKU / upstream) | a contribute* hook on HarnessPlugin (plugin contract) | the hook itself, returning the contribution | audit:plugin-contract-completeness (paired collector + consumer), audit:plugin-hook-category-assigned (resolves to a namespace) | npm run audit:plugin-contract-completeness |
| A system-prompt block | PromptContribution (prompts) | contributePrompts() / contributeRuntimeAwarePrompts() — helpers appendPrompt, prependPersona | no dedicated gate; static blocks fold into the config-manifest fingerprint (audit:plugin-content-hash-stability guards its determinism) | npm run audit:plugin-content-hash-stability |
| A custom audit field | PluginAuditPayload (typed records) | contributeAuditEmitter() → pluginPayloads — not a new column | audit:auditable-call — the structural column set is locked; a column add fails the shape+version check | npm run audit:auditable-call |
| An audit-ledger adapter | ProviderDecisionLedger (audit ledger) | setProviderDecisionLedgerFactory(...) | audit:auditable-call (the row your adapter persists must match the locked shape) | npm run audit:auditable-call |
| A storage adapter | StorageAdapter (storage) | runtime config / appRegistries | no shape gate — application code is unchanged by the adapter swap | — |
The shape is the same across rows: a gate fails because the addition omitted the one fact the ledger needs to stay joinable — a stage, a producer, a scrubber entry, a version bump. Read the gate name as the missing fact.
Nodes: the consumer path vs the builder path
The new-node checklist on the nodes
page describes editing the canonical builder — NODE_STAGE_MAP in
src/graph/topology.ts. That's the upstream path. A host adding a
node from a plugin takes a different one:
// A plugin contributes nodes; the builder calls each factory once at compile time.
const retrievalPlugin: HarnessPlugin = {
name: "tenant-retrieval",
extraGraphNodes: () => [{
name: "tenantRetrieval",
factory: (ctx) => async (state) => ({ retrieved: await ctx.search(state.query) }),
metadata: { stageId: "tool-loop", acceptsSeam: null, subscribes: ["query"], writes: ["retrieved"] },
}],
}A plugin-registered node carries its stageId in its own metadata.
The lattice gate (audit:graph-stages) rejects an out-of-stage edge
on the compiled graph, but the node never enters the substrate's
static NODE_STAGE_MAP — that table covers the canonical builder
only, which is why the node/edge counts stay byte-identical PR-to-PR.
The fact your node must satisfy is the same (a valid stage, valid
edges); the surface it's checked against differs.
The locked row, and the slot that isn't
The single most common extension instinct the substrate refuses:
adding a column to the audit row. The structural column set —
turnId, toolName, modelId, family, inputTokens,
outputTokens, subagentDepth, parentTurnId — is a cross-SKU
contract. No plugin, config field, or adapter alters it. A column add
fails audit:auditable-call by design: changing the shape without
bumping AuditRecordVersion is the silent-breaking-change the gate
exists to catch.
The sanctioned path for "I need to record my thing on the row" is
pluginPayloads — a namespaced, versioned slot the locked contract
reserves for exactly this:
const extractionQualityPlugin: HarnessPlugin = {
name: "extraction-quality",
contributeAuditEmitter: () => ({
// Emitted alongside the locked columns; your plugin owns the wire shape.
record: (call) => ({
pluginId: "extraction-quality",
subKind: "score",
data: { confidence: scoreFor(call), schemaVersion: 1 },
}),
}),
}Your payload rides one row per call, joinable by turnId like every
other field, without touching the structural columns every consumer
reads. The version lives in your data, not in the substrate's
AuditRecordVersion — the gate stays green because the contract
didn't move. See typed records for the five
first-party payload kinds this slot generalizes.
Mirror the gates in your own repo
Host extension means the upstream gates protected the surface you
consume — not your invariants. A per-tenant key that must never
reach logs, a domain tool that must have a matching detector, a
feature flag past its sunset: those are yours to gate. The pattern is
the one the substrate uses, documented at
Audit gates → Mirroring gates in your own repo:
one invariant per script, structured failure naming file and line,
wired under audit:<name> in your PR-blocking CI.
The discipline carries: adding a new invariant means adding a new
gate, not a line on a review checklist. The
audit:plugin-contract-completeness
script is a small reference shape to copy.
Agent-driven self-extension
The gates aren't only a CI wall — they're a feedback loop an agent can drive. Because every gate names the offending file, line, and the fact that's missing, an agent extending the runtime can run the loop unattended:
- Write the addition — a node, a scrubber, a
pluginPayloadsemitter for the tenant's need. - Run the matching
npm run audit:*gate. - Read the structured failure: it names the file, the symbol, and the missing fact (a stage, a producer, an allowlist entry).
- Apply the fix the gate points at. Re-run. Green means the addition preserved the ledger contract.
The gate output is the correction signal. An agent that can't see why
its node was rejected can't self-correct; one that reads
missing-stage: tenantRetrieval can. This is the same property that
makes the substrate "built for and by agents" — the contract is
legible to the thing operating it.
To push self-optimization further, feed the extension contract into the agent's own system prompt. A host that wants its agent to tune its plugin set, prompt blocks, or tool selection to a tenant's needs can inject the rules at turn time:
const selfTuningPlugin: HarnessPlugin = {
name: "self-tuning",
// Per-turn prompt the operating agent reads — the extension contract as context.
contributeRuntimeAwarePrompts: (ctx) => [{
id: "self-tuning.extension-guide",
mode: "append",
content: extensionContractFor(ctx.tenant), // gates, slots, and the locked columns
}],
}The agent then proposes additions that already respect the gates,
because the gates are in its context. Static guidance (the locked
column set, the pluginPayloads slot, the gate names) belongs in
contributePrompts so it folds into the fingerprint; per-tenant
guidance belongs in contributeRuntimeAwarePrompts so it stays out
of the cache key. See prompts and the
plugin contract for the composition rules.
The boundary holds either way: an agent can author freely inside the gates, and the gates are what keep its additions from making the audit ledger unusable. Self-extension is safe precisely because the contract is enforced, not advised.
Where to go next
Plugin contract
The full HarnessPlugin hook surface every extension point above registers through.
Audit gates
The subsystem-organized catalog of the gates this page pairs to extension points.
Nodes
The node shape, the new-node checklist, and the plugin-registered node path.
Scrubbers
The clearer contract and the contributeScrubbers hook for regulated identifiers.
Typed records
The pluginPayloads slot and the first-party payload kinds it generalizes.
Plugin bundles
Thematic facet sub-paths for plugins that span multiple semantic domains — barrel imports, define-helpers, and composePlugin. The second DX layer above HarnessPlugin, peer-equivalent to definePleachPlugin.
Authoring a HarnessPlugin
How to write a HarnessPlugin from scratch — the 74-hook surface, when each one fires, and the canonical no-op return for every hook shape.