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";@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:
| Era | Capability sat in | Today |
|---|---|---|
0.x | Loader seam | Loader seam |
1.0.0 | Loader seam | Loader seam (R-1 started) |
1.1.x | Loader seam | Some keys retired; rest stable |
| Future | Typed SessionRuntimeConfig strategies, HarnessPlugin hooks | Loader seam minimal residual |
Keys retired so far:
| Retired key | Replaced by |
|---|---|
orchestrator.streamHelpers | SessionRuntimeConfig.metaToolNames (typed config) |
orchestrator.fabricationGuard | setOrchestratorHotpathModules({ 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.
| Track | Scope | What lands |
|---|---|---|
| R-1 | Absorption | Capability moves from loader seam into the substrate, no surface change for consumers |
| R-2 | Typed config | Loader-resolved value becomes a typed field on SessionRuntimeConfig |
| R-3 | Plugin contract | Loader-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";| Field | Holds |
|---|---|
host.strategies | Host-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.modules | Operator-supplied module shims the runtime composes against — eventLogWriter, store, interrupt. These are substrate dependencies, not plugin contributions. |
host.raw | Uncategorized 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.strategies → host.modules →
host.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.
| Entry | Status |
|---|---|
streamHelpers | RETIRED. Bag field deleted; the lone production reader now calls this._getMetaToolNames() directly. SessionRuntimeConfig.metaToolNames is canonical. |
toolCoupling | RETIRE-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. |
fabricationGuard | NEAR-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.
| Gate | Enforces |
|---|---|
audit:bag-entries-have-retirement-path | Every 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-readers | Inventory 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-bag | Source-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-clean | Runtime 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/:
| File | Purpose |
|---|---|
index.mjs | Minimal no-op adapter that throws a doc-pointer error for every key |
reference.ts | Full 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:
| Phase | Keys resolved |
|---|---|
| Runtime construction | supabase, stores.*, registry constants |
| Turn start | orchestrator.intentDetector, orchestrator.fileIntentDetector |
| Stream phase | orchestrator.streamSingleTurn, orchestrator.streamDegenerationGuards |
| Tool dispatch | orchestrator.tools.* (registry, dataflow, guards, execution) |
| Synthesis | orchestrator.synthesis.*, orchestrator.finalization.finalizeContent |
| Quality / post-turn | orchestrator.quality.evaluator, orchestrator.workflow.workflowHintResolver |
| Middleware | orchestrator.middleware.creditBudget, orchestrator.middleware.safetyReview, orchestrator.middleware.enrichmentCitations |
| Provider fallback | orchestrator.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
providerconfig field for LLM execution. - Use the
pluginsconfig field for domain extensions. - Use
metaToolNamesfor continuation flow control. - Register tools through plugin
contributeToolsor the legacysetOrchestratorRegistryshim.
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
MCP integration
Integrate MCP servers from `@pleach/core` today — the `defineTool` wrapper pattern. Distinct from the `@pleach/mcp` SKU (see [/docs/mcp]).
@pleach/transport-azure-openai
First-party Azure OpenAI Service transport for cloud-routed Pleach runtimes — managed-identity credentials, deployment-name routing, and Azure-specific cost-event emission.