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.
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 74-hook surface at a glance
The full HarnessPlugin contract today exposes 74 hooks —
67 contribute* methods plus 7 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 | 13 | contributeTools, contributeIntentToolMap, contributeToolCouplingHints, contributeBatchingHints, contributeIntentClassifiers, contributeIntentParameterResolvers, contributeIntentKeywordClasses, contributeDomainClassifierPatterns, contributePlanPrefixes, contributeGuestDeniedTools, contributeDefaultAgentGraphRegistries, contributeToolFollowUpIntents, … |
| Stream observers + chunk handlers | 8 | contributeStreamObservers, 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 | 3 | contributeFamilyPivot, contributeFamilyExhaustedSurface, contributeRetryPolicy |
| Middleware + prompt bridge + domain | 7 | contributeMiddleware, contributeRuntimeAwareMiddleware, contributePromptContextBridge, contributeFallbackSystemPromptBuilder, contributeDomainSynonyms, contributeChainingFieldNames, contributeIntentMentionDetector |
| Audit + meta-learning + manifest | 6 | contributeAuditEmitter, contributeMetaLearningContext, contributeChatManifestProvider, contributeEventTypes, contributePreserveDataRefFields, contributeProbes |
| Misc (artifact cache, prefetcher, get_data) | 4 | contributeArtifactCacheReader, contributeStructurePrefetcher, contributeGetDataHandlerFactory, contributeDataChannelRefetch |
Top-level non-contribute* | 7 | extraGraphNodes, prePlanPrimer, postSynthesisGuard, onJobDispatch, onJobComplete, registerAsyncExecutors, eventResolver |
| Total | 74 |
Honest scope-limit. Most authors fill 1–5 hooks. The 74-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.
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 5 top-level (non-contribute*) hooks
import type { HarnessPlugin } from "@pleach/core";
export const myPlugin: HarnessPlugin = {
name: "my-plugin",
version: "0.1.0",
// 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,
// 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 seven are top-level rather than contribute* because they
either return a typed array of graph nodes (extraGraphNodes),
operate on a per-call context with a single first-wins return
(prePlanPrimer, postSynthesisGuard), 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).
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.