Authoring a HarnessPlugin
How to write a HarnessPlugin from scratch — the 80-hook surface, the named lifecycle vocabulary, when each hook fires, and the canonical no-op return for every hook shape.
A HarnessPlugin is the typed extension surface for
@pleach/core. Every sibling SKU — @pleach/compliance,
@pleach/gateway, @pleach/eval, @pleach/replay,
@pleach/mcp, @pleach/observe, @pleach/recipes,
@pleach/coding-agent, @pleach/sandbox, @pleach/langchain,
@pleach/base-tools — is a plugin that implements this
contract. Your own consumer code extends the runtime through the
same surface.
This page covers the practical authoring story: import shape, hook taxonomy, the canonical no-op for each return type, and the worked starting point. For the structural-invariant view (what plugins can and can't do, the lattice slots, the verdict ladder), see Plugin contract.
The canonical shape
import { definePleachPlugin, type HarnessPlugin } from "@pleach/core";
export const myPlugin = definePleachPlugin("my-plugin", {
// structured cluster — 8 most-used hooks
prompts: [personaBlock],
streamObservers: [redactPiiObserver],
safetyPolicies: [refusalPolicy],
tools: [searchCorpus],
// …
_raw: {
version: "0.1.0",
// forward-compat escape hatch — any hook not in the cluster
contributePostToolTier: () => myEntityRecognizer,
},
});The raw HarnessPlugin object literal still works — the type is
public and existing plugins keep loading. definePleachPlugin()
is the recommended path; it surfaces "what can my plugin
contribute?" through autocomplete instead of paging the 1100-line
interface.
The 80-hook surface at a glance
The full HarnessPlugin contract today exposes 80 hooks —
71 contribute* methods plus 9 top-level lifecycle / extension
points. The table below groups them informally so you can scan for
the one you need. For the canonical, audit-gated layout — every
hook resolved to exactly one of nine namespaces — see
Contribution namespaces.
| Bucket | Count | Examples |
|---|---|---|
| Prompts | 5 | contributePrompts, contributeRuntimeAwarePrompts, contributePromptHints, contributePromptSections, contributePromptSectionGeneratorBundle |
| Safety + scrubbing + synthesis directives | 4 | contributeSafetyPolicies, contributeScrubbers, contributeSynthesisDirectiveBlocks, contributeFinalizationPasses |
| Tools + planning + intent | 14 | contributeTools, contributeIntentToolMap, contributeIntentJustifiedTools, contributeToolCouplingHints, contributeBatchingHints, contributeIntentClassifiers, contributeIntentClassificationInstruction, contributeIntentParameterResolvers, contributeIntentKeywordClasses, contributeDomainClassifierPatterns, contributePlanPrefixes, contributeGuestDeniedTools, contributeDefaultAgentGraphRegistries, contributeToolFollowUpIntents |
| Stream observers + chunk handlers | 9 | contributeStreamObservers, contributeStreamFilters, contributeObserverConsumers, contributeStreamChunkHandlers, contributeStreamEventHandlersAdapters, contributeStreamEndDiagnosticsAnalyzer, contributeDetectionRules, contributeHallucinatedToolDetectors, contributeGarbledOutputRecorder |
| Synthesis + fabrication + post-tool | 7 | contributeFabricationDetectors, contributeFabricationDetectorRules, contributeFabricationGuard, contributeRefClassValidators, contributeContinuationPolicy, contributeContinuationShadowResolver, contributePostToolTier |
| Citations + entities | 5 | contributeCitationEntityExtractor, contributeCitationInjector, contributeCitationRuleSet, contributeEntityExtractors, contributeEntityNameCounter |
| Sandbox + interrupts + approval | 5 | contributeSandboxBridge, contributeSandboxInitialization, contributeSandboxAvailabilityEvaluator, contributeInterruptUIHandlers, contributeApprovalFlow |
| Family routing + retry + errors | 4 | contributeFamilyPivot, contributeFamilyExhaustedSurface, contributeRetryPolicy, contributeErrorClassifiers |
| Middleware + prompt bridge + domain | 7 | contributeMiddleware, contributeRuntimeAwareMiddleware, contributePromptContextBridge, contributeFallbackSystemPromptBuilder, contributeDomainSynonyms, contributeChainingFieldNames, contributeIntentMentionDetector |
| Audit + meta-learning + manifest | 7 | contributeAuditEmitter, contributeAuthEventSink, contributeMetaLearningContext, contributeChatManifestProvider, contributeEventTypes, contributePreserveDataRefFields, contributeProbes |
| Misc (artifact cache, prefetcher, get_data) | 4 | contributeArtifactCacheReader, contributeStructurePrefetcher, contributeGetDataHandlerFactory, contributeDataChannelRefetch |
Top-level non-contribute* | 9 | resolveCapabilityTrust, extraGraphNodes, prePlanPrimer, postSynthesisGuard, finalizeAnswer, onJobDispatch, onJobComplete, registerAsyncExecutors, eventResolver |
| Total | 80 |
Honest scope-limit. Most authors fill 1–5 hooks. The full hook
surface is the substrate's total contribution ceiling, not the
typical author's checklist. The structured capabilities cluster
covers the 8 most-reached-for hooks; _raw catches the rest.
The full hook list with one-line descriptions is in
examples/plugins/empty-plugin/plugin.mjs
— a copy-paste no-op scaffold where every hook is present with
the canonical empty return.
The lifecycle vocabulary
The hook table above is a capability list: it tells you what you
can contribute, not when your hook runs relative to anyone
else's. PLUGIN_LIFECYCLE_EVENTS is the ordered answer, exported
as data so you can read it, render it, or assert against it.
import { PLUGIN_LIFECYCLE_EVENTS, describePluginLifecycle } from "@pleach/core/plugins";| Event | Hook | Fires from | Dispatch | Cancellable |
|---|---|---|---|---|
capability.trust | resolveCapabilityTrust | runtime — registration | gate | yes |
turn.plan.pre | prePlanPrimer | graph node planGenerator (anchor-plan) | first-non-null | no |
turn.prompt.hints | contributePromptHints | graph — DI holder read by node closures | collect | no |
tool.job.dispatch | onJobDispatch | runtime — async task path | broadcast | no |
tool.job.complete | onJobComplete | runtime — async task path | broadcast | no |
synthesis.guard | postSynthesisGuard | graph node fabrication (tool-loop) | first-non-null | no |
turn.answer.finalize | finalizeAnswer | runtime — after the graph returns | first-non-null | no |
graph.nodes.extra | extraGraphNodes | graph build — compile time | collect | no |
runtime.async.executors | registerAsyncExecutors | runtime — construction | broadcast | no |
eventlog.domain.resolve | eventResolver | ⚠ unwired — no production consumer | collect | no |
Scope is deliberate: these are the hooks whose position in the runtime is a fact you need. The 71 capability-contribution hooks are consumed once at assembly time and have no position in a turn, so they are not in the vocabulary.
Which events run inside the graph
Not all of them, and the difference matters when you are reasoning about ordering, checkpointing, or replay. Three of the seven fire from the compiled graph; the other four fire from the runtime around it.
position is a discriminated union, so this is data you can branch
on rather than prose you have to trust:
type PluginLifecyclePosition =
| { kind: "graph-node"; node: string; stage: StageId }
| { kind: "graph-holder"; holder: string }
| { kind: "graph-build" }
| { kind: "runtime"; where: "registration" | "construction" | "async-task" | "post-graph" }
| { kind: "unwired"; reason: string };
describePluginLifecycle();
// → { graphNode, graphHolder, graphBuild, runtime, unwired }graph-node— invoked from a named node, at that node's canonicalStageId.turn.plan.preruns inplanGenerator(anchor-plan);synthesis.guardruns infabrication(tool-loop).graph-holder— on the graph path, but threaded through a DI holder that several node closures read, so it belongs to no single node and carries no single stage.graph-build— consumed once when the graph is compiled.graph.nodes.extraadds nodes rather than running inside one.runtime— outside the graph entirely.capability.trustfires at plugin registration, before any graph exists;runtime.async.executorsat runtime construction;tool.job.dispatch/tool.job.completeon the async task path;turn.answer.finalizeafterinvoke()returns.unwired— declared on the contract, collectable by a dispatcher, and consumed by nothing in production. See below.
The practical consequence: a runtime-positioned hook is not
subject to the stage lattice, is not part of a checkpoint boundary,
and does not appear in the graph's stage-transition record. If you
need an effect that participates in those, it has to be reached by
a graph node — a hook alone will not put it there.
Every graph-node claim is checked against NODE_STAGE_MAP: a
shipped test fails if a descriptor names a node that does not
exist, or claims a stage the canonical map disagrees with.
unwired — naming a hook nothing consumes
eventResolver is declared on the contract, and
PluginManager.getEventResolvers() collects it correctly. No
production code passes that output anywhere. Setting the hook today
has no runtime effect.
The vocabulary states this rather than hiding it:
describePluginLifecycle().unwired;
// → [{ event: "eventlog.domain.resolve", position: { kind: "unwired", reason: "…" } }]An unwired entry is not a bug report — it is plumbing whose
consumer has not landed, and reason says exactly what is missing.
The alternative is that the next author discovers it by writing a
hook, shipping it, and watching nothing happen. A shipped test
requires every unwired entry to carry a substantive reason.
Why turn.answer.finalize is not a graph node
It fires after invoke() returns, which looks like an omission and
is not. The turn's answer is only final once the synthesis call has
completed and the stream has stopped — that boundary is the end of
the graph run, so a hook that decorates the finished answer belongs
after it, not inside a node racing the stream it is inspecting.
The trade-off is explicit: a banner added here is outside the stage
lattice, outside checkpointing, and absent from the stage-transition
record. If you need an effect that participates in those, use
synthesis.guard, which runs inside the fabrication node.
The four dispatch rules
first-non-null— plugins are consulted in registration order; the first non-null return wins and the rest are never called. Registration order is therefore priority order.collect— every plugin contributes; results concatenate in registration order.broadcast— every plugin is notified; return values are discarded. A throwing handler is logged and skipped.gate— every plugin is consulted and any single refusal is decisive. Distinct fromfirst-non-nullbecause the refusal wins rather than the first answer, so an abstaining plugin cannot mask a later denial.
Every entry names the function that fires it
Each descriptor carries a dispatcher field — the PluginManager
method that actually invokes the hook:
PLUGIN_LIFECYCLE_EVENTS.find(d => d.event === "turn.plan.pre");
// { event: "turn.plan.pre", hook: "prePlanPrimer",
// dispatcher: "runPrePlanPrimer", phase: "turn-start",
// dispatch: "first-non-null", cancellable: false, summary: … }That field is typed keyof PluginManager, so a vocabulary entry
naming a dispatcher that does not exist fails to compile, and a
shipped test asserts each named dispatcher is callable at runtime.
The property this buys is narrow but load-bearing: a hook declared
in the contract and invoked by nothing — a dark hook — cannot be
described here without the build going red. Documentation that
would otherwise drift silently is checked mechanically instead.
Naming a new hook
The contribute prefix is reserved for capability contributions —
values collected once at assembly time. A lifecycle hook, one the
runtime calls at a defined point in a turn, must not use it; use
on*, pre*, post*, resolve*, finalize*, or a bare verb.
This is enforced, not advisory. No syntactic property separates the
two populations — eventResolver is a bare property and is
lifecycle; contributeRuntimeAwarePrompts takes a context object and
is not — so the completeness lock treats contribute* as an
exclusion arm. A lifecycle hook wearing that prefix would be
invisible to it. contributePromptHints is the single grandfathered
exception and the build fails if a second appears.
Introspecting a plugin
describeImplementedLifecycle() reports which lifecycle events a
given plugin actually implements — useful in diagnostics ("why
didn't my hook fire?") and in a trust policy deciding how much a
plugin is asking for.
import { describeImplementedLifecycle } from "@pleach/core/plugins";
describeImplementedLifecycle(myPlugin).map(d => d.event);
// → ["turn.plan.pre", "turn.answer.finalize"]capability.trust — admitting a plugin
resolveCapabilityTrust is the one hook that can refuse an
operation outright. It fires when a new plugin is registered, and
lets already-registered plugins decide whether it may join.
This is an extension point, not a security boundary. Three properties make it unsuitable as a control you rely on:
- It is registration-order-dependent. Only plugins already in the registry are consulted, so a policy registered second never sees the plugin registered first. Nothing enforces the order — the host controls it.
- It fails open silently. A policy that was never consulted and
a policy where everyone abstained both produce
null. Absence of a refusal is not evidence a policy was in force. - It cannot attest provenance. The runtime receives an object,
not a path or a signature, so
CapabilityTrustContextdescribes what a plugin declares — never where it came from.
Use it for peer advice on admission — "this plugin wants provider middleware and is not on my list." For a control you rely on, use the host policy below instead; the two compose.
The host policy — order-independent admission
PluginManagerOptions.capabilityTrustPolicy is supplied by the
embedder, not by a plugin, and fixes all three properties above.
import { PluginManager } from "@pleach/core";
const manager = new PluginManager({
capabilityTrustPolicy: (ctx) =>
ctx.declaredHooks.includes("contributeMiddleware") && !isAllowed(ctx.pluginName)
? { trusted: false, reason: "provider middleware is allowlist-only" }
: null,
});| peer hook | host policy | |
|---|---|---|
| Sees the first plugin | no | yes |
| Order-independent | no | yes |
| Supplied by | a plugin that already passed the gate | the embedder |
| A throwing policy | abstains | denies |
The throw rule inverts on purpose. A peer policy is third-party code whose bug must not be able to empty the registry. A host policy is your own code, installed deliberately — if it cannot render a verdict, admitting anyway is the unsafe direction, and there is no third party to protect from.
It is constructor-only; there is no setter. A policy installed after registration began would silently not apply to whatever had already registered — being unable to express that is the point.
SessionRuntimeConfig.capabilityTrustPolicy threads it through when
the runtime constructs the manager for you. If you pass a prebuilt
pluginManager, that manager is the authoritative registry and you
install the policy on it at your own construction site — the same
rule that makes plugins ignored when pluginManager is set.
Asserting a policy actually ran
Absence of a refusal is not evidence a gate ran: a policy never consulted and a policy where everyone abstained both produce zero denials.
manager.describeTrustPosture();
// → { hostPolicy: true, peerPolicyCount: 1, denials: 0 }hostPolicy is the field to assert on. peerPolicyCount is
order-sensitive by nature — a peer policy only ever saw the plugins
registered after it.
import { definePleachPlugin } from "@pleach/core";
const ALLOWED_MIDDLEWARE_AUTHORS = new Set(["@acme/audit-middleware"]);
export const trustPolicy = definePleachPlugin({
name: "acme-trust-policy",
version: "1.0.0",
resolveCapabilityTrust: (ctx) => {
const wantsProviderAccess = ctx.declaredHooks.includes("contributeMiddleware");
if (wantsProviderAccess && !ALLOWED_MIDDLEWARE_AUTHORS.has(ctx.pluginName)) {
return { trusted: false, reason: "provider middleware is allowlist-only" };
}
return null; // abstain
},
});The context describes the capability request rather than an origin — the runtime receives an object, not a file path, so it cannot honestly claim provenance:
interface CapabilityTrustContext {
pluginName: string;
pluginVersion: string;
declaredHooks: readonly string[]; // every function-valued key, sorted
registeredPluginNames: readonly string[];
metadata: Readonly<Record<string, unknown>> | undefined; // untrusted
}declaredHooks is the field policies actually use. What a plugin
is asking to do — rewrite provider requests, mutate the finished
answer, register graph nodes — is a better basis for a decision
than what it calls itself.
The rules that make it a gate
- Only already-registered plugins are consulted. Trust flows forward in registration order, so a plugin can never authorise itself, and the first plugin registered is always admitted.
- Any single refusal is decisive. Returning
{ trusted: true }records an opinion; it does not overrule a peer's refusal. nullabstains, and abstention is distinct from approval — which is exactly why the dispatch rule isgate.- A refusal SKIPS the plugin; it does not throw.
register()is called from bare loops that do not catch per plugin, so throwing would drop every plugin after the denied one and abort runtime construction — the same "one local defect becomes a total failure" outcome the abstain-on-throw rule below exists to prevent. A denial costs exactly the denied plugin. - Denials are retained, not just logged.
pluginManager.getTrustDenials()returns aCapabilityTrustDeniedErrorper refusal, carryingpluginName,deniedBy, andreason. A host enforcing a policy can assert it is empty; a skipped plugin is otherwise indistinguishable from one that was never supplied. declaredHookswalks the prototype chain.HarnessPluginis an interface, so a class-based plugin carries its hooks on the prototype — where an own-keys enumeration cannot see them but every collector can. The enumeration a policy reads is never narrower than the surface the runtime reaches.- A throwing policy abstains. The alternative would let one plugin's bug lock every other plugin out of the registry, turning a local defect into a total runtime failure. The throw is logged loudly instead.
- Absent hook, unchanged behaviour. When no registered plugin implements it, every registration is admitted exactly as before.
To read the verdict without catching, call
pluginManager.resolveCapabilityTrust(plugin) — it returns
{ deniedBy, reason } or null.
Canonical no-op returns
Each hook shape has one canonical "I don't contribute here" return. The collector ignores the hook entirely; nothing is dispatched.
| Hook return type | Canonical no-op |
|---|---|
readonly Array<T> (multi-contribution) | [] |
Record<string, T> (named map) | {} |
T | null (first-wins) | null |
T | undefined (first-wins) | undefined |
void (lifecycle: onJobDispatch, onJobComplete) | bare return |
The empty-plugin scaffold uses the canonical form for every hook — copy it, then put real bodies on the ones you need and delete the rest.
The minimal walk-through
import { definePleachPlugin } from "@pleach/core";
// 1. Pick the hooks you need.
// 2. Provide bodies for them.
// 3. Register the plugin once at runtime construction.
export const auditPlugin = definePleachPlugin("audit-plugin", {
_raw: { version: "0.1.0" },
streamObservers: [
{
when: { callClass: "synthesize" },
factory: () => ({
observerId: "audit-stream",
onChunk(chunk) {
// mirror chunk into your audit log
return { kind: "continue" };
},
}),
},
],
});Register at runtime construction:
const runtime = new SessionRuntime({
storage: new MemoryStorage(),
plugins: [auditPlugin, anotherPlugin],
});Registration order is the dispatch order. The substrate doesn't re-order plugins; two non-commuting plugins (e.g. a PII redactor and a metrics observer) should be sequenced explicitly at the construction site. See Plugin contract — registration order for the canonical non-commuting example.
The structured capabilities cluster
PluginCapabilities surfaces the 8 most-used hooks as a flat
structured menu so authors don't page the full interface. Each
field takes a value (not a thunk), and the factory forwards
it into the corresponding contribute* method.
| Capability key | Hook it maps to |
|---|---|
prompts | contributePrompts |
runtimeAwarePrompts | contributeRuntimeAwarePrompts (accepts function OR fixed array) |
safetyPolicies | contributeSafetyPolicies |
fabricationDetectors | contributeFabricationDetectors |
tools | contributeTools |
streamObservers | contributeStreamObservers |
intentToolMap | contributeIntentToolMap |
toolCouplingHints | contributeToolCouplingHints |
Any hook outside the structured cluster goes in _raw (typed as
Partial<HarnessPlugin>). Keys in _raw win on collision with
the structured cluster (last-write-wins), so authors migrating
bespoke plugins keep full control.
The 9 top-level (non-contribute*) hooks
import type { HarnessPlugin } from "@pleach/core";
export const myPlugin: HarnessPlugin = {
name: "my-plugin",
version: "0.1.0",
// Admit or refuse a plugin registering after this one.
resolveCapabilityTrust: (_ctx) => null,
// Register custom nodes into the compiled graph.
extraGraphNodes: () => [],
// Inject a one-shot system message before plan generation.
prePlanPrimer: (_ctx) => null,
// Inspect assistant content; emit a corrective system message.
postSynthesisGuard: (_ctx) => null,
// Prepend a non-destructive banner to every finished answer.
finalizeAnswer: (_ctx) => null,
// Lifecycle: paired job-dispatch + job-complete callbacks.
onJobDispatch: (_ctx) => {},
onJobComplete: (_ctx) => {},
// Register async executors for long-running job-backed tools.
registerAsyncExecutors: (_registrar) => {},
// Resolve domain-specific event types when folding the event log.
eventResolver: undefined,
};These nine are top-level rather than contribute* because they
gate registration itself (resolveCapabilityTrust),
return a typed array of graph nodes (extraGraphNodes),
operate on a per-call context with a single first-wins return
(prePlanPrimer, postSynthesisGuard, finalizeAnswer), fire
side-effect-only lifecycle callbacks (onJobDispatch,
onJobComplete), register async executors (registerAsyncExecutors),
or supply a domain-event resolver property rather than a collector
method (eventResolver).
Guarding vs. finalizing the answer
postSynthesisGuard and finalizeAnswer both inspect the finished
answer, but they fire on different conditions and correct it two
different ways.
postSynthesisGuard runs on the synthesized draft. Return a string
to send a corrective system message back for a re-plan — use it when
the content is wrong and should be regenerated. Return { banner }
instead to prepend a bounded, labelled caveat without regenerating,
when the content is substantively grounded but needs a note. null
takes no action.
postSynthesisGuard(ctx): string | { banner: string } | null {
if (ctx.completedTools.length === 0 && ctx.assistantContent.includes("I ran")) {
return "You claimed a tool ran but none did. Re-plan without claiming execution."
}
return null
}finalizeAnswer fires unconditionally on every synthesis — normal
and force-synthesis turns alike — after the graph produces the answer
and just before the message.complete event. It only prepends: return
{ banner } to add a non-destructive notice via a content.correction
(the answer body is preserved, never regenerated), or null to leave
it untouched. The first non-null banner wins.
finalizeAnswer(ctx): { banner: string } | null {
return ctx.completedTools.length === 0 && ctx.assistantContent.length > 4000
? { banner: "> Note: this answer was produced without tool results." }
: null
}The banner is prepended verbatim, so it carries its own formatting.
Reach for finalizeAnswer when a notice must ride on every answer,
and postSynthesisGuard when the correction is conditional on the
draft — including the re-plan path a plain banner can't trigger.
Capability breadcrumbs
Eleven contribute* collectors emit a one-shot
[Pleach:capability-not-contributed] console line when they
aggregate to an empty array across all registered plugins. The
breadcrumb is dedup-keyed on ${sessionId}:${capability} and
surfaces a missing plugin without spamming logs.
| Capability hook |
|---|
contributeStreamObservers |
contributeStreamFilters |
contributeSafetyPolicies |
contributeFabricationDetectors |
contributePostToolTier |
contributeRefClassValidators |
contributeMiddleware |
contributeRuntimeAwareMiddleware |
contributeToolCouplingHints |
contributeIntentToolMap |
contributeIntentParameterResolvers |
To surface the same gaps at construction time, call
inspectRuntime(runtime) and walk report.capabilities for rows
whose wiredCount === 0. See
Runtime inspector.
Canonical reference
| What | Where |
|---|---|
| Empty-plugin scaffold (every hook, every canonical no-op) | examples/plugins/empty-plugin/ |
Stream observer with amend verdict | examples/observers/dialect-normalization/ |
Stream observer with stop verdict | examples/observers/halt-on-pattern/ |
Vertical entity recognizer via contributePostToolTier | examples/plugins/domain-entity-recognition/ |
Custom event channel via emit verdict | examples/plugins/custom-event-channel/ |
| Full type surface | packages/core/src/plugins/types.ts |
Each example is a standalone npm package with a node --test
smoke. Clone, run, modify.
Where to go next
Plugin contract
The structural-invariant view — what plugins can and can't do, lattice slots, verdict ladder.
Stream observers
The contributeStreamObservers hook in detail — verdicts, factory pattern, replay determinism.
Post-tool tier
The contributePostToolTier hook — uniform enrichment across tool batches.
Plugin bundles
composePlugin() for assembling a larger plugin across multiple files.
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.
Contribution namespaces
The nine namespaces that organize every HarnessPlugin contribution hook — the canonical, audit-gated map of where a plugin plugs into the runtime.