pleach
Plugins

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.

BucketCountExamples
Prompts5contributePrompts, contributeRuntimeAwarePrompts, contributePromptHints, contributePromptSections, contributePromptSectionGeneratorBundle
Safety + scrubbing + synthesis directives4contributeSafetyPolicies, contributeScrubbers, contributeSynthesisDirectiveBlocks, contributeFinalizationPasses
Tools + planning + intent13contributeTools, contributeIntentToolMap, contributeToolCouplingHints, contributeBatchingHints, contributeIntentClassifiers, contributeIntentParameterResolvers, contributeIntentKeywordClasses, contributeDomainClassifierPatterns, contributePlanPrefixes, contributeGuestDeniedTools, contributeDefaultAgentGraphRegistries, contributeToolFollowUpIntents, …
Stream observers + chunk handlers8contributeStreamObservers, 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 + retry3contributeFamilyPivot, contributeFamilyExhaustedSurface, contributeRetryPolicy
Middleware + prompt bridge + domain7contributeMiddleware, contributeRuntimeAwareMiddleware, contributePromptContextBridge, contributeFallbackSystemPromptBuilder, contributeDomainSynonyms, contributeChainingFieldNames, contributeIntentMentionDetector
Audit + meta-learning + manifest6contributeAuditEmitter, contributeMetaLearningContext, contributeChatManifestProvider, contributeEventTypes, contributePreserveDataRefFields, contributeProbes
Misc (artifact cache, prefetcher, get_data)4contributeArtifactCacheReader, contributeStructurePrefetcher, contributeGetDataHandlerFactory, contributeDataChannelRefetch
Top-level non-contribute*7extraGraphNodes, prePlanPrimer, postSynthesisGuard, onJobDispatch, onJobComplete, registerAsyncExecutors, eventResolver
Total74

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 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 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

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

On this page