Skip to content
pleach
Plugins

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.

BucketCountExamples
Prompts5contributePrompts, contributeRuntimeAwarePrompts, contributePromptHints, contributePromptSections, contributePromptSectionGeneratorBundle
Safety + scrubbing + synthesis directives4contributeSafetyPolicies, contributeScrubbers, contributeSynthesisDirectiveBlocks, contributeFinalizationPasses
Tools + planning + intent14contributeTools, contributeIntentToolMap, contributeIntentJustifiedTools, contributeToolCouplingHints, contributeBatchingHints, contributeIntentClassifiers, contributeIntentClassificationInstruction, contributeIntentParameterResolvers, contributeIntentKeywordClasses, contributeDomainClassifierPatterns, contributePlanPrefixes, contributeGuestDeniedTools, contributeDefaultAgentGraphRegistries, contributeToolFollowUpIntents
Stream observers + chunk handlers9contributeStreamObservers, contributeStreamFilters, contributeObserverConsumers, contributeStreamChunkHandlers, contributeStreamEventHandlersAdapters, contributeStreamEndDiagnosticsAnalyzer, contributeDetectionRules, contributeHallucinatedToolDetectors, contributeGarbledOutputRecorder
Synthesis + fabrication + post-tool7contributeFabricationDetectors, contributeFabricationDetectorRules, contributeFabricationGuard, contributeRefClassValidators, contributeContinuationPolicy, contributeContinuationShadowResolver, contributePostToolTier
Citations + entities5contributeCitationEntityExtractor, contributeCitationInjector, contributeCitationRuleSet, contributeEntityExtractors, contributeEntityNameCounter
Sandbox + interrupts + approval5contributeSandboxBridge, contributeSandboxInitialization, contributeSandboxAvailabilityEvaluator, contributeInterruptUIHandlers, contributeApprovalFlow
Family routing + retry + errors4contributeFamilyPivot, contributeFamilyExhaustedSurface, contributeRetryPolicy, contributeErrorClassifiers
Middleware + prompt bridge + domain7contributeMiddleware, contributeRuntimeAwareMiddleware, contributePromptContextBridge, contributeFallbackSystemPromptBuilder, contributeDomainSynonyms, contributeChainingFieldNames, contributeIntentMentionDetector
Audit + meta-learning + manifest7contributeAuditEmitter, contributeAuthEventSink, contributeMetaLearningContext, contributeChatManifestProvider, contributeEventTypes, contributePreserveDataRefFields, contributeProbes
Misc (artifact cache, prefetcher, get_data)4contributeArtifactCacheReader, contributeStructurePrefetcher, contributeGetDataHandlerFactory, contributeDataChannelRefetch
Top-level non-contribute*9resolveCapabilityTrust, extraGraphNodes, prePlanPrimer, postSynthesisGuard, finalizeAnswer, onJobDispatch, onJobComplete, registerAsyncExecutors, eventResolver
Total80

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";
EventHookFires fromDispatchCancellable
capability.trustresolveCapabilityTrustruntime — registrationgateyes
turn.plan.preprePlanPrimergraph node planGenerator (anchor-plan)first-non-nullno
turn.prompt.hintscontributePromptHintsgraph — DI holder read by node closurescollectno
tool.job.dispatchonJobDispatchruntime — async task pathbroadcastno
tool.job.completeonJobCompleteruntime — async task pathbroadcastno
synthesis.guardpostSynthesisGuardgraph node fabrication (tool-loop)first-non-nullno
turn.answer.finalizefinalizeAnswerruntime — after the graph returnsfirst-non-nullno
graph.nodes.extraextraGraphNodesgraph build — compile timecollectno
runtime.async.executorsregisterAsyncExecutorsruntime — constructionbroadcastno
eventlog.domain.resolveeventResolver⚠ unwired — no production consumercollectno

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 canonical StageId. turn.plan.pre runs in planGenerator (anchor-plan); synthesis.guard runs in fabrication (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.extra adds nodes rather than running inside one.
  • runtime — outside the graph entirely. capability.trust fires at plugin registration, before any graph exists; runtime.async.executors at runtime construction; tool.job.dispatch / tool.job.complete on the async task path; turn.answer.finalize after invoke() 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 from first-non-null because 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 CapabilityTrustContext describes 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 hookhost policy
Sees the first pluginnoyes
Order-independentnoyes
Supplied bya plugin that already passed the gatethe embedder
A throwing policyabstainsdenies

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.
  • null abstains, and abstention is distinct from approval — which is exactly why the dispatch rule is gate.
  • 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 a CapabilityTrustDeniedError per refusal, carrying pluginName, deniedBy, and reason. A host enforcing a policy can assert it is empty; a skipped plugin is otherwise indistinguishable from one that was never supplied.
  • declaredHooks walks the prototype chain. HarnessPlugin is 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 typeCanonical 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 keyHook it maps to
promptscontributePrompts
runtimeAwarePromptscontributeRuntimeAwarePrompts (accepts function OR fixed array)
safetyPoliciescontributeSafetyPolicies
fabricationDetectorscontributeFabricationDetectors
toolscontributeTools
streamObserverscontributeStreamObservers
intentToolMapcontributeIntentToolMap
toolCouplingHintscontributeToolCouplingHints

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

WhatWhere
Empty-plugin scaffold (every hook, every canonical no-op)examples/plugins/empty-plugin/
Stream observer with amend verdictexamples/observers/dialect-normalization/
Stream observer with stop verdictexamples/observers/halt-on-pattern/
Vertical entity recognizer via contributePostToolTierexamples/plugins/domain-entity-recognition/
Custom event channel via emit verdictexamples/plugins/custom-event-channel/
Full type surfacepackages/core/src/plugins/types.ts

Each example is a standalone npm package with a node --test smoke. Clone, run, modify.

Where to go next