Skip to content
pleach
Architecture

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 — HarnessPlugin is a bounded contract (77 optional hooks — 68 contribute* hooks plus 9 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

KeyTypeMaps to
promptsreadonly PromptContribution[]contributePrompts
runtimeAwarePrompts((ctx, state) => readonly PromptContribution[]) | readonly PromptContribution[]contributeRuntimeAwarePrompts
safetyPoliciesreadonly SafetyContribution[]contributeSafetyPolicies
fabricationDetectorsreadonly FabricationDetector[]contributeFabricationDetectors
toolsreadonly ToolDefinitionLite[]contributeTools
streamObserversreadonly StreamObserverRegistration[]contributeStreamObservers
intentToolMapreadonly IntentToolMapEntry[]contributeIntentToolMap
toolCouplingHintsreadonly 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)
  • contributeSynthesisDirectiveBlocks
  • contributeFinalizationPasses
  • contributeDetectionRules
  • contributeIntentClassifiers
  • contributeSandboxBridge
  • contributeInterruptUIHandlers
  • contributeCitationRuleSet
  • contributeChatManifestProvider
  • contributeBatchingHints, contributeEventTypes
  • extraGraphNodes, prePlanPrimer, postSynthesisGuard
  • The deprecated bare-property forms — flagged in inspectRuntime() as deprecated-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, …) plus composePlugin() for assembling a plugin across multiple files. Both APIs produce a HarnessPlugin; 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. 71 contribute* hooks are catalogued today (the full hook surface adds 9 top-level lifecycle points): 68 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; contributePromptSections ships the first slice of its contract while the consumer rewire lands). PluginManager.collectAuthEventSink() landed 2026-09-02, so contributeAuthEventSink now counts toward the wired 68 — but "wired" only means the audit found a call to the collector, and the sole call anywhere in the repo is the facet bridge (runtime.plugins.collectAuthEventSink()) the same commit added. No host code reads that facet property yet: zero call sites across the app, every @pleach/* package, and the Go CLI (measured 2026-09-02). Implementing the hook still does nothing observable; see Auth events. 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

CapabilityAPI surfaceWhen you reach for it
Register extra graph nodes in lattice enrichment slotsextraGraphNodesIntent detection, plan generation, safety review, quality scoring
Register stream observers on provider seamscontributeStreamObserversPer-chunk inspection, content rewriting, halt on policy violation
Register prompt contributorscontributePrompts, contributeRuntimeAwarePromptsStatic system blocks, runtime-aware sections, retrieval-driven additions
Register safety policiescontributeSafetyPoliciesRefusal-pattern detectors, policy-bound rewriters
Subscribe to lifecycle eventsruntime.events.iterate({chatId, fromSequenceNumber}) + contributeStreamObserversDomain 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 envelopesseam observer emit verdictPipe 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.

The lifecycle vocabulary

Hooks that have a position in a turn are enumerated as data by PLUGIN_LIFECYCLE_EVENTS, with a phase, a dispatch rule, and the PluginManager method that fires each one. Because the dispatcher field is typed keyof PluginManager, an entry describing a hook nothing invokes cannot compile — the vocabulary is prevented from documenting a dark hook rather than merely discouraged from it.

capability.trust is the only hook in the contract that can refuse an operation outright: already-registered plugins decide whether an incoming plugin may join the registry, and any single refusal is decisive. A denial skips just that plugin and is retained on getTrustDenials(); it does not throw, because register() is called from loops that do not catch per plugin.

The peer hook is an extension point, not a security boundary — trust flows forward in registration order and the runtime receives an object rather than a path, so it cannot attest provenance. For a control you rely on, pass capabilityTrustPolicy to the PluginManager constructor (or via SessionRuntimeConfig.capabilityTrustPolicy): a host policy is consulted for every plugin including the first, is order-independent, and denies rather than abstains when it throws. describeTrustPosture() reports whether one is actually in force — absence of a refusal is not evidence a gate ran. See Authoring a HarnessPlugin. Full authoring detail, including the CapabilityTrustContext shape and the gate semantics, is in Authoring a HarnessPlugin.

What a plugin cannot do

These boundaries are enforced by lint, by type system, or by runtime invariant — not by convention.

ForbiddenEnforced by
Add an out-of-lattice edge to the graphaudit:graph-stages (CI gate)
Bypass the singleton synthesize seamSynthesizeSeamHolder + TurnSynthesizeCounter (runtime)
Reach across the seams/ boundary into the modelfamily matrixlint:harness-boundary (CI gate)
Register an async stream-observer dispatch pathType signature is onChunk(chunk, ctx): Verdict, sync only
Use a raw callClass: "..." literal outside a seam factorylint: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.

StageEnrichment slots
anchor-planintentDetection, planGeneration, anchorBuilding
tool-looptoolSelection, toolExecution, dataSilo, jobSilo
synthesizesynthesizerPreamble, citationInjector
post-turnqualityScoring, 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:

VerdictEffect
continuePass through unchanged
amendReplace chunk content 1:1 — strict, no multiplex
emitPass through and emit a named-channel envelope downstream
stopStop 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.

Auth events — vocabulary first

A session's audit trail covered its middle and neither end: no login, no logout, no access denial. @pleach/core now owns the vocabulary for those events and the contract for emitting them — and deliberately does not own the store.

type AuthEventType =
  | "auth.session.started"
  | "auth.session.ended"
  | "auth.session.expired"
  | "auth.credential.issued"
  | "auth.credential.revoked"
  | "auth.access.denied"

type AuthMethodClass =
  | "federated"        // any external IdP — OIDC / SAML / social
  | "api-key"
  | "anonymous-token"
  | "internal-machine"

Why core doesn't pick the store

The C9 hash chain is keyed (tenant_id, chat_id), and an authentication event has no chat by nature. Measured on a production corpus: 4,269 chatless rows, zero of them chained. Writing auth events to the harness event log would produce rows a reader can see and the chain does not protect — worse than no row, because the trail looks complete while its integrity guarantee stops short of exactly the events an auditor examines first.

The host knows which of its tables carry integrity guarantees for a chatless row. Core cannot. Choose a sink whose store is chained or otherwise tamper-evident, and say so in your compliance documentation — an unchained access log is a legitimate choice only if it is a documented one.

Shape rules that are load-bearing

  • The provider is an opaque string, not a union member. AuthMethodClass is the closed vocabulary; the vendor's brand goes in providerId, which core never interprets. A union that grows one member per vendor is how a domain-agnostic package acquires a domain.
  • principalId: null is a measurement, not a placeholder. It means no principal was resolved — an anonymous or rejected request. Never default it. An invented principal is worse than an absent one, because it is attributable to someone.
  • No network or device identifiers. No IP, no user agent, no geolocation. Those carry jurisdictional consequences a domain-agnostic package cannot decide on a deployment's behalf. Attach them in your own sink, under your own retention policy.
  • The sink must not throw, and callers do not await it. The access decision has already been made by the time it runs, so throwing converts an audit gap into an outage. A sink needing durability past process exit must arrange that itself.

isAuthEvent(value) is a cheap structural guard, exported because auth events usually originate in middleware or webhooks outside any typed path — where the type alone guarantees nothing.

`contributeAuthEventSink` has a collector, but no host reads it

PluginManager.collectAuthEventSink() landed 2026-09-02, so the hook is no longer missing a collector — audit:plugin-contract-completeness now counts it as wired. That "wired" is narrower than it sounds: the audit's own definition of a consumer includes a call through the collector, and the only call anywhere in the repo is the facet bridge (runtime.plugins.collectAuthEventSink()) landed in the same commit. No host application code calls that facet property (measured 2026-09-02: zero call sites across the app, every @pleach/* package, and the Go CLI). Implementing the hook today still compiles and does nothing observable. Until a host consumer lands, emit access events into your own sink directly and use the shapes above as the schema.

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

FabricationGuardImpl carries one method per detection signal:

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

EntryStatusDetail
streamHelpersRETIREDBag field deleted; the typed SessionRuntimeConfig.metaToolNames is canonical. Field count on the bag dropped 3 → 2.
toolCouplingRETIRE-READYTyped replacement landed; no novel bag reads remain.
fabricationGuardNEAR-READYTyped 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.

PackageWhat it contributesStatus
@pleach/compliance 0.1.0Scrubbers (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.0EvalSuite + 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.0GatewayClient — 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.0ReplayClient 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.0Concrete 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.0Typed 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.0Vendor-neutral SandboxProvider contract + in-memory fixture. Canonical name SandboxProvider; legacy SandboxAdapter retained as a @deprecated alias.Shipping (contract)
@pleach/observe 0.1.0Destination-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.0Composable 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