pleach
Architecture

Plugin contract

The HarnessPlugin extension surface — what plugins can do, what they can't, and the structural invariants the substrate enforces against them.

HarnessPlugin is the consumer extension contract for @pleach/core. Every sibling SKU — @pleach/compliance, @pleach/eval, @pleach/gateway, @pleach/replay, @pleach/mcp, @pleach/coding-agent, @pleach/sandbox, @pleach/langchain, @pleach/base-tools, @pleach/observe, @pleach/recipes — is a plugin that implements this contract. All ship at 0.1.0 · FSL-1.1-Apache-2.0 in the first-wave cut; @pleach/trust-pack alone remains a reserved npm name. See Packages for the canonical per-SKU status table. Your own consumer code extends the runtime through the same surface; sibling SKUs slot in alongside without changing graph shape.

The contract is small on purpose. Plugins fill named slots; they don't get a handle on the lattice, the synthesize seam, or the modelfamily matrix. That bounded surface is what makes substrate guarantees (one synthesize per turn, family-locked routing, replay determinism) hold no matter what plugins do.

Plugins are a thematic island. Not one of the six cluster tripletsHarnessPlugin is a bounded contract (74 optional hooks — 67 contribute* hooks plus 7 top-level lifecycle hooks — plus four structural invariants), not a three-concept cluster. See What lives outside the cluster pattern.

definePleachPlugin() — the typed factory

definePleachPlugin() is the recommended construction surface. It takes the same fields as a raw HarnessPlugin literal plus a flat capabilities menu, and returns a HarnessPlugin with autocomplete that surfaces "what can my plugin contribute?" in one place instead of paging the 1100-line interface.

The factory exports from the top-level barrel:

import { definePleachPlugin } from "@pleach/core";
import type { PluginCapabilities } from "@pleach/core";
export const myPlugin = definePleachPlugin("my-plugin", {
  prompts: [personaBlock],
  safetyPolicies: [refusalPolicy],
  fabricationDetectors: [phantomToolDetector],
  streamObservers: [redactPiiObserver],
  tools: [searchCorpus, fetchUrl],
  intentToolMap: [intentMapEntry],
  toolCouplingHints: [couplingHint],
  runtimeAwarePrompts: (ctx, state) => buildPerTurnPrompts(ctx, state),
  _raw: {
    version: "0.1.0",
    // hooks not yet covered by the structured menu — see below
    contributeFabricationGuard: () => myFabricationGuard,
    contributeSynthesisDirectiveBlocks: (ctx) => [domainDirective(ctx)],
  },
});

Capability fields take values directly, not thunks — prompts: [personaBlock], not prompts: () => [personaBlock]. The factory forwards each field into the corresponding contribute* method on HarnessPlugin. One field is the exception: runtimeAwarePrompts accepts either a (ctx, state) => PromptContribution[] function or a fixed array, mirroring the underlying hook's per-turn shape.

PluginCapabilities — the initial 8 slots

KeyTypeMaps to
promptsreadonly PromptContribution[]contributePrompts
runtimeAwarePrompts((ctx, state) => readonly PromptContribution[]) | readonly PromptContribution[]contributeRuntimeAwarePrompts
safetyPoliciesreadonly SafetyContribution[]contributeSafetyPolicies
fabricationDetectorsreadonly FabricationDetector[]contributeFabricationDetectors
toolsreadonly ToolDefinitionLite[]contributeTools
streamObserversreadonly StreamObserverRegistration[]contributeStreamObservers
intentToolMapreadonly IntentToolMapEntry[]contributeIntentToolMap
toolCouplingHintsreadonly CouplingHint[]contributeToolCouplingHints

The cluster covers the most-used contribution surface. Future hooks land additively — widening PluginCapabilities doesn't break existing call sites.

_raw — the forward-compat escape hatch

_raw is typed as Partial<HarnessPlugin> and accepts any contribution hook not surfaced through the structured cluster above. Reach for it when contributing:

  • contributeFabricationGuard (the typed hook for the prior untyped-bag fabrication-guard implementation — see below)
  • contributeSynthesisDirectiveBlocks
  • contributeFinalizationPasses
  • contributeDetectionRules
  • contributeIntentClassifiers
  • contributeSandboxBridge
  • contributeInterruptUIHandlers
  • contributeCitationRuleSet
  • contributeChatManifestProvider
  • contributeBatchingHints, contributeEventTypes
  • extraGraphNodes, prePlanPrimer, postSynthesisGuard
  • The deprecated bare-property forms — flagged in inspectRuntime() as deprecated-wired

Keys present in _raw win on collision with the structured cluster (last-write-wins) so authors migrating bespoke plugins keep full control.

The raw object literal still works — HarnessPlugin is a public type and existing plugins keep loading. The factory is what new plugins should reach for; it picks up future capability additions without the plugin author having to chase type changes by hand.

For larger plugins, see Plugin bundles — thematic facet sub-paths (@pleach/core/plugins/safety, @pleach/core/plugins/prompts, …) plus composePlugin() for assembling a plugin across multiple files. Both APIs produce a HarnessPlugin; choose by plugin size, not contract surface.

The substrate's audit:plugin-contract-completeness audit asserts every contributeX hook on HarnessPlugin has both a paired PluginManager.collectX accessor and at least one substrate consumer site. 41 hooks are catalogued today: 38 wired end-to-end, 3 baselined awaiting consumer (contributeBatchingHints is on the 1.2.0 retirement track per audit:harness-plugin-deprecated-usage; contributeEventTypes runs an intentional collector-bypass; contributeMiddleware ships the Stage 1 contract while Stage 2 consumer rewires land). The 5 hard-deprecated lifecycle hooks (onSessionCreated, onToolCompleted, onMessageAdded, queryExtensions, sandboxAvailability) have been retired; replace them with contributeStreamObservers, contributeDetectionRules, or the typed runtime.events.iterate projection surface.

What a plugin can do

CapabilityAPI surfaceWhen you reach for it
Register extra graph nodes in lattice enrichment slotsextraGraphNodesIntent detection, plan generation, safety review, quality scoring
Register stream observers on provider seamscontributeStreamObserversPer-chunk inspection, content rewriting, halt on policy violation
Register prompt contributorscontributePrompts, contributeRuntimeAwarePromptsStatic system blocks, runtime-aware sections, retrieval-driven additions
Register safety policiescontributeSafetyPoliciesRefusal-pattern detectors, policy-bound rewriters
Subscribe to lifecycle eventsruntime.events.iterate({chatId, fromSequenceNumber}) + contributeStreamObserversDomain events, cross-cutting telemetry, third-party sinks. The legacy lifecycle callbacks (onSessionCreated, onToolCompleted, onMessageAdded) have been retired — read from the event log projection instead.
Emit named-channel envelopesseam observer emit verdictPipe structured chunks through to downstream consumers

The slot model means a plugin's surface area is auditable: what it contributes is visible at registration time, not threaded through opaque callbacks. A reviewer reading plugins: [compliancePlugin, gatewayPlugin, myPlugin] against a runtime construction sees the exact list of contributions each name brings by walking each plugin's contributeStreamObservers, extraGraphNodes, contributePrompts, and contributeSafetyPolicies exports — there is no hidden channel through which a plugin could mutate the graph, the seam set, or the modelfamily matrix.

What a plugin cannot do

These boundaries are enforced by lint, by type system, or by runtime invariant — not by convention.

ForbiddenEnforced by
Add an out-of-lattice edge to the graphaudit:graph-stages (CI gate)
Bypass the singleton synthesize seamSynthesizeSeamHolder + TurnSynthesizeCounter (runtime)
Reach across the seams/ boundary into the modelfamily matrixlint:harness-boundary (CI gate)
Register an async stream-observer dispatch pathType signature is onChunk(chunk, ctx): Verdict, sync only
Use a raw callClass: "..." literal outside a seam factorylint:callclass-literals (CI gate)

The async-observer restriction is the one that surprises people. The reason it's sync is the replay determinism story: an observer that returns Promise<Verdict> introduces non-determinism into the stream, and replay determinism is the load-bearing property that the shipping @pleach/eval@0.1.0 and @pleach/replay@0.1.0 SKUs are built around — and that any host's own diff harness can lean on today.

The lattice slots

Each of the four lattice stages exposes named enrichment slots that plugins fill. Slots are typed; a plugin contributing to a slot declares which stage it belongs to and what channels it reads and writes.

StageEnrichment slots
anchor-planintentDetection, planGeneration, anchorBuilding
tool-looptoolSelection, toolExecution, dataSilo, jobSilo
synthesizesynthesizerPreamble, citationInjector
post-turnqualityScoring, consolidation, recoveryShaping

A plugin can fill zero or more slots. Slots themselves don't multiply — two plugins contributing to intentDetection chain deterministically by registration order, not by reduction.

synthesize is a true singleton stage — only the synthesizer node runs there. Recovery shaping (refusal hints, retry narration, garble recovery) is a post-turn concern: it moved off the lattice into the recovery stream filters, which fire at stage completion via the StreamObserverRegistry. See Stream observers.

Stream observer verdicts

Observers ride on top of every provider seam. For each inbound chunk the seam dispatches the observer ladder; each observer returns one of four verdicts:

VerdictEffect
continuePass through unchanged
amendReplace chunk content 1:1 — strict, no multiplex
emitPass through and emit a named-channel envelope downstream
stopStop the stream; downstream reads the stop sentinel

amend being 1:1 is deliberate: a one-chunk-in, many-chunks-out observer would break the byte-replay property. Plugins that need fan-out emit named envelopes on a channel, not extra stream chunks. Concretely: a redaction observer that replaces a span with [REDACTED] returns an amend verdict whose chunk.text is the cleaned string, and the chunk count stays the same. A metrics observer that wants to ship a structured side-effect returns an emit verdict with an envelope on a named channel (e.g. a metrics channel), and the main stream sees the original chunk pass through unchanged.

contributeFabricationDetectors slot

A plugin can register one or more fabrication detectors through this slot. Each detector receives a typed context — the completed tools for the turn, the assistant content, the user text, the set of known tool names, and the current call class — and returns a FabricationFinding or null. The graph's FabricationNode iterates the union of every plugin's detectors per turn.

contributeFabricationDetectors?(): readonly FabricationDetector[]
// lib/plugins/phantomToolDetector.ts
import type { HarnessPlugin } from "@pleach/core";
import type { FabricationDetector } from "@pleach/core/plugins";

const phantomToolDetector: FabricationDetector = {
  id: "phantom-tool",
  detect(ctx) {
    const match = ctx.assistantContent.match(/\b([a-z_]+)\(/);
    if (!match) return null;
    const name = match[1];
    if (ctx.knownToolNames.has(name)) return null;
    return {
      detectorId: "phantom-tool",
      severity: "medium",
      reason: `Mentions ${name}() but no such tool is registered`,
      evidence: { name },
    };
  },
};

export const myPlugin: HarnessPlugin = {
  name: "my-plugin",
  contributeFabricationDetectors: () => [phantomToolDetector],
};

See Fabrication detection for the full detector contract, the context shape, and how findings flow through the post-graph pipeline.

contributeSynthesisDirectiveBlocks

A plugin can return synthesis-mode directive blocks that the runtime composes into the synthesis-time prompt seam. Each block declares an id, a priority, and the directive text the seam should fold in.

contributeSynthesisDirectiveBlocks?(
  ctx: SynthesisDirectiveContext,
): readonly SynthesisDirectiveBlock[]

The hook receives a runtime-context argument carrying the channel, the resolved model, and the tenant. Contributions can be context-conditional — return a block for gateway channels and skip it for others, or vary the text by tenant tier.

The substrate ships its default synthesis-mode block at priority 10. Higher-priority contributions stack over the default; same- priority contributions append in registration order. See Prompts for the composition order overview.

contributePrompts / contributeRuntimeAwarePrompts

Two prompt-contribution hooks split by when their inputs resolve. contributePrompts(ctx) returns prompts that are independent of runtime state — persona blocks, static system instructions, provider-version notes. contributeRuntimeAwarePrompts(ctx) returns prompts that depend on values resolved per-turn, such as the active tool set, the channel mode, or the tenant.

contributePrompts?(ctx: PromptContext): readonly PromptContribution[]
contributeRuntimeAwarePrompts?(
  ctx: RuntimePromptContext,
): readonly PromptContribution[]

The runtime calls each hook once per turn and merges results into the composed prompt via the order documented in Prompts.

For the friendly API helpers (appendPrompt, prependPersona, replaceCore, scopedPrompt, gatedPrompt, createPlugin) that wrap these hooks, see Prompt builder.

contributeScrubbers

Host plugins register additional Scrubber instances by returning them from contributeScrubbers(). The runtime composes scrubbers across plugins; each scrubber declares the event-type allowlist it covers.

contributeScrubbers?(): readonly Scrubber[]

audit:c8-event-type-allowlist-coverage gates the build on every persisted event type having at least one scrubber registered — even a pass-through counts. See Scrubbers for the gate's rationale and Compliance for the four bundled scrubbers.

DomainContextStrategy (isTableValueWord host-supply)

DomainContextStrategy is the host-supply seam for domain-context heuristics the substrate previously hardcoded. Hosts pass a strategy object at runtime construction; the substrate calls into it from the word-classification and garble-recovery paths.

The substrate's word-classification step now calls out to DomainContextStrategy.isTableValueWord(word) instead of consulting a built-in word list. Hosts override this to teach the substrate domain-specific vocabulary without forking the substrate — a search host registers query types, a medical host registers ICD codes, a finance host registers ticker shapes.

Other strategy entries (body-garble dispatcher, short-content garble, early-coherence) are similarly host-overridable. See the source for the full strategy interface.

Minimal plugin shape

// pleach.plugin.ts
import type { HarnessPlugin } from "@pleach/core";

export const myPlugin: HarnessPlugin = {
  name: "my-plugin",

  contributeStreamObservers: () => [
    {
      when: { callClass: "synthesize" },
      factory: () => ({
        observerId: "redact-pii",
        onChunk(chunk) {
          // Return { kind: "continue" } to pass the chunk through, or
          // { kind: "amend", chunk: rewritten } to rewrite it in-flight.
          return { kind: "continue" };
        },
      }),
    },
  ],

  contributePrompts: () => [
    /* PromptContribution entries */
  ],
};

Lifecycle events (session created, tool completed, message added) are not plugin callbacks — the onSessionCreated / onToolCompleted / onMessageAdded hooks have been retired. Subscribe to the same signals through the typed event-log projection instead:

for await (const event of runtime.events.iterate({ chatId })) {
  /* react to tool.completed, message.added, … */
}

Register the plugin once at runtime construction:

const runtime = new SessionRuntime({
  storage: new SupabaseAdapter({ client: supabase }),
  checkpointer: new SupabaseSaver({ client: supabase }),
  plugins: [myPlugin, compliancePlugin, gatewayPlugin],
  userId: "user_123",
});

Registration order is the dispatch order — the substrate doesn't re-order plugins. Two plugins that genuinely commute will produce the same observable output regardless of order; two that don't should be sequenced explicitly at the registration site. The canonical non-commuting pair is a PII-redaction observer and a metrics observer that records chunk lengths: register redaction first and the metrics observer sees the redacted text length; register metrics first and it sees the original length. Both orderings are legal; the substrate's job is to make the choice visible at the construction site, not to pick for you.

contributeFabricationGuard

The fabrication guard hook returns one FabricationGuardImpl instance — a single object whose methods the substrate's fabrication pipeline calls during a synthesized turn. Until recently the implementation reached the runtime through the orchestratorHotpath untyped bag; the typed hook replaces it.

contributeFabricationGuard?(): FabricationGuardImpl | null | undefined

FabricationGuardImpl carries one method per detection signal:

MethodPurpose
applyFabricationGuard(params)The top-level pipeline — wipes paragraphs, returns the FabricationGuardResultShape.
isGuardBlockedFailure(errorMessage)Classify a failed-tool error as guard-blocked vs real upstream failure.
detectBulkFailureFabrication(params)Fires when failure ratio is high and the response carries quantitative content.
detectUnqueriedCitations(params)Citations to sources the model never queried this turn.
detectSelfFabricationConfession(params)Confession phrase + an 8-hex prefix matching a prior tool-use's jobId.
detectMethodResultFabricationConfession(params)Phrase-only confessions retracting fabricated method-result tables.
contentReferencesMissingTools(content, unavailableToolNames)Bytewise scan over prose for any tool in an unavailable set.
detectIdentifierMismatch(params)Identifiers in prose absent from tool results.

The hook returns one impl per plugin. If multiple plugins contribute, registration order determines dispatch order. Returning null or undefined skips the contribution — the substrate falls through to its baseline behavior, and any signal whose slot is unfilled cleanly skips per Fabrication detection.

contributeFabricationGuard is not in the structured PluginCapabilities cluster (the initial 8 keys). Reach for it through _raw:

// lib/plugins/corpusGuard.ts
import { definePleachPlugin } from "@pleach/core";
import type { FabricationGuardImpl } from "@pleach/core/plugins";

const corpusGuard: FabricationGuardImpl = {
  applyFabricationGuard(params) { /* ... */ },
  isGuardBlockedFailure(err) { /* ... */ },
  // ...remaining methods
};

export const corpusGuardPlugin = definePleachPlugin("corpus-guard", {
  _raw: {
    version: "0.2.0",
    contributeFabricationGuard: () => corpusGuard,
  },
});

bag-entry-retirement-readiness audit status

The audit tracks per-entry migration status from the orchestratorHotpath untyped bag to the typed hook surface. Current state on the OrchestratorHotpathModules shape:

EntryStatusDetail
streamHelpersRETIREDBag field deleted; the typed SessionRuntimeConfig.metaToolNames is canonical. Field count on the bag dropped 3 → 2.
toolCouplingRETIRE-READYTyped replacement landed; no novel bag reads remain.
fabricationGuardNEAR-READYTyped contributeFabricationGuard hook landed; residual bag reads remain on the deprecation path.

Hosts still reading from the bag should migrate via the bare → method codemod documented in Host adapter.

[Pleach:capability-not-contributed] breadcrumbs

Eleven contribute* collectors emit a one-shot [Pleach:capability-not-contributed] console line when they aggregate to an empty array. The breadcrumb is dedup-keyed on ${sessionId}:${capability}, so it fires once per session per capability rather than per turn. The runtime keeps working — the collector returns its empty result and the substrate proceeds — but the line surfaces a missing plugin without spamming logs.

The eleven gated capabilities:

Capability hook
contributeStreamObservers
contributeStreamFilters
contributeSafetyPolicies
contributeFabricationDetectors
contributePostToolTier
contributeRefClassValidators
contributeMiddleware
contributeRuntimeAwareMiddleware
contributeToolCouplingHints
contributeIntentToolMap
contributeIntentParameterResolvers

Payload shape:

console.log("[Pleach:capability-not-contributed]", {
  capability: "contributeStreamObservers",
  sessionId: "sess_018f...",
  hint: "register a HarnessPlugin that implements contributeStreamObservers() to provide this capability",
});

To surface the same gaps at construction time — before any seam fires — call inspectRuntime(runtime) and walk report.capabilities for rows whose wiredCount === 0. See Runtime inspector.

Sibling SKUs as plugins

The @pleach/* siblings surface as plugins (or as DI-friendly clients that consume the plugin contract). The table below reflects where each SKU sits today — most ship a real Phase A surface; a few are contract-only or still landing slice-by-slice. Pin exact versions per the Packages bucket guidance.

PackageWhat it contributesStatus
@pleach/compliance 0.1.0Scrubbers (SSN-US, Luhn, US-DL, KeyedRegex) + ComplianceRuntime contract substrate; event-chain attestation; verification utilities for HIPAA / GDPR / PCI-DSS / SOC 2.Shipping
@pleach/eval 0.1.0EvalSuite + EvalCase discriminated union + scorers + report formatters. Reads the AuditableCall ledger via the event-log projection (runtime.events.iterate); consumes @pleach/replay's ReplayClient via DI.Shipping
@pleach/gateway 0.1.0GatewayClient — thin wrapper over @pleach/core's model-family substrate; per-tenant BYOK key routing; family-strict cascade pivot; per-call cost emission; OTel llm.invocation span emission.Shipping
@pleach/replay 0.1.0ReplayClient walks the canonical event log via runtime.events.iterate / fold. createReplayRuntime factory ships real bodies for replayTurn, fromSnapshot, fork, and aggregateMultiTenant — zero throw sites remain.Shipping
@pleach/mcp 0.1.0Concrete MCPServer class wrapping SessionRuntime over stdio. SSE + WebSocket transport arms ship in the union for shape-locking; server.start({ transport: "sse" | "websocket" }) throws NotImplementedError("D-PA-181") until the next slice. Multi-tenant registerSession() throws NotImplementedError("D-PA-184") until @pleach/gateway C3 lands.Shipping (stdio end-to-end)
@pleach/coding-agent 0.1.0Typed CodingAgentRuntime contract at @pleach/coding-agent/runtime. start(), stop(), and executeStep() bodies all land at the 0.1.0 cut. executeStep() throws PACK_270_D3_EXECUTE_STEP_NOT_STARTED_MESSAGE only when called before start().Shipping
@pleach/sandbox 0.1.0Vendor-neutral SandboxProvider contract + in-memory fixture. Canonical name SandboxProvider; legacy SandboxAdapter retained as a @deprecated alias.Shipping (contract)
@pleach/observe 0.1.0Destination-flexible audit-row SDK. init + per-turn recorder + destination plug, five destinations (memory, postgres, supabase, otel, pleachHosted), PII redaction, sampling, fingerprint, subagent.Shipping
@pleach/recipes 0.1.0Composable one-line factories: simpleChatbot, ragChatbot, observableChatbot, compliantChatbot, instrumentedCodingAgent. Each subpath pulls only the @pleach/* peers its recipe needs.Shipping

The plug-and-play guarantee already holds — none of the above can break the lattice, the singleton seam, or the family lock (those are CI gates on the core repo, not opt-in discipline). Each sibling's own README documents the slots it fills and the config it expects.

Where to go next

On this page