pleach
Integrate

Host adapter

`setHarnessModuleLoader` — the dynamic-import seam hosts mid-migration use to wire their own implementations of capabilities the substrate hasn't fully absorbed yet.

new SessionRuntime() constructs with zero config. But runtime.executeMessage(...) reaches back into the host application for capabilities the substrate doesn't ship absorbed yet — streaming utilities, fabrication guards, intent detectors, citation enrichers, tool registries. The seam is setHarnessModuleLoader.

This page is for hosts mid-migration. If you're building a new agent on @pleach/core, prefer the typed config surfaces (provider, tools, plugins, metaToolNames) — the loader seam exists for hosts that grew up with the substrate inside a larger app and are unwinding the integration gradually.

import {
  setHarnessModuleLoader,
  type HarnessModuleKey,
  type HarnessModuleLoader,
} from "@pleach/core/runtime";
Subpath@pleach/core/adaptersSourcesrc/adapters/

What happens without it

Calling executeMessage without registering a loader produces:

HarnessModuleLoaderUnregisteredError: [harness] Module loader is not
registered. Tried to load key="orchestrator.intentDetector".
The host application must call setHarnessModuleLoader(...) at startup …

The error names the first key the runtime actually reaches for on a turn — an early turn-start key like orchestrator.intentDetector, not a later stream-phase key. Wire the loader at startup and the error goes away.

The 30-second contract

import { setHarnessModuleLoader, type HarnessModuleKey } from "@pleach/core/runtime";

setHarnessModuleLoader((key: HarnessModuleKey) => {
  switch (key) {
    case "orchestrator.streamDegenerationGuards":
      return import("@your-app/streamDegenerationGuards");
    case "orchestrator.fabricationGuard":
      return import("@your-app/fabricationGuard");
    case "orchestrator.intentDetector":
      return import("@your-app/intentDetector");
    // ... ~40 more keys ...
    default:
      throw new Error(`Unhandled harness module key: ${key}`);
  }
});

HarnessModuleKey is a string-literal union. TypeScript will tell you when a new key gets added — the default arm catches anything your switch doesn't cover.

The retirement direction

The loader surface is shrinking, not growing. Each @pleach/core release retires keys as the underlying capability moves into the substrate as typed config:

EraCapability sat inToday
0.xLoader seamLoader seam
1.0.0Loader seamLoader seam (R-1 started)
1.1.xLoader seamSome keys retired; rest stable
FutureTyped SessionRuntimeConfig strategies, HarnessPlugin hooksLoader seam minimal residual

Keys retired so far:

Retired keyReplaced by
orchestrator.streamHelpersSessionRuntimeConfig.metaToolNames (typed config)
orchestrator.fabricationGuardsetOrchestratorHotpathModules({ fabricationGuard }) (registered shim)

Re-adding a retired key is a regression. The runtime's substrate test suite catches the regression at build time, not first turn.

The R-tracks

The retirement work is tracked as three named tracks. Hosts can opt into each independently.

TrackScopeWhat lands
R-1AbsorptionCapability moves from loader seam into the substrate, no surface change for consumers
R-2Typed configLoader-resolved value becomes a typed field on SessionRuntimeConfig
R-3Plugin contractLoader-resolved value becomes a HarnessPlugin contribution hook

The reference adapter at examples/host-adapter/reference.ts in the package marks every R-1-retired arm with a RETIRED YYYY-MM-DD (R-1.X, commit <sha>) comment. Hosts cribbing from that file should delete the marked arms rather than wire them.

CreatePleachRuntimeConfig.host.{strategies, modules, raw}

createPleachRuntime() accepts a typed host carveout that groups the host-private substrate dependencies under three named fields. The carveout is the destination loader-bag keys absorb into as they retire — once a key lands here, the loader stops being asked for it on that runtime.

import {
  createPleachRuntime,
  type CreatePleachRuntimeConfig,
} from "@pleach/core";
FieldHolds
host.strategiesHost-private strategy injection slots — the cohort marked @strategy.host-private on SessionRuntimeConfig. Typed as Partial<Pick<SessionRuntimeConfig, ...>> over a 15-key list (see below).
host.modulesOperator-supplied module shims the runtime composes against — eventLogWriter, store, interrupt. These are substrate dependencies, not plugin contributions.
host.rawUncategorized escape hatch. Accepts any Partial<SessionRuntimeConfig>. Use only when neither the typed groups above nor the top-level options expose the field.

The 15 keys host.strategies accepts (from HostExtensionBundle):

orchestratorConfig, lineageTracker, agentRegistry, planManager, learningAuditor, preserveDataRefFields, getDataHandlerFactory, dataChannelRefetch, continuationShadowResolver, entityNameCounter, structurePrefetcher, artifactCacheReader, guestDeniedTools, fabricationDetectorRules, recordGarbledOutput.

The 3 keys host.modules accepts: eventLogWriter, store, interrupt.

const config: CreatePleachRuntimeConfig = {
  plugins: [compliancePlugin, gatewayPlugin],
  host: {
    strategies: {
      lineageTracker: myLineageTracker,
      planManager: myPlanManager,
      guestDeniedTools: GUEST_DENIED,
    },
    modules: {
      eventLogWriter: mySupabaseEventLogWriter,
      store: myMultiNamespaceKV,
    },
    // raw: { ... }  — only when a field is neither classified above nor a top-level option
  },
  userId: "user_123",
};

const runtime = createPleachRuntime(config);

The three fields are independent. A host can fill strategies without touching modules; a host with no operator-supplied module shims can omit modules entirely. raw is the safety valve — prefer strategies/modules when applicable so the autocomplete surface stays meaningful.

Merge precedence

Top-level fields → host.strategieshost.moduleshost.raw → (deprecated) advanced. Higher precedence wins on key collision. advanced?: Partial<SessionRuntimeConfig> stays in the shape for 1.x back-compat with the pre-1.0 surface; it retires in 2.0. New hosts shouldn't reach for advanced.

Bag-entry retirement readiness

A per-bag-entry status report tracks the migration off the orchestratorHotpath bag toward the typed surface + plugin-routed runtime.plugins.collect* cohort. The bag is transitional, not terminal — every entry carries an inline retirement-path comment on the OrchestratorHotpathModules interface declaration.

EntryStatus
streamHelpersRETIRED. Bag field deleted; the lone production reader now calls this._getMetaToolNames() directly. SessionRuntimeConfig.metaToolNames is canonical.
toolCouplingRETIRE-READY. Typed HarnessPlugin.contributeToolCouplingHints is canonical; hosts adopting the plugin-routed path read through runtime.plugins.collectToolCouplingHints(). The bag entry is retained as CLI/raw-path fallback and retires fully once non-host consumers no longer reach it.
fabricationGuardNEAR-READY. Surface widened from 1 → 9 host-side functions (regex tables bake in domain rules); the typed contributeFabricationGuard hook is the canonical path, and the H-3.3 runtime probe recordFabricationGuardResolverPath emits per-invocation via:"bundle"|"bag-fallback"|"unwired" telemetry to drive the 3-batch soak before the bag-fallback retires.

The canonical routing layer is runtime.plugins.collect* — the bag is the legacy fallback. Hosts adopting facets should read through the plugins facet, not the bag. See Facets.

Audit gates enforcing the contract

Four gates run in CI on the upstream core repo. Their job is to catch "we typed the hook but the bag read is still load-bearing" regressions at build time, rather than at first turn in production.

GateEnforces
audit:bag-entries-have-retirement-pathEvery property on OrchestratorHotpathModules must have an inline retirement-path comment (// TRANSITIONAL —, // D-PA-N<digit>, or // retir<inflection>) immediately above it. New entries cannot land without a documented retirement vehicle.
audit:bag-readersInventory of getOrchestratorHotpathModules().{fabricationGuard,toolCoupling} read callsites. G3 routing assertion: any file reading bag.toolCoupling MUST also reference collectToolCouplingHints (plugin-routed canonical path) OR carry a // FALLBACK-OK: <tag> marker. Companion to the entry-side gate.
audit:fabrication-guard-bagSource-text regression locking R-1.C Variant A′ post-audit state. Fails on novel dynamicImportApp("orchestrator.fabricationGuard") call sites beyond the baselined documentation-comment hit at appRegistries.ts.
audit:fabrication-guard-resolver-cleanRuntime soak ledger — accumulates per-canvas-batch counts of the [UXParity:fabrication-guard-resolver-path] probe grouped by via discriminator. :strict fails until the last 3 batches show auth via:"bundle" > 0 AND via:"bag-fallback" == 0. Replaces calendar bake with runtime evidence.

pleach-plugin-modernize codemod

The codemod rewrites the four soft-deprecated bare properties into their typed contribute* method form. It does not migrate to definePleachPlugin — it's a narrower rewrite that produces method-form plugins which definePleachPlugin can then accept.

tools: [a, b]            →  contributeTools: () => [a, b]
events: { foo: ... }     →  contributeEventTypes: () => ({ foo: ... })
customEventTypes: [...]  →  contributeEventTypes: () => [...]
batchingHints: [a, b]    →  contributeBatchingHints: () => [a, b]

The codemod is an internal repo script in the upstream core repo, not a published npm binary:

node scripts/codemods/pleach-plugin-modernize.mjs <glob> [--dry-run] [--json]

It only rewrites bare properties inside object literals containing a name: "<string>" field, so arbitrary objects aren't touched. Collision-safe — if a contribute* method already exists alongside the bare property in the same plugin object, the codemod warns to stderr and skips. customEventTypes folds into contributeEventTypes only when no events / contributeEventTypes already exists in the same literal; otherwise it's left for manual merge.

It's the prep step for the upcoming bare-property cut, after which those forms become type errors. The output composes with definePleachPlugin without further edits.

setOrchestratorHotpathModules

A second registration helper for hot-path modules — capabilities called synchronously inside the per-call seam loop, where dynamic import overhead would matter.

import { setOrchestratorHotpathModules } from "@pleach/core/runtime";

setOrchestratorHotpathModules({
  fabricationGuard: fabricationGuardModule as never,
  // ... other hotpath modules
});

Used for capabilities that are domain-coupled in a way the substrate doesn't want to absorb. The fabrication guard, for example, carries host-specific regex tables (tool aliases, phantom tool replacements, identifier patterns) — registering it synchronously keeps host-domain rules in host code without introducing dynamic-import latency on the per-call path.

metaToolNames (the canonical R-1.A migration)

Hosts that previously resolved CONTINUATION_META_TOOL_NAMES through the loader should now pass them as typed config:

import { SessionRuntime } from "@pleach/core";

const runtime = new SessionRuntime({
  metaToolNames: new Set([
    "set_step_complete",
    "wait_for_jobs",
    // ... your meta-tool names
  ]),
  // ...other config
});

Without the field, the runtime fires a one-shot probe lazily on the first read of the meta-tool set (not at construction) — the _getMetaToolNames() accessor emits it the first time a guard reaches for the set and finds it undefined:

[UXParity:metaToolNames-config-missing] { reason: "metaToolNames-undefined-at-read", ... }

The runtime then falls back to a shared empty set, which silently disables continuation and fabrication guards that key off the set. (setOrchestratorAdapter is unrelated — it's a deprecated delegating stub that forwards to the adapter facet and emits no metaToolNames warning.) Fix by passing the field; never ship past the probe in production.

The reference adapter

The package ships two reference adapter files at examples/host-adapter/:

FilePurpose
index.mjsMinimal no-op adapter that throws a doc-pointer error for every key
reference.tsFull production-shaped adapter with vendor paths neutralized to @your-app/*

Start from index.mjs for a brand-new host — implement one key at a time as you need it. Crib from reference.ts to see the full set of keys a mature integration wires.

What gets called when

Different keys resolve at different turn phases. A rough map:

PhaseKeys resolved
Runtime constructionsupabase, stores.*, registry constants
Turn startorchestrator.intentDetector, orchestrator.fileIntentDetector
Stream phaseorchestrator.streamSingleTurn, orchestrator.streamDegenerationGuards
Tool dispatchorchestrator.tools.* (registry, dataflow, guards, execution)
Synthesisorchestrator.synthesis.*, orchestrator.finalization.finalizeContent
Quality / post-turnorchestrator.quality.evaluator, orchestrator.workflow.workflowHintResolver
Middlewareorchestrator.middleware.creditBudget, orchestrator.middleware.safetyReview, orchestrator.middleware.enrichmentCitations
Provider fallbackorchestrator.providers.circuitBreaker, orchestrator.providers.fallbackExecutor, orchestrator.providers.modelAvailabilityChecker

The runtime does not cache resolved modules itself — dynamicImportApp re-invokes the registered loader on every call. Deduplication comes from the process-wide import() module cache the loader delegates to, so a key resolves cheaply on repeat reaches but the loader still runs each time (scope is process-wide, not per-runtime-lifetime).

When you don't need the loader

For a brand-new host that's not bringing a legacy orchestrator into the substrate:

  • Use the typed provider config field for LLM execution.
  • Use the plugins config field for domain extensions.
  • Use metaToolNames for continuation flow control.
  • Register tools through plugin contributeTools or the legacy setOrchestratorRegistry shim.

You'll still hit some loader-required keys (the substrate is mid- migration), but the minimum set is small. The no-op adapter at examples/host-adapter/index.mjs implements just the keys required for a first-turn execution to land.

Where to go next

On this page