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 triplets —
HarnessPluginis a bounded contract (74 optional hooks — 67contribute*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
| Key | Type | Maps to |
|---|---|---|
prompts | readonly PromptContribution[] | contributePrompts |
runtimeAwarePrompts | ((ctx, state) => readonly PromptContribution[]) | readonly PromptContribution[] | contributeRuntimeAwarePrompts |
safetyPolicies | readonly SafetyContribution[] | contributeSafetyPolicies |
fabricationDetectors | readonly FabricationDetector[] | contributeFabricationDetectors |
tools | readonly ToolDefinitionLite[] | contributeTools |
streamObservers | readonly StreamObserverRegistration[] | contributeStreamObservers |
intentToolMap | readonly IntentToolMapEntry[] | contributeIntentToolMap |
toolCouplingHints | readonly 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)contributeSynthesisDirectiveBlockscontributeFinalizationPassescontributeDetectionRulescontributeIntentClassifierscontributeSandboxBridgecontributeInterruptUIHandlerscontributeCitationRuleSetcontributeChatManifestProvidercontributeBatchingHints,contributeEventTypesextraGraphNodes,prePlanPrimer,postSynthesisGuard- The deprecated bare-property forms — flagged in
inspectRuntime()asdeprecated-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, …) pluscomposePlugin()for assembling a plugin across multiple files. Both APIs produce aHarnessPlugin; 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
| Capability | API surface | When you reach for it |
|---|---|---|
| Register extra graph nodes in lattice enrichment slots | extraGraphNodes | Intent detection, plan generation, safety review, quality scoring |
| Register stream observers on provider seams | contributeStreamObservers | Per-chunk inspection, content rewriting, halt on policy violation |
| Register prompt contributors | contributePrompts, contributeRuntimeAwarePrompts | Static system blocks, runtime-aware sections, retrieval-driven additions |
| Register safety policies | contributeSafetyPolicies | Refusal-pattern detectors, policy-bound rewriters |
| Subscribe to lifecycle events | runtime.events.iterate({chatId, fromSequenceNumber}) + contributeStreamObservers | Domain 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 envelopes | seam observer emit verdict | Pipe 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.
| Forbidden | Enforced by |
|---|---|
| Add an out-of-lattice edge to the graph | audit:graph-stages (CI gate) |
| Bypass the singleton synthesize seam | SynthesizeSeamHolder + TurnSynthesizeCounter (runtime) |
Reach across the seams/ boundary into the modelfamily matrix | lint:harness-boundary (CI gate) |
| Register an async stream-observer dispatch path | Type signature is onChunk(chunk, ctx): Verdict, sync only |
Use a raw callClass: "..." literal outside a seam factory | lint: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.
| Stage | Enrichment slots |
|---|---|
anchor-plan | intentDetection, planGeneration, anchorBuilding |
tool-loop | toolSelection, toolExecution, dataSilo, jobSilo |
synthesize | synthesizerPreamble, citationInjector |
post-turn | qualityScoring, 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:
| Verdict | Effect |
|---|---|
continue | Pass through unchanged |
amend | Replace chunk content 1:1 — strict, no multiplex |
emit | Pass through and emit a named-channel envelope downstream |
stop | Stop 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 | undefinedFabricationGuardImpl carries one method per detection signal:
| Method | Purpose |
|---|---|
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:
| Entry | Status | Detail |
|---|---|---|
streamHelpers | RETIRED | Bag field deleted; the typed SessionRuntimeConfig.metaToolNames is canonical. Field count on the bag dropped 3 → 2. |
toolCoupling | RETIRE-READY | Typed replacement landed; no novel bag reads remain. |
fabricationGuard | NEAR-READY | Typed 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.
| Package | What it contributes | Status |
|---|---|---|
@pleach/compliance 0.1.0 | Scrubbers (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.0 | EvalSuite + 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.0 | GatewayClient — 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.0 | ReplayClient 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.0 | Concrete 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.0 | Typed 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.0 | Vendor-neutral SandboxProvider contract + in-memory fixture. Canonical name SandboxProvider; legacy SandboxAdapter retained as a @deprecated alias. | Shipping (contract) |
@pleach/observe 0.1.0 | Destination-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.0 | Composable 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
Contribution namespaces
The nine namespaces every contribution hook resolves to — the canonical map of where a plugin plugs in.
Architecture
The six pieces of the substrate — stage lattice, call classes, seams, family-lock, audit ledger, event log.
Packages
The @pleach/* matrix — which sibling does what.
Auditable call row
The audit row every plugin's contributions are reflected in.
Host adapter
setHarnessModuleLoader and the R-track that retires loader keys into plugin slots.
Fingerprint
The cache-key tuple — what's in it, what's deliberately excluded, why the split matters for caching, replay, and audit.
Plugin authoring standards
Conventions for naming, versioning, file layout, peer dependencies, license, and deprecation of plugins built against @pleach/core. Lint-enforced where mechanical, doc-recommended where judgment matters.