SessionRuntime
The main entry point — construct a runtime, create sessions, execute messages, and consume the stream.
A SessionRuntime owns one user's sessions, the per-turn graph,
the stream pipeline, the AuditableCall ledger, and the plugin
set — it's the one class you instantiate from @pleach/core.
Everything else is configuration passed to its constructor or
methods called on the instance. See
Storage for adapter choices and
Stream events for what executeMessage
yields.
SessionRuntime is the implementation surface of the
runtime-lifecycle cluster —
it hosts the three lifecycle concepts (session lifecycle, turn
lifecycle, event log) under one constructor.
import { SessionRuntime } from "@pleach/core";Construction
const runtime = new SessionRuntime(config);All fields are optional. With no config, you get an in-memory
runtime with userId: "anonymous" — fine for a tutorial, never
fine for production.
SessionRuntimeConfig fields
| Field | Type | Default | Purpose |
|---|---|---|---|
storage | StorageAdapter | fresh MemoryAdapter | Where sessions persist. See Storage. |
checkpointer | Checkpointer | none | Where checkpoints persist. See Checkpointing. |
eventLogWriter | HarnessEventWriter | none | Write side of the durable event log. |
eventReader | HarnessEventReader | none | Read side of the durable event log — backs runtime.events.iterate / .fold and resumeSession's Layer-3 hydration. Store-agnostic; the host casts its store into it at construction. See Backing the reader. |
cache | StorageAdapter | none | Optional hot-data cache adapter |
userId | string | "anonymous" | Session owner. Production deployments must pass this. |
organizationId | string | – | Multi-tenant scoping |
clientId | string | generated | Sync version-vector key |
provider | AgentProvider | – | LLM provider implementation |
plugins | HarnessPlugin[] | [] | Domain extensions. See Plugin contract. |
metaToolNames | Set<string> | empty set | Meta-tool names the runtime treats as flow-control (e.g. set_step_complete, wait_for_jobs) |
enabledSafetyPolicies | readonly SafetyPolicyId[] | [] | Operator opt-in for safety policies plugins registered. Canonical read surface is runtime.safety.{listAvailable, getActive, getRegistry}. |
seedCoreDefaults | boolean | true | Auto-seed the 11 core.* prompt fragments |
interrupt | InterruptConfig | – | Human-in-the-loop configuration |
enableSubagentConcurrency | boolean | false | Allow parallel subagents |
maxConcurrentSubagents | number | 4 | Concurrency cap when enabled |
chatId | string | – | Provenance link to an upstream chat row |
isGuest / guestId | boolean / string | – | Guest-mode session ownership |
authToken | string | – | Bearer token forwarded to API calls |
jobDispatchEndpoint | string | – | URL the runtime POSTs queued jobs to |
enabledSafetyPolicies participates in the fingerprint cache key —
toggling a policy invalidates the cache automatically.
metaToolNames is load-bearing after R-1.A. Hosts that don't pass
it see a one-shot [UXParity:metaToolNames-config-missing] probe
plus a startup warning, and continuation guards silently disable.
The continuation guards are the runtime nodes that decide whether a
named flow-control tool (set_step_complete, wait_for_jobs)
signals "this step is done" or "this is just another tool call";
when the guards disable, the tool loop keeps iterating on what
should have been a terminal signal, which is the symptom most hosts
notice before they read the probe.
Strategy injection
SessionRuntimeConfig also carries a second set of fields the
substrate calls strategies — typed slots the host fills with
domain code, each with one well-defined shape and one consumer.
A strategy that's unset gracefully degrades; the runtime stays
functional and the strategy-backed behavior goes inert.
Representative slots include summaryExtractor, toolCatalog,
subagentExecutor, fabricationGuardStrategy,
hallucinationDetectorFactory, and the orchestrator-side
provider-fallback registry. The full surface, the wiring patterns,
and the per-turn vs. per-runtime allocation rule live on
Runtime strategies.
Facets
Each facet returns a typed slice of the runtime — runtime.sessions
for session lifecycle, runtime.events for the event log,
runtime.sync for bidirectional sync, runtime.dev for diagnostics.
Reading a facet gets you the methods grouped under that name and the
typed receipts those methods return.
Runtime-level facet inventory:
| Facet | Covers |
|---|---|
runtime.sessions | Session lifecycle — create, resume, find, save, delete, updateProviderModel |
runtime.sync | Bidirectional sync — execute, resolveConflict, subscribeToStream. Callable: runtime.sync(sessionId) preserves the legacy shape. |
runtime.events | Event-log iteration + fold + the live event bus (on, once, off) |
runtime.spans | Span start / flush / shutdown plus inFlightCount, isShutdown, snapshot introspection |
runtime.tenant | Tenant identity — frozen at construction; identity-stable across reads |
runtime.plugins | PluginManager access plus the full collect* cohort and registry lookups |
runtime.prompts | Prompt-contribution introspection (list, get, getAll, listByOrigin) |
runtime.safety | listActivePolicies, listAvailablePolicies, getRegistry |
runtime.tools | Registered tool catalog introspection |
runtime.observerRouter | Late-binding stream-observer registration; receipts on unregister |
runtime.degradation | Model degradation reads; getDegradedModelRecord returns a receipt |
runtime.checkpoints | rollback, list |
runtime.timeTravel | Time-travel API accessor |
runtime.async | Async-task manager, subagent spawn, result lookup |
runtime.interrupts | Interrupt manager; resolve returns an InterruptResolveReceipt |
runtime.adapter | Orchestrator-adapter wiring + capability registry |
runtime.diagnostics | Boot-time readiness probes for the host-extension bag |
runtime.dev | Diagnostic sub-API — store, stream manager, repetition-guard wiring |
runtime.graph.recovery | Per-turn recovery hooks |
runtime.graph.heuristics | Tunable graph heuristics |
runtime.graph.config | Graph-build configuration |
runtime._internal | INTERNAL graph-consumption surface — substrate-only, do not call |
Receipt-shaped returns
Mutating accessors across sessions, sync, observerRouter,
degradation, and interrupts return typed receipts rather than
booleans or void. The pattern is deliberate: every call site that
mutates runtime state echoes a structured outcome callers can branch
on, audit, or log without re-derivation.
Examples:
runtime.sessions.delete(sessionId)→SessionDeleteReceipt(deleted,sessionId,removedFromCache).runtime.sync.flushOutbox()→OutboxFlushReceiptwith a four-verdictoutcome("all-flushed" | "partial" | "all-stuck" | "none-pending") plusfailedEntries,flushedAt, and the pre-widening counts.runtime.sync.resolveConflicts(...)includes aconflictedPathsarray describing every diverged field (path,localValue,remoteValue,chosenSide).runtime.sync.resolveConflict(...)→SyncConflictResolveReceipt(resolved,sessionId,conflictId,resolution,resolvedAt).runtime.observerRouter.unregister(reg)→{ removed, wasRegistered }.runtime.degradation.getDegradedModelRecord(modelId)→ receipt with the active routing entry ornullwhen none is held.runtime.interrupts.resolve(id, decision)→InterruptResolveReceipt.
Deprecation contract
Existing flat methods stay around with @deprecated JSDoc that
points to the facet. For example, runtime.getTenantId() redirects
readers to runtime.tenant.getTenantId(), and
runtime.getSafetyRegistry() redirects to runtime.safety.getRegistry().
The minor release that adds the facet also marks the flat method
@deprecated; the next major removes the flat method. Flat methods
are never removed in a minor bump.
For the full facet inventory, the TurnOrchestrator.* facet set,
and the audit gates that enforce coverage, see Facets.
Creating a session
const session = await runtime.createSession({
provider: { type: "anthropic" },
model: { id: "claude-sonnet-4-20250514" },
tools: { enabled: ["search", "calculator"] },
});createSession accepts a Partial<SessionConfig>. Defaults:
| Config field | Default |
|---|---|
provider | { type: "anthropic" } |
model | { id: "claude-sonnet-4-20250514" } |
tools | { enabled: [] } |
projectId / campaignId | inherited from runtime if set |
Returns a Session instance bound to a generated UUIDv7 id —
session.id is what executeMessage takes. The session is
written to storage before this resolves.
Executing a message
executeMessage is an async generator. Iterating it streams the
turn; every yielded value is a StreamEvent.
for await (const event of runtime.executeMessage(session.id, "Hello")) {
switch (event.type) {
case "message.delta":
process.stdout.write(event.delta);
break;
case "tool.completed":
console.log("tool finished:", event.toolCall.name);
break;
case "error":
console.error(event.code, event.error);
break;
}
}Signature
runtime.executeMessage(
sessionId: string,
content: string,
options?: {
fileReferences?: unknown[];
abortSignal?: AbortSignal;
},
): AsyncGenerator<StreamEvent>;Aborting a turn
Pass an AbortSignal. The runtime cancels in-flight provider calls,
unwinds the tool loop cleanly, and flushes a final ledger row with
outcome.status: "user-aborted". The user-aborted row carries the same
(sessionId, turnId, stageId, seqWithinTurn) identity tuple as a
successful row, which is what lets a per-turn cost rollup count an
abort against the same turnId as the calls that ran before it —
the aborted call still consumed input tokens through the provider's
prefill, and the row exists so that spend isn't invisible.
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 30_000);
for await (const event of runtime.executeMessage(
session.id,
prompt,
{ abortSignal: ctrl.signal },
)) {
// ...
}Deleting a session
await runtime.deleteSession(session.id);Cascades into the checkpointer when one is wired. Audit-ledger rows
are not deleted — the ledger is append-only by contract, which is
why ProviderDecisionLedger has no update or delete primitive
in any conforming language. The GDPRSoftDelete plug-point on the
ledger interface (no-op default; production wiring lands in
@pleach/compliance@0.1.0 alongside its scrubber cohort
and attestation surface) is the path for subject-key-derived redaction:
it leaves the recordId in place (so the hash chain holds) and
clears the identifying fields under the subject key, satisfying a
deletion request without breaking the append-only invariant. Wire
your own implementation today via HarnessPlugin.
Flat method surface (legacy)
The tables below mirror the facets above as flat methods on the runtime; the methods are @deprecated and forward to their facet equivalents — prefer Facets for new code.
Session lifecycle methods
| Method | Signature | Use |
|---|---|---|
createSession | (config?: Partial<SessionConfig>) => Promise<Session> | Mint a new session, persist to storage |
resumeSession | (sessionId: string) => Promise<Session> | Load an existing session from storage |
saveSession | (state: SessionState) => Promise<void> | Flush an explicit state mutation to storage |
deleteSession | (sessionId: string) => Promise<SessionDeleteReceipt> | Remove the session; cascades to checkpointer. Returns a receipt with deleted, sessionId, and removedFromCache. |
getActiveSession | () => Session | null | Last session loaded into the runtime |
rollbackToCheckpoint | (sessionId, checkpointId) => Promise<Session> | Restore a session from a named checkpoint |
resolveConflict | (sessionId, conflictId, "local" | "remote") => Promise<SyncConflictResolveReceipt> | Resolve a sync conflict surfaced via sync.conflict. Returns a per-path receipt. |
destroy | () => Promise<void> | Tear down stream + interrupt managers, unsubscribe listeners |
Stream subscription
Two ways to consume events. executeMessage is the per-turn async
generator above. For cross-cutting subscribers, the runtime also
exposes a mode-filtered subscription via its embedded StreamManager.
const unsubscribe = runtime.subscribeToStream("messages", (event) => {
// event is StreamEvent — already filtered to the mode
});| Mode | Includes |
|---|---|
"values" | session.created, session.resumed, checkpoint.created |
"updates" | message.added, message.complete, tool lifecycle, job lifecycle, artifact.created |
"messages" | message.delta, message.complete, thinking.delta, thinking.complete |
"custom" | Any event whose type starts with custom. |
"debug" | All events — unfiltered |
"all" | All events — unfiltered |
getStreamManager() returns the underlying manager when you need to
set a throttle or an event-type allowlist directly:
manager.setThrottleInterval(ms), manager.setEventFilter(types).
Accessor methods
The runtime exposes its internal coordinators behind named getters.
Returns are undefined when the coordinator wasn't wired at
construction.
| Method | Returns |
|---|---|
getInterruptManager() | InterruptManager | undefined — present when interrupt.enabled: true |
resolveInterrupt(id, decision) | InterruptResolveReceipt — submit an ApprovalDecision and receive a receipt echoing the outcome |
getTimeTravelApi() | Checkpoint list / restore API (see Checkpointing) |
getAsyncTaskManager() | Background job manager (see Stream events) |
getPluginManager() | Registered plugin set |
getStreamManager() | StreamManager — throttle + filter the live stream |
getStore() | The storage adapter passed at construction |
getSubagentManager() | SubagentManager | undefined when concurrency is enabled |
getCapabilityRegistry() | Registered capability strategies |
getSafetyRegistry() | SafetyPolicyRegistry |
listActiveSafetyPolicies() | readonly SafetyPolicySummary[] |
getDegradedModels() | Models the degradation tracker is routing around |
isModelDegraded(modelId) | Boolean check for a single model |
getPlanningContext() | Current planning context (advanced) |
The published .d.ts is the canonical signature reference; your IDE
autocompletes from it.
Lifecycle events
SessionRuntime extends EventEmitter. The same shapes that come
out of executeMessage also fire as events, so a long-lived
runtime can attach cross-cutting subscribers without owning the
async iterator:
runtime.on("checkpoint.created", (event) => {
metrics.increment("checkpoints", { sessionId: event.checkpoint.sessionId });
});The full event union is the StreamEvent type — see
Stream events for the catalog.
Plugin contributions
runtime.plugins is the canonical read surface for everything a
plugin contributed. Beyond PluginManager access, the facet exposes
a collect* cohort that mirrors PluginManager.collect* one-for-one
— roughly two dozen accessors covering retry policy, continuation
policy, fabrication detectors, finalization passes, intent
classifiers, tool-coupling hints, intent-tool maps, sandbox bridge,
interrupt UI handlers, citation rules, manifest providers, fabrication
guard, guest-denied tools, stream observers, hallucinated-tool
detectors, stream-chunk handlers, middleware, and runtime-aware
middleware. The full hook list lives on
Plugin contract.
const retryPolicies = runtime.plugins.collectRetryPolicy();
const continuationPolicies = runtime.plugins.collectContinuationPolicy();
const detectors = runtime.plugins.collectFabricationDetectors(sessionId);Two convenience accessors round out the facet:
runtime.plugins.listAvailablePromptContributions()— every prompt contribution every plugin registered, as a snapshot array.runtime.plugins.getSafetyRegistry()— the activeSafetyPolicyRegistry. Equivalent toruntime.safety.getRegistry().
Collection-shaped accessors return [] when no plugin contributed;
capability-shaped accessors return null. Empty results never throw.
Event-log projections
runtime.events.iterate(...) returns a paginated async-iterable over
harness_event_log rows ordered by (chat_id, sequence_number, created_at, id). The accessor reads the durable Supabase log
server-side; PA-1 Phase A.2 promoted this from a stub to a real
projection surface.
for await (const row of runtime.events.iterate({ chatId })) {
// EventLogRow — id, session_id, event_type, payload, sequence_number, …
}
const result = await runtime.events.fold(myProjection, { chatId });
// result.state, result.rowsProcessed, result.projectionruntime.events.fold(projection, options?) composes a
GraphProjection<T> over the iteration. options.fromSequenceNumber
skips at the SQL layer — rows with sequence_number IS NULL (pre-PA-1
legacy) are always included for back-compat.
See Event-log projections for the projection contract and the canonical bundled projections.
The live event bus (runtime.events.on, once, off) is co-located
on the same facet but is unrelated to the persisted log — ordering
between the two is not guaranteed; treat the bus as a hot stream.
Span introspection
runtime.spans exposes the OTel-shaped exporter the runtime threads
through every turn:
runtime.spans.start({ name: "host.custom", kind: "host.custom" });
await runtime.spans.flush(); // call from a waitUntil tail
runtime.spans.inFlightCount(); // 0 when the exporter doesn't track
runtime.spans.isShutdown(); // false when the exporter doesn't track
runtime.spans.snapshot(); // { inFlightCount, isShutdown } in one readinFlightCount, isShutdown, and snapshot are introspection
methods backed by optional getInFlightCount?() / isShutdown?()
hooks on the exporter contract. When a host-supplied exporter doesn't
implement them, the facet returns 0 / false — documented as
"unknown — assume zero / false". The default NoopOtelExporter and
the reference CapturingOtelExporter both track accurately.
cacheBackend default-policy promotion
memoryCacheBackend is the substrate default for cacheBackend. The
runtime constructor default-constructs one when config.cacheBackend
is undefined; the seam factories receive the backend automatically
via createSeam.ts. Previously the field had to be configured
explicitly; today it is always-on with a default cap of 1000 entries
and 64 MB.
The change is observable in runtime.cache?.metricsSnapshot() on day
zero. No consumer action is required.
To override, pass a custom cacheBackend field on
SessionRuntimeConfig. Set it to null to disable caching entirely.
For the contract, the two read modes, and the 4-gap fingerprint thread, see Cache.
Event-log hash chain
The persistence layer can stamp a per-chat hash chain onto every
harness_event_log row. The wire-in lives on EventLogWriter and is
gated by the c9PhaseBEnabled flag; when on, the writer maintains a
prevHashCache keyed by chat_id, links each new row to the previous
row_hash, and surfaces verifyChainForChat(...) and
generateProof(...) from @pleach/core/eventLog for downstream
audit. Hosts that don't care about the chain leave the flag off and
the writer behaves exactly as before.
Where to go next
Stream events
Every event type executeMessage can yield, with the payload shape.
Storage
MemoryAdapter, IndexedDBAdapter, SupabaseAdapter — pick one per environment.
Checkpointing
Snapshot, restore, time-travel — the per-channel checkpoint API.
React
HarnessProvider, useHarness, and the hook surface for browser apps.
Building chat UI
From <ChatBox /> for the MVP to the @pleach/react primitives for the middle ground to assistant-ui or CopilotKit for design-system-grade chat. Three layers, one substrate.
Providers
Wire an LLM provider into the runtime — Vercel AI SDK, Anthropic SDK, or your own — through the `AgentProvider` interface.