pleach
Plugins

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 addingInterfaceRegister viaGate that fails if wrongCheck locally
A graph nodeStateGraphNodeMetadata + 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-completenessnpm run ci:graphnoderef
A state channelChannel<T> (channels)a node's subscribes / writes metadata — there is no contributeChannels hookreached through a node, so audit:graph-stages covers it; reducer commutativity is a determinism contract, not gate-checkednpm run audit:graph-stages
An event type / writerPluginEventDefinition (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 onenpm 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 contributionaudit:plugin-contract-completeness (paired collector + consumer), audit:plugin-hook-category-assigned (resolves to a namespace)npm run audit:plugin-contract-completeness
A system-prompt blockPromptContribution (prompts)contributePrompts() / contributeRuntimeAwarePrompts() — helpers appendPrompt, prependPersonano 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 fieldPluginAuditPayload (typed records)contributeAuditEmitter()pluginPayloadsnot a new columnaudit:auditable-call — the structural column set is locked; a column add fails the shape+version checknpm run audit:auditable-call
An audit-ledger adapterProviderDecisionLedger (audit ledger)setProviderDecisionLedgerFactory(...)audit:auditable-call (the row your adapter persists must match the locked shape)npm run audit:auditable-call
A storage adapterStorageAdapter (storage)runtime config / appRegistriesno 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:

  1. Write the addition — a node, a scrubber, a pluginPayloads emitter for the tenant's need.
  2. Run the matching npm run audit:* gate.
  3. Read the structured failure: it names the file, the symbol, and the missing fact (a stage, a producer, an allowlist entry).
  4. 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

On this page