# Agent application runtime

> Source: https://github.com/max-listov/stitchkit/blob/master/docs/guide/agent-runtime.md

---

# Agent application runtime

> **Maturity: evolving.** This surface is still finding its shape and may be
> redefined in any minor release — always with a `### ⚠️ Breaking changes` entry
> and a migration section, never silently. If your application already owns the
> conversation loop, `mountAgent` from `stitchkit/tools` is the stable path.

`stitchkit/agent-runtime` is the server-only, opinionated layer above
`mountAgent`. Use it when the application wants Stitchkit to own conversation
mechanics: durable acceptance, history projection, the AI SDK stream loop,
checkpoints, keyed interruption, terminal commit and stable application events.

If the application already owns that loop, continue importing `mountAgent` from
`stitchkit/tools`. `stitchkit/tools` does not depend on this entrypoint at all;
this entrypoint uses the tool executor from it, but pulls no MCP peer, so
choosing `mountAgent` alone costs you nothing from here.

UI and shared DTO code must import canonical records and delivery validation from
`stitchkit/agent-runtime/browser`, not from the server runtime barrel:

```ts
import {
  AgentRunSchema,
  AgentRuntimeEventSchema,
  advanceAgentRuntimeEventCursor,
} from 'stitchkit/agent-runtime/browser'
```

This browser-safe entrypoint re-exports the same schemas and inferred types used
by the runtime. It intentionally excludes model construction, execution,
persistence, event sinks and every Node context dependency.

## Install

```sh
bun add stitchkit ai zod
```

`mountAgent` from `stitchkit/tools` — used in the composition below — additionally
needs the MCP peer, which that entrypoint imports statically:

```sh
bun add @modelcontextprotocol/server
```

OpenRouter is isolated so other runtime users do not resolve its package:

```sh
bun add @openrouter/ai-sdk-provider
```

## Minimal composition

```ts
import { z } from 'zod'
import {
  AgentContextOverflowError,
  composeAgentPrompt,
  createAgentRuntime,
  createMemoryAgentRuntimeStore,
  defineAgentProtocol,
  defineModelRegistry,
} from 'stitchkit/agent-runtime'
import { openRouterProvider } from 'stitchkit/agent-runtime/openrouter'
import { composeToolLifecycle, mountAgent } from 'stitchkit/tools'

const protocol = defineAgentProtocol({
  context: z.object({ userId: z.string() }),
  inputMetadata: z.object({}),
})

const models = defineModelRegistry({
  providers: {
    openrouter: openRouterProvider({ apiKey: env.OPENROUTER_API_KEY }),
  },
  models: {
    fast: {
      provider: 'openrouter',
      modelId: 'provider/model',
      contextWindow: 128_000,
      capabilities: ['tools'],
    },
  },
})

const prompt = composeAgentPrompt<{ userId: string }>([
  {
    name: 'product',
    stability: 'stable',
    render: ({ context }) => `Help user ${context.userId}.`,
  },
])

const runtime = createAgentRuntime({
  protocol,
  store: createMemoryAgentRuntimeStore(),
  models: {
    preflight: () => models.preflight('fast', ['tools']),
    resolve: () => models.resolve('fast', ['tools']),
  },
  prompt: ({ context, signal, model }) =>
    prompt({
      context,
      signal,
      budget: {
        contextWindow: model.descriptor.contextWindow,
        reservedOutput: 8_000,
        toolSchemas: { provenance: 'unavailable' },
        attachments: { value: 0, provenance: 'measured' },
        providerOverhead: { provenance: 'unavailable' },
      },
    }),
  tools: ({ context, toolFenceLifecycle }) =>
    mountAgent(service, {
      context,
      lifecycle: composeToolLifecycle(authLifecycle, toolFenceLifecycle),
    }),
  runs: { inputPolicy: 'interrupt', coalescePending: true },
  loop: {
    idleTimeoutMs: 60_000,
    prepareStep: ({ context, steps }) => ({
      activeTools: steps.some((step) => step.toolCalls.length > 0)
        ? ['lookup']
        : ['lookup', 'discover'],
      instructions: `Current account: ${context.userId}`,
    }),
    stopPolicies: [
      {
        name: 'repeated-tool-error',
        when: ({ steps }) => hasRepeatedToolError(steps),
      },
    ],
  },
})

const ticket = runtime.submit({
  conversationId: 'conversation-id',
  idempotencyKey: 'request-id',
  context: { userId: 'user-id' },
  parts: [{ type: 'text', text: 'Hello' }],
  recordIds: {
    inputMessageId: 'user-message-id',
    runId: 'run-id',
    assistantMessageId: 'assistant-message-id',
  },
})

const admission = await ticket.admission
await ticket.accepted
const terminal = await ticket.result
```

## Bounded deferred tool catalogs

Use `createDeferredAgentToolSurface` when a measured large catalog makes
sending every schema on every provider step expensive. The controller mounts
the complete executable surface in process, but initially advertises only its
search tool, explicit `alwaysOn` tools and bounded policy pins:

```ts
import { createDeferredAgentToolSurface } from 'stitchkit/agent-runtime'
import { composeToolLifecycle } from 'stitchkit/tools'

const deferred = createDeferredAgentToolSurface({
  surfaces: {
    member: { services: memberServices, runtimeTools, alwaysOn: ['ask_user'] },
    broadcast: { services: broadcastServices, runtimeTools },
  },
  selectSurface: ({ context }) => context.mode,
  pins: ({ context }) => context.skillTools,
  search: {
    name: 'tool_search',
    maxQueryBytes: 1_024,
    maxResults: 8,
    maxResultBytes: 8_192,
    select: ({ query, manifest }) => rankCatalog(query, manifest),
  },
  activation: {
    maxSelectedTools: 8,
    maxActiveTools: 16,
    maxSchemaBytes: 32_768,
  },
  observe: event => recordDeferredToolEvidence(event),
})

const runtime = createAgentRuntime({
  // protocol, store, models and prompt...
  tools: runContext => deferred.mount(runContext, {
    context: runContext.context,
    lifecycle: composeToolLifecycle(authLifecycle, runContext.toolFenceLifecycle),
  }),
  loop: { prepareStep: deferred.prepareStep(applicationPrepareStep) },
})
```

The built-in selector deterministically ranks exact name, prefix/token and
description matches. A custom async selector returns names only; unknown and
duplicate output is removed against the local canonical manifest. Search never
executes the selected operation and never returns its schema. A later provider
step receives the real selected tools by name, so their direct lifecycle,
fencing, hooks, errors and `present.agent` output remain unchanged.

Selection is a versioned receipt in the ordinary durable tool-result history.
The latest valid same-run search replaces the earlier selection; parallel
searches in one step merge in call order. Recovery reconstructs it, while a
different run or finite surface cannot inherit it. Known inactive calls take a
recoverable `SEARCH_REQUIRED` search round; unknown names still fail.

Every ceiling is required. `maxSchemaBytes` measures UTF-8 bytes over canonical
name, description and presentation schema. It is useful for before/after
comparison, but provider serialization and tokenization differ: use existing
provider-reported input usage and cost to decide whether the extra search round
is beneficial. Controller evidence intentionally omits query text, prompts,
arguments and application context. → ADR 0129.

### Published headless harness and structured runner

`stitchkit/agent-runtime/harness` publishes the resource-aware composition over
`createAgentRuntime`. `createHeadlessAgentHarness` accepts the existing protocol and store,
caller-provided model resolution, bounded instruction/skill/resource loading, direct tools and a
prompt-budget callback. Resource names are unique; count, UTF-8 bytes and diagnostics are bounded.
Invalid resources fail before a provider request. Diagnostics are evidence for the caller: their
observer is isolated, and the loader decides whether a reported condition is fatal.

`createAgentHarnessFileResources` is the optional filesystem implementation. It reads only
caller-declared absolute roots, refuses symlinks and reports provenance as
`rootId:relative/path`. Instructions enter the prompt immediately. Skills and ordinary resources
enter as bounded name/description summaries; `read_resource` loads one exact body later as
a normal direct typed tool. Custom loaders remain supported unchanged.

Each run may resolve a different provider adapter without changing the harness. The
`profile-applied` observation records the actual `AgentModelDescriptor`, resource kind/name/
provenance and sorted direct tool names; it intentionally contains no resource text, prompts,
arguments or credentials. Observer failure never changes the canonical run outcome.

[`packages/core/examples/agent-runtime/headless-agent-runner.ts`](../../packages/core/examples/agent-runtime/headless-agent-runner.ts)
is a reference structured control loop for `submit`, `interrupt`, `snapshot` and bounded `close`.
It starts no process and chooses no framing, authentication or transport. Importing either example
has no side effect; an external supervisor owns process placement and restart.

| Concern | Public Stitchkit composition | Boundary |
| --- | --- | --- |
| execution loop | `createAgentRuntime` | already available; do not copy a second loop |
| model choice | `defineModelRegistry` and `models.resolve` | provider credentials/discovery stay application-owned |
| resources | `createHeadlessAgentHarness` → `composeAgentPrompt` | paths, trust, precedence and watching stay application-owned |
| tools | `mountAgent` + `createAgentToolFenceLifecycle` | auth and domain effects stay application-owned |
| follow-up | `runs.inputPolicy: 'queue'` | durable FIFO after the current run |
| interrupt | `interrupt` or `interrupt-next` | `interrupt-next` terminates the active run and gives the new durable admission next priority |
| recovery | `runtime.recover` + `scanRecoverable` | replay safety and context reconstruction are explicit callbacks |
| reconnect | canonical snapshot + `advanceAgentMultiSessionCursor` | conversation/run-scoped gaps require resync |
| persistence | memory reference, public driver, or SQLite leaf | product rows/outbox remain outside the runtime store |
| executable lifecycle | structured runner example | framing, auth, process placement, restart and OS isolation stay host-owned |

`createAgentHarnessControlServer` adds transport-neutral correlated requests, shared observers and
one exclusive controller lease per conversation. `close()` on a connection detaches only that
client; it never closes the harness. Normal `event` delivery stays serialized. A bounded slow
consumer is detached and receives `resync-required` through the required synchronous
`onOverflow` callback; that callback closes/aborts the underlying transport, which then reconnects
and requests a fresh snapshot. It never writes a second frame concurrently with a blocked
`deliver`. Browser code uses `createAgentControlView`,
`reduceAgentControlSnapshot` and `reduceAgentControlEvent`; the reducer owns no renderer and marks a
conversation `resyncRequired` after a transient sequence gap.

Configure `loop.toolApproval` to use the installed AI SDK approval policy and
`loop.toolApprovalSecret` to sign exact requests. A request is stored on the terminal assistant
before any effect. `pendingApprovals()` derives unresolved requests from canonical messages;
`respondToApproval()` writes one tool-role approval response and queues a successor. The SDK
revalidates the signature/tool call/input before the original direct tool reaches its existing
fence. Reconnect and SQLite reopen require no promise registry. Remembered policy and a stronger
cross-crash exactly-once guarantee remain application concerns.

Approval requests suspend a call; they do not settle its result. History carries exact call and
approval identity across assistant/tool records, so an approved result can precede the next signed
request in a successor run. Automatic decisions follow the same chronology. Unknown/duplicate
responses and results with a different call or tool name are invalid; dropping an invalid active
approval input fails the run with a private diagnostic rather than starting a fresh model turn.

`stitchkit/agent-runtime/coding-tools` returns ordinary direct runtime tools named `read_file`,
`write_file`, `edit_file`, `list_directory`, `glob`, `search_files`, `run_command` and optional
`read_output`. Every call passes a required host authorization callback. An optional async/sync
`authorizePath({ path })` is the one operation-independent policy for direct file access and
discovery: direct read/write/edit refuses a denied path, while listing, glob and search omit it.
Denial is recursive on every surface — direct access and discovery base paths ask about `.` and each
ancestor, outermost first, stopping at the first refusal, so denying `credentials` denies everything
under it. The comparison is segment-wise, so it does not deny `credentials-backup`, and a path of
depth N costs N+1 questions. Directory decisions happen before descent; file decisions and
`search_files.include` happen before opening content. Requested paths use `/`: a backslash is
refused rather than normalised, because the workspace walk reads it as a separator and every
authorization callback would otherwise be asked about a different decomposition than the one opened. File paths are relative, bounded and contained after
descriptor-relative resolution: each ancestor is opened without following symlinks and remains
pinned through authorization and the filesystem effect. Reads revalidate the pinned file identity;
writes and patches revalidate the pinned parent identity; search and resource discovery descend
only through opened directories. Linux traverses `/proc/self/fd`; macOS uses the package's
architecture-specific Node-API `openat` backend. Unsupported platforms, including FreeBSD until a
real backend is qualified, fail closed. The package needs no install-time compiler or script.
Other platforms fail these filesystem operations closed because Node exposes no equivalent
portable directory-handle-relative API. Writes/edits reject symlink targets, content is strict
UTF-8 and retained bytes are finite. Shell accepts a finite alias mapped by the host to an absolute executable plus an
argument array — never a shell command string — and uses only the explicitly supplied environment.
Arguments, output and time are bounded, while cancellation terminates the child. The configured
root and cwd are path boundaries, not a security sandbox: isolate the process when an executable
must not access the rest of the machine. Path admission does not constrain `run_command`; an
executable requires process isolation for that guarantee. → ADR 0172

An absolute path is still refused. When it is inside the canonical workspace root, the refusal
includes the exact workspace-relative `recoveryPath`; an absolute path outside the root has no
suggested replacement. Applying the suggestion still passes the same descriptor-relative
containment and symlink checks.

`edit_file` replaces one exact snippet. It inserts `newText` literally in both single and
`replaceAll` modes, including JavaScript replacement tokens such as `$$`, `$&`, `` $` `` and `$'`.
`oldText` is itself the freshness guard for the region it changes, so the digest is the optional `expectedSha256` and an edit is one call; pass the digest
`read_file` returned when you want the whole-file guarantee, and read the `sha256` it returns to
chain the next edit without re-reading. The read, the occurrence count and the construction of the
new content all happen inside one per-target lock, so two concurrent edits of different snippets in
one file cannot each build a file from the same base and have the second erase the first — the lock
is process-local, which covers many agents in one process and not two processes over one workspace.
It authorizes the exact replacement count, result digest and byte size, and deliberately does not
claim multi-file atomicity.

`write_file` creates missing parent directories inside the root and names them in
`createdDirectories`; the walk that finds them runs before authorization and reports them in the
authorization payload, so no directory is created before a host approves it.

**Every ordinary outcome is a refusal a model can act on** — a missing file is `NOT_FOUND`, an
existing file without `overwrite` is `CONFLICT`, an ambiguous snippet is `CONFLICT` carrying its
occurrence count, a path outside the root is `FORBIDDEN`, an unknown spilled-output reference is
`SPILL_REFERENCE_UNKNOWN` — each with a `hint` naming the next move.
Host-level causes stay scrubbed to `INTERNAL_SERVER_ERROR` and name nothing outside the workspace.
Every refusal code is a member of `STITCH_ERROR_STATUS`, so its status survives an envelope that
crosses a process boundary and is rebuilt from the wire.
→ ADR 0139

`list_directory` marks excluded directories rather than hiding them, and `glob` reports
`skippedDirectories` beside its matches: an empty result from a tree whose files all live under an
excluded directory is not "no files", and a model told only "nothing found" concludes the wrong
thing. `search_files` takes `regex`, `context` lines and an anchored `include` pattern matched
against the whole workspace-relative path. `*` does not cross `/`, so files in subdirectories need
`**/`; `scannedFiles` counts files admitted by both that filter and host authorization. A `hint`
explains the zero only when the filter produced it, rejecting every file the scan reached; an empty
tree and a host refusal return the same empty result without blaming the pattern. Regex is bounded by
refusing backreferences and lookaround and capping line length rather than by a timeout, because a
JavaScript `RegExp` cannot be interrupted once it starts backtracking. With an optional `AgentCodingArtifactStore`, shell output beyond the inline
preview continues into an opaque bounded artifact and `read_output` reads slices without
exposing a host path. Without a store, the previous finite output-limit behavior is unchanged.

`run_command` treats every invocation as finite. `shellTimeoutMs` bounds normal execution and
`shellTerminationGraceMs` bounds settlement after timeout, caller cancellation, output overflow or
parent exit. POSIX hosts launch a dedicated process group and kill every descendant remaining in
that group; a process that deliberately escapes the group is outside the guarantee. Node's
portable Windows child-process API cannot kill a complete descendant tree without invoking a
second host executable, so Windows kills the direct child, destroys retained pipes at the grace
deadline and makes no descendant-cleanup claim. A signal already aborted before execution returns
`cancelled` without spawning.

Coding commands compose the shared native execution owner. Artifact retention and UTF-8
previews remain coding-tool policy; normal leader exit still forcibly stops remaining POSIX
group members before draining. A structural sandbox launcher uses the same settlement owner
with a finite output queue. Sandbox shutdown waits for actual owned completion and surfaces
cleanup refusal rather than treating it as successful teardown.

One `createAgentHarnessFileResources` instance represents one immutable discovery generation:
concurrent and repeated `load()` calls share it, so a direct resource read cannot cross into a
new catalog unexpectedly. Discovery uses the same descriptor-anchored traversal as coding search;
construct a new loader to refresh after filesystem changes.

Runtime events preserve reasoning/text/tool lifecycle order. Durable admission,
checkpoint, run-state and terminal events carry stable identities; transient
deltas carry one runtime epoch and monotonic sequence. On a transient gap,
reload the canonical snapshot and resume from the durable record instead of
persisting deltas as a second history.

Prompt `contextWindow` budgeting is a pre-request decision over the assembled
context. `AgentRun.usage` is cumulative measured/provider-reported execution
evidence across attempts and compaction. They answer different questions and
must not be substituted for each other. TTFT and throughput require timestamps
from the actual provider stream plus a provider-reported or tokenizer-measured
token count; character counts cannot produce exact token or throughput figures.

`recordIds` is optional. Supply stable application record IDs when an accepted-response transport must
return durable placeholders before the run finishes. `ticket.admission` resolves after the store
acceptance CAS and reports the canonical committed `input`, assigned `run`, a typed `pending`
assistant projection, compatibility IDs and snapshot version.
Those assigned IDs can differ from the proposal when an input coalesces into an existing queued
successor. Reuse the same `inputMessageId` for retries carrying the same idempotency key; input
identity is caller-stable, while the receipt reports the run/assistant identities that assignment
may change. Await `admission` first on the immediate accepted-response path. `ticket.accepted`
remains the signal-only compatibility surface and additionally waits for admission publication.

The in-memory store is a reference adapter and has process-local durability
only. Production applications normally call `createAgentRuntimeStore()` and
provide one database transaction driver:

```ts
const store = createAgentRuntimeStore({
  transaction: (work, options) =>
    options?.access === 'read'
      ? db.readTransaction(tx => work(tx))
      : db.writeTransaction(tx => work(tx)),
  head: {
    load: (tx, conversationId) => loadRuntimeHead(tx, conversationId),
    compareAndSwap: (tx, operation) => casRuntimeHead(tx, operation),
  },
  runs: {
    load: (tx, identity) => loadRun(tx, identity),
    loadByAssistantMessageId: (tx, identity) => loadRunByAssistant(tx, identity),
    loadMany: (tx, identities) => loadRuns(tx, identities),
    listActive: (tx, conversationId) => listActiveRuns(tx, conversationId),
    save: (tx, record) => saveRun(tx, record),
  },
  admissions: {
    load: (tx, identity) => loadAdmission(tx, identity),
    loadByInputMessageId: (tx, identity) => loadAdmissionByInput(tx, identity),
    create: (tx, receipt) => createAdmission(tx, receipt),
  },
  history: {
    load: (tx, conversationId) => loadCanonicalMessages(tx, conversationId),
    apply: (tx, mutation) => applyCanonicalHistoryMutation(tx, mutation),
  },
  scanRecoverable: page => scanRecoverableRuns(page),
})
```

Prompt budgeting keeps reservation deficits signed. If instructions,
`reservedOutput`, tool schemas, attachments and provider overhead already exceed
`contextWindow`, `composeAgentPrompt` returns `contextDecision: 'oversized'`
and a negative `availableHistoryTokens` even when history is empty. A `compact`
policy is returned only when removing history could actually make the prompt
fit. Exact equality (including a zero window with zero reservations/history)
fits; any unavailable component keeps the decision `unavailable`.

The same opaque `tx` reaches head, run, admission and history callbacks. The adapter maps rows
and supplies atomicity; Stitchkit owns transition validation and revision
arithmetic. The executable reference is
[`examples/agent-store-prisma/adapter.ts`](../../examples/agent-store-prisma/adapter.ts).
`compareAndSwap` returns either `{ outcome: 'applied' }` or
`{ outcome: 'conflict', actualVersion }`. Decide that outcome by reading the
version, comparing it and then writing unconditionally — inside the transaction
the adapter supplies, that is atomic. A conditional upsert is the shorter thing
to write and it is wrong twice: `ON CONFLICT ... WHERE` guards the update branch
only, so the first write to a conversation applies whatever `expectedVersion` it
was given, and the affected-row count that would report the outcome is the
driver's, which need not be the rows the statement moved. The head contains only
schema version, conversation identity and monotonic version. Runs and admission receipts are normalized records;
recovery queries active run states directly instead of maintaining a second projection.
An admission receipt retains its canonical input, and a terminal run retains its canonical
assistant, so physical product-history compaction cannot break idempotent retries.

### Built-in SQLite persistence

Use the runtime-specific leaf; the neutral agent runtime never imports either
SQLite built-in:

```ts
// Bun
import { createBunSqliteAgentRuntimeStore } from 'stitchkit/agent-runtime/sqlite/bun'

const sqlite = createBunSqliteAgentRuntimeStore({ filename: './agent-runtime.sqlite' })
const runtime = createAgentRuntime({ ...config, store: sqlite.store })

await runtime.close()
await sqlite.close()
```

```ts
// Node 22.5+
import { createNodeSqliteAgentRuntimeStore } from 'stitchkit/agent-runtime/sqlite/node'

const sqlite = createNodeSqliteAgentRuntimeStore({ filename: './agent-runtime.sqlite' })
```

Initialization creates only `stitchkit_agent_runtime_*` tables and records
schema version 2 in `stitchkit_agent_runtime_meta`; it does not use
`PRAGMA user_version` or mutate application tables. An unknown schema version or
unversioned partial Stitchkit schema is refused. The connection is owned by the
returned handle and closes only after accepted operations drain.

Each handle serializes its transactions. Separate connections use
`busy_timeout = 0`: a competing synchronous writer fails promptly with
SQLite's lock error, so an awaited transaction can resume and commit or roll
back. Retry that explicit error with an application-owned bounded policy; do
not configure a blocking busy timeout on the same JavaScript thread.

Aggregate reads and catalog pages use `BEGIN`, so a WAL writer may remain active
while the reader sees one committed snapshot. Mutations and purge use
`BEGIN IMMEDIATE`: they reserve the writer slot before reading state for CAS and
never attempt a deferred read-to-write upgrade. A custom driver may inspect the
additive `transaction` option `{ access: 'read' }`; ignoring it preserves the
previous write-safe behavior.

The adapter proves canonical durability across reopen and process restart. It
does not make external tool effects exactly once, join application projections
atomically or replace an application outbox. If a product row must commit with
an agent transition, implement `AgentRuntimeStoreDriver` over the application's
own transaction boundary instead.

Opening a version 1 store migrates it in one transaction, forward only — back
the file up first, a 0.85.x package refuses a migrated file. Existing
normalized state becomes one `runtime/baseline` event per conversation, dated
when the migration ran and carrying `asOf` for the last message it describes;
search and projections address that pre-migration history as one snapshot at
`seq 1`. New transitions continue the monotonic conversation sequence. Version
2 requires SQLite FTS5 because event search is an advertised capability, so a
build without FTS5 is a startup error (`requires FTS5 support`) rather than an
empty search result; check with
`SELECT sqlite_compileoption_used('ENABLE_FTS5')`.

The handle returned by `createBunSqliteAgentRuntimeStore` /
`createNodeSqliteAgentRuntimeStore` is `{ store, conversations, database,
transaction, close }`. `database` is the shared connection and `transaction`
is one write transaction in the store's own serialization: the SQLite
companions — `createSqliteAgentProjectionStore`, `createSqliteAgentSpillStore`,
`createSqliteAgentChildManager`, `createAgentScheduleService` — take `{ sqlite }`
and write their rows and events through it, so a row and its event land
together or not at all.

`conversations.messages` pages the model's history: what compaction removed is
not in it, which is the point of compaction. A person reading back their own
conversation needs the other view, and asks for it explicitly:

```ts
const page = await conversations.messages({
  conversationId,
  limit: 50,
  direction: 'after',
  includeCompacted: true,
})
const removed = new Set(page.compacted)
```

`items` stays one sequence in the order it happened, and `compacted` names the
ids inside it that the model no longer sees — the boundary is a mark on the
conversation, not a second list. A compaction summary sits at the position of
the last message it replaced, so the block reads through and the summary
arrives after it, where it was written; in the model's history, where every
replaced message is gone, it occupies the block's place either way. Without
the flag the page is exactly what it was, `compacted`
empty. The v1 → v2 migration baseline records the same thing: the whole
sequence, with `compacted` naming what had been folded away. A file migrated
by 0.86.0 has a baseline built from the active history only — the messages
themselves are still in the store and this read reaches them, but that one
event cannot be rewritten after the fact.

### Retried provider streams

With `loop.retry` set, a provider stream that fails before any tool call in the
attempt is retried at the step boundary. Subscribers see `attempt-reset` for the
run before the next attempt's first delta and drop what the failed attempt
streamed; the durable draft is checkpointed after that output is discarded; an
input injected into the failed attempt is taken again by the retry; and the
failed attempt's reported usage stays in the run's spend. Transient events —
deltas, reasoning, `attempt-reset` — are numbered from 1 per run, counting only
what was published, so a cursor sees no gap on an ordinary stream.

### Branch or restore with a new identity

Use `store.branchConversation()` to create an independent conversation. `importConversation()`
restores the original identity and is not a fork. The built-in memory and SQLite stores and
`createAgentRuntimeStore()` expose branching; a hand-written `AgentRuntimeStore` can advertise this
optional capability. The host authorizes the caller's source/archive access and destination
creation before calling this trusted store boundary.

```ts
const copy = await sqlite.store.branchConversation({
  requestKey: 'branch-request-1',
  destinationConversationId: 'new-conversation',
  source: { kind: 'conversation', conversationId: 'original', throughRunId: 'finished-run' },
  // Required when selected history has spills: an explicit policy for this destination.
  artifactAuthorization: { operation: 'read', path: 'approved-destination-workspace' },
})

const restored = await sqlite.store.branchConversation({
  requestKey: 'restore-request-1',
  destinationConversationId: 'restored-as-new',
  source: { kind: 'archive', bytes: archiveBytes },
})
```

`throughRunId` selects its exact terminal/abandon event. Omit it to include the settled tip and
compaction recorded after the last run. The selected prefix must contain no queued or acquired
runs. A later active source run does not prevent branching an earlier settled prefix. A migrated
baseline has no earlier per-run event boundary; it can be copied at its settled tip.

The result is `{ outcome: 'created' | 'duplicate', receipt }`. Keep the receipt: it binds source
and destination identity, source kind, exact `sourceSeq`/`sourceVersion`, `sourceDigest`, request
fingerprint and creation time. A retry with the same destination/key and selector returns the
original receipt even after the source advances or the destination continues. Changing the
request conflicts; no existing destination is overwritten. Archive fingerprints bind the supplied
bytes. The source digest binds the selected canonical events, artifacts and reconstructed snapshot.
Run update timestamps reconstructed from events use their recorded `occurredAt`. Abandon transitions
retain the full failed assistant in the same event; a legacy archive must retain that exact assistant
in its runtime snapshot, otherwise copying refuses instead of inventing historical content or time.

No provider or tool runs during copying. The next ordinary runtime `submit` continues the copied
history. All engine identities and typed references are new, including repeated references to one
spill. Compacted content stays in the event history and SQLite inactive rows; the model reads the
same active summaries. A hash-only checkpoint is historical digest evidence, marked
`sourceSha256`; a later full terminal record must settle its content.

Recorded provider options and canonical reasoning/tool-call provider metadata are retained.
Provider message hashes and request references remain consistent after rekey. Metadata is
data, subject to the opaque JSON reference guard: an undocumented runtime reference key
refuses instead of carrying a source identity into the destination. Unknown message fields
and malformed request/message digests also refuse before any destination writes.

A branch grants no source authority. Terminal run owners/fences and approval signatures are
removed. State, schedules, children, retries, sandbox, approval rejection and durability facts are
retained as inert `runtime/branch-observation` records; they cannot repopulate operational state
or pending effects. Spills discard source authorization and require the explicit fresh
`artifactAuthorization`; normal destination read authorization still applies. No cached projection,
schedule row or child relation is cloned. Ordinary companion projections in the export have
`{ name, version, uptoSeq, value, updatedAt }` envelopes. Branch validates their envelopes,
unique names and checkpoints within the supplied journal, then discards their values. They are
derived application caches, including ones containing source references; their values are never
replayed as state or copied as authority. Rebuild destination projections with
`createSqliteAgentProjectionStore().advance(...)` over the destination ledger. An exact earlier
boundary never inherits a later cache value.

The store validates the complete archive before writing: contiguous events, a matching runtime
snapshot, known typed forms, reference closure, and spill bytes/digests. Unsupported provider
envelopes, encrypted invocation payloads, unknown event versions, malformed projection envelopes,
missing artifacts and undocumented reference fields in opaque JSON refuse atomically. They need
an owner-defined transfer codec; never recursively rewrite internal JSON in the application.
Third-party drivers must provide atomic write transactions and archive support for spills;
`history.restore` is required for migrated baselines with inactive rows.

### Event ledger, projections and durable capabilities

`AgentRuntimeStore` exposes bounded `readEvents`, declared `appendEvent`, canonical
`exportConversation` and empty-target `importConversation`. Runtime transitions, exact provider
requests and completed provider response identities enter the same append-only ledger. A
`provider/response` payload binds `response: { id, provider? }` to its `runId`, retry `attempt` and
`stepNumber`; `step-finished.response` projects the same object to the operator sink. The optional
upstream name comes from the model provider's own `resolveResponseProvider` — `openRouterProvider`
supplies it — so the runtime carries the fact without learning any gateway's metadata key. The normalized
head/run/message tables remain the fast operational projection.

For `runtime/transition` payload inspection, use `AgentStoreTransitionRecordSchema` and its
inferred `AgentStoreTransitionRecord` type. A checkpoint record may hold either the full assistant
message or `{ id, status: 'streaming', sha256?, sourceSha256?, parts: number, bytes: number }`;
exactly one digest is present. Numeric `parts` counts parts; it is not an array of message parts.
`sourceSha256` preserves a branch's historical source digest. These hash-only records are
observations, not executable checkpoint inputs. `AgentStoreTransitionSchema` describes full
store mutations. Shape inspection does not replace branch's complete replay/reference validation.
Schema v4 archives written by 0.109.4 use the same journal representation.

Use `defineAgentProjection` with `createSqliteAgentProjectionStore` for a
deterministic versioned fold. Every value reports `uptoSeq`, so a caller can
distinguish current data from a projection that still has events to consume.
Changing the projection version folds from sequence one.

The built-ins are `agentSummaryProjection`, `agentUsageProjection` and
`agentOutlineProjection`; focused card, state-slot and schedule projections are
also available. `list(projection)` reads all materialized conversation rows in
one SQLite query instead of reopening their event logs.

Durable state is declared once and injected on every provider request:

```ts
const goal = defineStateSlot({
  name: 'goal',
  schema: z.object({ objective: z.string(), status: z.string() }),
})

const slots = createAgentStateSlotStore({ store: sqlite.store, definitions: [goal] })
await slots.set({
  conversationId,
  name: 'goal',
  value: { objective: 'finish migration', status: 'active' },
  actor: 'human',
})

const runtime = createAgentRuntime({ ...config, store: sqlite.store, stateSlots: [goal] })
```

`createAgentStateTools` binds `create_goal`, `update_goal`, `get_goal` and
`todo_write` to one conversation. Human changes call the same slot store with
`actor: 'human'`; they remain structured `state/set` facts rather than forged
chat messages.

`createSqliteAgentSpillStore` persists oversized output separately and records
its locator, hash and lifecycle in the ledger. `read_output` and
`search_output` re-run both locator authorization and the originating tool
authorization before reading bytes. A reference the store does not hold is a
refusal like any other — `SPILL_REFERENCE_UNKNOWN` / 404, naming the reference
and pointing at the one the spilling command returned. The lookup is scoped by
conversation, so a reference belonging to another conversation is absent rather
than forbidden, and the refusal cannot tell a model that it exists elsewhere. `createSqliteAgentEventSearch` returns the
exact conversation and event sequence; cross-conversation results are denied
unless `authorizeConversation` approves each target.
`createAgentEventSearchTools` supplies `session_search`, `session_trace` and
`session_event` over the same bounded APIs.

For credential-free failure tests, import `createFaultProviderServer` and
`createReplayAgentProvider` from `stitchkit/agent-runtime/testing`. Runtime
`loop.retry` applies an explicit `AgentRetryPolicy`: each decision is recorded
as `retry/scheduled`, each new attempt as `retry/started`, and an attempt that
has invoked a tool is never automatically replayed.

`createAgentScheduleService` persists `at`, `after` and `every` input. Repeating
schedules require an explicit IANA time zone. Dispatch uses a stable
`schedule:<id>:<occurrence>` idempotency key, and restart lateness is recorded.
Resolve after durable admission; return `{ status: 'retry', reason }` for a
transient refusal or `{ status: 'terminal', reason }` to stop the entire schedule in
`failed` state. Only those two shapes are failures: any other returned value — a
queue length, a receipt — means the callback returned, so it delivered. Throws retry
too. `maxAttempts` (default 1000, about sixteen hours at the retry ceiling) bounds
one occurrence: a one-off schedule then becomes `failed`, a recurring one gives up
that occurrence (`schedule/failed` with `skipped: true` and the `nextAt` it moves
to) and keeps its cadence. A failure reason is kept to 1000 characters. Backoff is 1, 2, 4, 8, 16, 32, then 60 seconds,
capped at 60 seconds and persisted across restart. `retryAt`, `attempts` and `lastError`
show retry diagnostics; success resets them. `nextAt` and `lateByMs` retain the original
occurrence time. Recurring schedules fire once after an idle stretch and advance to the
next future slot on their original cadence.

Dispatch receives an `AbortSignal`, has a 30-second deadline and holds a unique
60-second lease. A late or cancelled attempt cannot settle or emit firing events.
Honor the signal and durably deduplicate the key: a crash after admission but before
settlement can replay delivery. This is at-least-once, not exactly-once.
Due selection is indexed, limited to 32 parallel deliveries per tick; one hung batch
can delay newly due work by at most its deadline. The service keeps one nearest-due
timer plus per-attempt deadline timers. Storage failures use a bounded recovery timer;
idle ticks do not write. SQLite v4 migration preserves due times and claims; stop older
scheduler processes before migration. See [ADR 0202](https://github.com/max-listov/stitchkit/blob/master/docs/decisions/0202-schedule-retries-preserve-occurrence-identity.md).
`createAgentScheduleTools` exposes the exact `schedule_after`, `schedule_at`,
`schedule_every`, `schedule_list` and `schedule_cancel` surface.

`createSqliteAgentChildManager` seeds a child from the parent's ledger through
an exact sequence, stores the graph, clamps requested limits to a durable parent
remainder and stops budget overruns at the next step boundary as
`policy_stop`. That enforcement runs inside the child's own runtime: the host that spawns it gives the child
`loop.stopPolicies: [agentChildBudgetStopPolicy({ manager, childConversationId })]`, which
calls `recordStepUsage` at every step boundary; `recordStepUsage` alone measures and decides,
and reports `enforced: false` when the deciding process holds no handle to stop. Given to `createAgentRuntime` as `children`, the manager stops the parent's children after the parent's terminal is durable — interrupted, cancelled, timed out or shut down; a successful, policy-stopped, superseded or failed parent leaves them to the conversation (ADR 0175 carries the table); without `children` nothing cascades; an unreachable child is recorded
as `lost`, not misreported as a provider failure.
`createAgentChildTools` adds `subagent`, `subagent_fork`, `list_agents`,
`send_message` and `interrupt_agent`; final output stays behind its bounded
result locator.

## Durable order

`acceptInputAndAssignRun` is one atomic operation. It is followed by ownership
acquisition, revision-checked assistant checkpoints and one terminal CAS. The
process-local coordinator releases its lane only after terminal commit.

A queued admission is durable immediately, even if its predecessor is still awaiting ownership
or its first checkpoint. Snapshots expose causal turn order rather than physical append order:
the predecessor's assigned input(s) and assistant come before the successor input(s). Execution
uses the same run boundary, so neither `prompt({ snapshot })` nor default/custom history
projection can see inputs assigned to a later run. `inject` remains the explicit path that moves
a successor input into the run already in flight.

```text
input + queued run → running → execution settled → terminal CAS → successor
```

## What happens to a run when new input arrives

`runs.inputPolicy` decides. It takes five values, or a function returning one:

| policy | the run in flight | what it already produced |
|--------|-------------------|--------------------------|
| `queue` (default) | finishes first | kept |
| `inject` | continues, and answers the new input too | kept, and built on |
| `interrupt` | ends | kept, and marked as cut off |
| `interrupt-next` | ends; this input runs before ordinary queued work | kept, and marked as cut off |
| `supersede` | ends | discarded from the prompt, kept in the record |

`interrupt-next` is the explicit priority path for an urgent input. If A is
running, ordinary B is queued, and urgent C arrives, the coordinator aborts A,
waits for A to settle and then runs C before B. B keeps its durable identity and
eventually runs. Urgent inputs remain FIFO among themselves. They do not
coalesce into an ordinary pending run: that would erase the priority boundary.

`interrupt` and `supersede` differ in exactly one thing, and the question that
picks between them is **not** "was the run interrupted" but **"did anyone see
what it produced"**:

- The user pressed **stop**. The partial answer was streamed to their screen and
  they read it. It belongs in the conversation — dropping it makes the history
  lie to the model about what the human has seen. That is `interrupt`.
- A newer message **superseded** the run. Whether the partial reached anyone
  depends on the delivery surface: a token stream shows it as it is produced, a
  surface that sends nothing until the run is done never showed it at all. When
  it reached nobody, it is not part of the conversation. That is `supersede`.

**Stitchkit cannot answer that question for you** — delivery belongs to the
transport, and the runtime sees an abort, not a screen. Hence a declared policy
(→ ADR 0108), and hence `inputPolicy` accepting a function, so one application
can hold two surfaces with different rules without the core learning which is
which:

```ts
runs: {
  inputPolicy: (input) =>
    protocol.parseContext(input.context).surface === 'operator' ? 'queue' : 'supersede',
}
```

`input.context` is the **raw** context here — admission runs before the runtime
parses it, so the callback narrows it itself with the protocol it already has.

A superseded run ends with `terminalReason: 'superseded'`, run state
`'superseded'` and an assistant message of status `'superseded'`. **The record
is kept** — excluded from the projection, not deleted — so an operator can see
what was thrown away, and run identity, admission receipts and the terminal CAS
keep the row they depend on. Compaction leaves it alone for the same reason: a
turn whose answer is never spoken is not a turn that may be summarised into one,
because that would both feed the discarded text to the summariser and drop the
record in `replacedMessageIds`.

It is also outside the token budget. `selectAgentHistory` removes it with reason
`'superseded'` and does not count it, so an abandoned fragment cannot push a
real turn out of a context it never occupies.

### How an interrupted turn reaches the model

An interrupted turn is projected, and says so:

```text
{ role: 'assistant', content: [
    { type: 'text', text: 'We are the team, where would you like' },
    { type: 'text', text: '[interrupted: this turn was cut off before it finished]' },
]}
```

`history.interruptedAssistant` chooses the form, and the difference between the
first two is structural rather than cosmetic. **An assistant turn in provider
history is a commitment**: the model reads its own previous turn as something it
said and stays consistent with it. A system line is context.

| value | form | right when |
|-------|------|-----------|
| `assistant-marked` (default) | assistant turn plus a marker | the human read the text |
| `system-note` | `[interrupted] partial response: …` as a system line | the fragment reached nobody |
| `omit` | not projected at all | you want it gone from the request |

There is deliberately no value that reproduces what the projection used to do,
which was to send the partial as an ordinary assistant turn and drop its
`control` marker on the way. That was the defect, not a behaviour to stay
compatible with.

`projectAgentHistoryDetailed` reports what reached the provider, including part
types that no projected content stands for:

```ts
const { decisions } = await projectAgentHistoryDetailed(snapshot.messages)
// → { messageId: 'assistant-2', action: 'projected', reason: 'projected',
//      omittedParts: ['source', 'provider'] }
// → { messageId: 'assistant-1', action: 'omitted', reason: 'superseded' }
```

`runtime.stop(key, 'supersede')` is the same decision taken by hand: the
process-local escape hatch chooses the reason, so a caller that knows the answer
was never delivered can discard it without a newer input arriving.

### An input that joins a run in flight

`inject` is right when the new input **refines** rather than redirects, and the
steps already taken are still worth something: *"summarise this thread… actually,
in bullet points"* should not throw away the reading the first message paid for.

What happens:

1. The input is admitted exactly like any other — a committed user message and a
   **queued run**, durable before anything else happens.
2. At the running loop's next step boundary, that input joins its prompt. Only
   that input: an unrelated queued submission is never carried in.
3. When the run finishes, its terminal commit — **one transaction** — records
   the input as one the run answered and settles the queued successor with
   `terminalReason: 'absorbed'` and `absorbedIntoRunId` naming the run that
   answered it. Every ticket for that successor resolves to the same answer.

Nothing durable happens between 1 and 3, and that is the design (→ ADR 0113).
A run that crashes, is closed, or is interrupted after taking an input on
commits no absorption at all, so what is left behind is an ordinary queued
successor — the state every other policy already produces and recovery already
handles. **There is no ordering in which an accepted input becomes
unanswerable.** Only a run that *completes* may absorb; an interrupted one took
the input into its prompt and then stopped, and does not get to say it answered
it.

An absorbed run has **no assistant message of its own** — it produced none, and
writing an empty one would be a record claiming otherwise. Its answer is
reachable through `absorbedIntoRunId`, and the store follows that pointer itself
when a submission arrives on the absorbed run's idempotency key, so a retry
after a restart returns the answer rather than an empty terminal record.

It does publish one more `run-state` event, carrying `'superseded'`. It never
enters the run executor, so that event is the only thing that tells a delivery
surface following its `runId` that it is no longer queued.

With `coalescePending`, one successor can carry several inputs, and an
absorption covers a successor **whole or not at all** — a partial one would
leave a terminal run with inputs nobody answered. Inputs that coalesce before
the absorbing run's last step boundary join the same absorption. One that
arrives after it cancels the absorption: the successor then answers all of its
inputs itself, and the absorbing run has answered one of them too. A duplicate
answer, never a missing one.

(0.63.0 shipped a version of this that committed the absorption at the step
boundary, before the answer existed, and it was withdrawn in 0.65.0. ADR 0113
records what that ordering broke.)

With `runs.coalescePending: true`, an active lane has at most one queued
successor. Every later accepted input is atomically appended to that successor;
its `AgentRun.inputMessageIds` records the whole batch and every input ticket
resolves to the same terminal run. Coalescing never mutates the active run.

`AbortSignal.aborted` is only a cooperation request. A successor does not begin
while the predecessor still owns managed callbacks. A hung predecessor blocks
the lane in the first version.

Shutdown is two-phase, and both budgets carry the names they carry everywhere
else in Stitchkit:

```ts
const closed = await runtime.close({ gracePeriodMs: 30_000, forceTimeoutMs: 5_000 })
if (!closed.settled) {
  console.warn(`exiting with ${closed.remaining} run(s) still in flight`)
}
```

`close` first rejects new process-local admissions and gives active runs
`gracePeriodMs` to finish on their own. Only after that budget expires does it
abort them with reason `shutdown`; `forceTimeoutMs` then bounds the settlement
wait for a non-cooperative model or tool, measured from the abort — the two
budgets add up rather than overlapping.

**`close()` reports what it achieved rather than promising an outcome it cannot
reach.** The result is `{ settled, timedOut, remaining }`: `settled` when every
in-flight run finished, `timedOut` with a `remaining` count when the force
budget expired first. There is no combination of budgets that is both bounded
and guaranteed to leave nothing in flight — that is the trade the budgets exist
to make, and the result is where you read which side of it you got:

| budgets | behaviour | can return with a run in flight |
|---------|-----------|---------------------------------|
| neither | aborts immediately, waits for settlement | no — unbounded wait |
| `gracePeriodMs` only | waits, aborts, then waits for settlement | no — unbounded wait |
| `forceTimeoutMs` only | aborts immediately, waits at most that long | **yes** — `timedOut` |
| both | waits, aborts, waits at most that long | **yes** — `timedOut` |

A caller that wants the old shape still writes `await runtime.close(…)` and
ignores the result. A caller deciding whether to exit the process reads it.
Durably queued records rejected from the local queue remain recoverable through
`scanRecoverable`; close never marks them terminal on its own.

Use `await runtime.interrupt({ conversationId, runId })` when the interruption
must be durable: it first commits `interrupt_requested`, then aborts the local
coordinator signal. If provider completion races that revision change, the terminal path reloads
the canonical snapshot. An already-terminal winner settles the ticket directly; a still-owned
`interrupt_requested` run is committed as `interrupted`, and unrelated aggregate-head conflicts
remain retriable while the run is active with the same owner and fencing token. A stale owner or
fencing token remains a conflict. Only the execution that applies the terminal mutation publishes the
**delivery** `terminal` event — a loser settles from canonical state without republishing the turn,
and its `AgentRuntimeResult.metrics` is `undefined`. The **operator** `run-terminal` event is not
gated that way: a losing execution still ran, and still spent whatever it spent, so it reports its
own usage. The two channels answer to different readers — delivering a turn twice is a user's
problem, and omitting a run's cost is an operator's.
`runtime.stop(key)` is the process-local signal-only escape hatch.

When an operator has positive evidence that another process owning one known
run is gone, close that run through the runtime boundary rather than updating
an adapter table or JSON payload directly:

```ts
await runtime.abandon({
  conversationId,
  runId,
  expectedRevision: run.revision,
  staleOwner: true,
})
```

The revision protects against stale operator evidence. The mutation updates the
canonical run and failed assistant atomically, removes the run from recovery
indexes, and publishes the resulting `run-state`. A run that moved since it was
read returns a conflict; `staleOwner: true` is mandatory evidence, not a default.

## Store operations

`AgentRuntimeStore` remains the runtime-facing aggregate. Application adapters
implement the smaller `AgentRuntimeStoreDriver` rather than these eleven members:

- `acceptInputAndAssignRun`
- `acquireRun`
- `checkpointRunAssistant`
- `requestRunInterrupt`
- `recoverRun`
- `commitRunTerminal`
- `replaceCompactedRange`
- `loadSnapshot` — the whole conversation; see **Reading a conversation** below
- `loadRun` — one run by id, with the answer it produced if it has ended
- `listActiveRuns` — the runs of one conversation that have not ended
- `scanRecoverable` — one **bounded page** of recoverable runs; `recover()`
  calls this and nothing else, so an adapter that implements the interface has
  everything recovery needs

### Reading a conversation

Two shapes of read, and the difference matters as a conversation grows.

**Bounded.** `loadRun` and `listActiveRuns` read run records and the
conversation head. Neither touches history, so neither grows with the length of
the conversation. `loadRun` is how you resolve the `runId` that
`submit().admission` hands back — it returns the run, the version it was read
at, and, once the run is terminal, the answer it produced.

**Unbounded.** `loadSnapshot` returns every message and every run, and so does
every mutation result: the store's reducer validates its invariants against the
whole conversation, and the runtime builds the next prompt from the snapshot the
mutation returns. Ask for it when you need the conversation — composing a
prompt, or compacting — and not to look one run up.

So the cost of a run scales with the length of its conversation, not with the
length of the turn. **Configure compaction** (see below) for anything
long-running: it is the only thing in the framework that makes a conversation
smaller, and without it a year-old assistant thread is read in full on every
turn. This is a known limit, held deliberately rather than by omission — paged
history would have to change what a snapshot *is*, and the store's invariants
with it, and that is a decision on its own (→ ADR 0112).

Every mutation carries an expected run revision or snapshot version. Input
assignment additionally carries an idempotency identity. A conflict is a
control outcome; stale data is never silently overwritten.

Acquisition increments an optional monotonic `fencingToken`. The managed runtime carries it through
checkpoint/terminal CAS and tool context, so a distributed adapter can reject an old owner even if
an owner label is reused. Lease expiry and renewal remain application-owned.

On startup, `runtime.recover({ resolveContext })` consumes bounded lightweight
pages, then restores each conversation's durable execution order before any run
acquires. Persisted `executionSequence` orders work that started; queued
`interrupt-next` work precedes ordinary queued work with FIFO preserved inside
each class. Scan identifiers, equal timestamps and page boundaries therefore
never become queue order. Its safe default resumes queued runs and reports acquired or
`interrupt_requested` runs as skipped. A policy may requeue acquired work only
with explicit replay-safe evidence, or abandon it only with stale-owner
evidence. Each attempted run returns its own outcome/error; a `resumed` or
`requeued` outcome also exposes the terminal `result` promise. The outcome is
reported only after durable acquisition, so a lost acquisition is `failed`
rather than a successful-looking handoff. `pageSize`, `maxRuns`, and `signal`
bound the pass. `runtime.resume(...)` remains available for one known queued
record; its `accepted` promise likewise means that the recovered run acquired
durable ownership, while `result` carries terminal completion.

Canonical records currently write `schemaVersion: 1`. A durable adapter owns
read-time migration of older rows: migrate to the current shape at its storage
boundary, validate with the exported schema, and write only the current
version. Core deliberately does not guess an application's database migration
or silently accept an unknown future version.

### Purging a conversation

```ts
import { purgeAgentConversation } from 'stitchkit/agent-runtime';

const result = await purgeAgentConversation(store, {
  conversationId,
  expectedVersion: snapshot.version, // optional stale-intent protection
});
```

The optional `AgentRuntimeStore.purgeConversation` capability is implemented by the memory and
initialized Bun/Node SQLite stores. The helper returns `unsupported` for stores without it; it
never pretends deletion succeeded. Other outcomes are `purged`, `already_purged`,
`active` (with `runIds`), or `conflict` (with `actualVersion`). Storage failures reject.

Purge refuses queued, running and interrupt-requested runs. It does not interrupt, force-abandon
or wait for a provider. Authorize the destructive request in the host, close its conversation
ingress/attachments, then settle runs through normal interruption or explicitly justified recovery.
Retry purge after they are terminal. An optional expected version must match the current snapshot;
a version conflict requires a fresh deletion decision, not blind retry with a new version.

Successful purge atomically removes messages (including compacted inactive rows), all runs and
retained terminal assistants, admissions/idempotency receipts, conversation heads and their owned
indexes. It retains only a permanent conversation-ID tombstone. Purging an unknown ID reserves it
as well, so a submit paused before admission cannot create it later. Repeating the call returns
`already_purged`, regardless of the original expected version. A lost response is safely retried
with the same ID; **new chats require a new ID**.

Every later runtime mutation rejects with `AgentConversationPurgedError`, including fresh and
duplicate admissions, checkpoints, recovery and compaction. Existing controller leases grant no
exception. Existing reads return empty snapshots, absent runs and empty message pages; purged
conversations disappear from the SQLite catalog and recovery scans. Snapshots retain their
existing empty `version: 0` shape; use the purge result, not an empty snapshot, as deletion evidence.

SQLite commits the tombstone and all deletions in one `BEGIN IMMEDIATE` transaction. Failure before
commit rolls everything back; reopen/recovery cannot restore committed deleted records. Same-thread
connection contention rejects promptly without effects; retry after the competing operation settles.
Initialization adds the tombstone table and write guards to schema v1 transactionally, without
changing application tables or `user_version`. Guards also fence older writers. `initialize: false`
on an original v1 database does not add purge capability; open an initialized writer first.

Consumer-owned model selections, projections, attachment files, delivery/outbox records and event
logs remain outside this boundary. Retain an authorized cleanup intent plus any needed opaque file
references in consumer storage, purge first, then retry idempotent consumer cleanup. Invalidate local
caches/subscriptions so an old delivered event cannot repopulate a UI. The library never traverses
paths, revokes remote artifacts or claims a cross-store transaction. Nor does logical purge securely
wipe database free pages, WAL or backups. Memory tombstones last only for that store instance.

Custom normalized drivers may opt in through `AgentConversationPurgeDriver` on `driver.conversations`.
Their transaction must serialize `isPurged`, head/active-run checks, `remove`, and **all** mutations
for the same ID, including absent-ID admission. An optimistic head CAS alone is insufficient. `remove`
must delete every owned payload/index and preserve the tombstone; errors must roll back the entire
transaction. Drivers without this guarantee must leave the capability absent. See ADR 0138.

## Events and reconnect

A subscriber that switches over `event.type` handles one transient event
beyond deltas and reasoning: `attempt-reset`, published when `loop.retry`
starts a new attempt, meaning everything this run streamed before it is
withdrawn. `reduceAgentControlEvent` does that; a hand-written reducer must,
or it keeps the failed attempt's partial text on screen.

`publish` receives event classes with different guarantees:

- `admission` follows a successful acceptance CAS and carries the same complete
  projection as `ticket.admission`;

- `assistant-delta` is transient and ordered by
  `(runId, runtimeEpoch, sequence)`;
- `reasoning-start`, `reasoning-delta` and `reasoning-end` are transient and
  ordered by the same identity; delta carries only the current text fragment,
  while provider metadata stays inside a validated canonical envelope;
- `assistant-checkpoint` follows a successful checkpoint CAS;
- `run-state` follows durable queue/acquire/interrupt transitions;
- `run-operation` follows a successful operation CAS. Its `operation` is the
  durable `AgentRun.lastOperation`: `model-request` or `compaction`, with
  `started`, `first-output`, `completed`, `failed` or `cancelled` phase and the
  original timestamps. Concrete models use SDK call identity in an awaited model
  middleware; global provider IDs use a runtime-generated identity during awaited
  step preparation. Both durably admit the request before entering `doStream`, so a failed
  write prevents the provider call. This does not claim HTTP transmission is
  measured. First output is a
  non-empty text/reasoning/tool-argument delta or complete tool call; metadata,
  usage, files and sources do not count;
- `tool-status` is transient lifecycle presentation with JSON-safe input on
  start and output on completion. A mounted typed failure carries the same safe
  `{ error, retryable, details?, _hint? }` envelope as the durable result; an unknown
  internal cause remains generic and stays in local observability only;
- `terminal` follows the winning terminal CAS.

`loop.checkpointEveryEvents` batches ordinary stream parts (default `20`) and
does not batch live event delivery. Tool call/result/error/denial, approval
request/response and step-finish boundaries force an assistant checkpoint and
reset that batch. The checkpoint is awaited after the managed loop observes the
normalized boundary. It does **not** promise persistence before a tool side
effect: tool fencing supplies the real ownership checks around execution, while
cross-crash effect idempotency stays application-owned. The following model
step waits for the previous step-finish checkpoint before its own durable
request admission and provider invocation. → ADR 0174.

`compaction` names invocation of the configured callback, including a
`not_needed` context-budget check; it does not by itself say that history was
summarized. Operation timestamps are retained wall-clock observations and may
move backwards after a clock correction. Do not subtract them for duration;
phase order comes from durable snapshot versions/event order.

These are post-commit notifications, not a transactional outbox: a process can
crash between the database commit and `publish`. Reconnect should load canonical
state. Exactly-once external delivery remains an application-owned outbox.

### External channel ingress and delivery

The executable
[`external-channel-harness.ts`](../../packages/core/examples/agent-runtime/external-channel-harness.ts)
is the reference composition for webhooks, polling transports and local-device
adapters. Its generic boundary injects a runtime, durable store and delivery
adapter; the optional
[`external-channel-grammy.ts`](../../packages/core/examples/agent-runtime/external-channel-grammy.ts)
attaches the same ingress function to grammY polling or webhook lifecycle
without putting provider types in the agent runtime.

Inbound update identity, principal resolution, conversation mapping and reply
target are application policy. Persist and deduplicate the update before
`runtime.submit`, use the stored idempotency key on crash recovery, then persist
the admitted `runId`. A duplicate arriving after restart resolves to that same
mapping instead of creating another model turn.

For output, choose `terminal-only` or `streaming` explicitly. Stable durable
`eventId` values and transient `(runtimeEpoch, sequence)` identities become
outbox keys; the application store assigns causal ordinals. A bounded channel
is only a process-local wakeup and never the durable authority. Before sending,
persist one of these states:

| State | Recovery action |
| --- | --- |
| `not-dispatched` | may dispatch once |
| `possibly-dispatched` | reconcile with the adapter or remain unresolved |
| `acknowledged` | deduplicate; delivery is complete |

The agent `terminal` event and the adapter's delivery receipt are intentionally
different records. A successful run does not prove that a reply reached its
channel, and a delivery failure never rewrites the canonical agent result.
Shutdown stops ingress, drains accepted process-local work and leaves ambiguous
outbox records visible for a later reconciliation pass. Stitchkit does not own
a channel database, durable broker, provider payload schema or application
identity policy.

Durable event IDs are derived from run, event type and snapshot version. Use
`advanceAgentRuntimeEventCursor` to classify delivery.

**`gap` is reported for transient events only**, where `sequence` is a per-run
counter the runtime really does increment once per event. Durable events carry
the *conversation's* version, which advances on every mutation including the many
that publish nothing — checkpoints, compaction, an acceptance that has not
started — so two consecutive durable events are routinely several versions apart
and adjacency says nothing. A durable loss is not detectable from the cursor;
reload on reconnect, which is what a bounded fire-and-forget sink asks of you
anyway. `createAgentRuntimeEventSink` adds a bounded failure-isolated lifecycle and typed
projection/redaction hook. `onPublishError` records direct publisher failures without changing the
already committed run.

A named custom stop condition terminalizes with `policy_stop`; its `policyName`
is persisted on the run and included in the terminal event/result. `max-steps`
is the reserved built-in policy name. `loop.prepareStep` is the controlled AI
SDK step boundary for changing active tools, model, instructions or messages.
It cannot replace the managed tool set or bypass its lifecycle fence.

Context can grow between steps as tool results and deferred schemas enter the
provider prompt. When application budgeting can prove that the next assembled
step exceeds the selected model window, refuse it by type before that provider
call:

```ts
prepareStep: (step) => {
  if (wouldExceedSelectedModelWindow(step)) {
    throw new AgentContextOverflowError('Prepared step exceeds the selected model window')
  }
  return chooseProductStepOptions(step)
}
```

That deliberate refusal ends the run as `context_overflow` on the durable
record, delivery terminal and operator event. Stitchkit does not inspect error
messages — it recognises failures by identity, not by text. A store refusing an
owned mutation ends the run `storage_conflict`; `protocol.acceptTerminal`
refusing a finished message ends it `output_rejected`; a check the SDK runs on
what this runtime handed it ends it `runtime_failure`. Every error the runtime
cannot identify as its own remains `provider_failure`, and operator-only
observability retains its original cause in all of them.

Completion validity belongs to the protocol and is checked before the terminal
CAS. Protocols that require a visible answer opt in explicitly:

```ts
const protocol = defineAgentProtocol({
  context: ContextSchema,
  inputMetadata: InputMetadataSchema,
  terminalAcceptance: 'require-output',
})
```

The built-in rule accepts non-blank text, generated files, structured provider
parts, and a named tool-only `policy_stop`. The default is `allow-empty` for
protocols where an empty acknowledgement is meaningful. Pass a callback when
the product has a narrower definition; it receives the candidate message,
terminal reason and optional policy name, and runs before persistence. A false
result fails the candidate instead of rewriting an already committed success.

`loop.idleTimeoutMs` measures provider silence. It starts immediately before the
provider call, resets on stream activity, and pauses while local tools execute.
Resource preparation, child waits and approval waiting do not spend this budget.
The host gives tools their own deadlines. A stalled provider still aborts with
durable reason `timeout`; caller interruption and shutdown reach tools directly.

Reconnect loads the durable snapshot. Missing transient deltas do not mean the
canonical result was lost. Exactly-once external delivery requires the
application's transactional outbox or stable-ID deduplication.

## Managed tools

Durable `step` bodies return lossless JSON: finite numbers (excluding negative zero),
strings, booleans, null, dense arrays and plain data objects. Return `null` explicitly
for effect-only bodies and convert Date values to strings. Unsupported values are
refused after the body, so external effects still require idempotency. First results
and replay are detached snapshots; mutating a returned object cannot rewrite a record.
The optional [sandbox coding profile](sandbox.md#coding-tool-integration) reuses the
same tool mounting, authorization and output handling for namespace-backed commands.

Always compose `toolFenceLifecycle` into `mountAgent`. It checks ownership
before a managed side effect and again before accepting its result. Fence loss
uses an internal control signal: it stops the old loop and is not sent to the
model as a tool error.

`mountAgent` sends failed runner outcomes through the AI SDK's tool-error
channel. AgentRuntime persists them with `outcome: 'error'`, publishes the same
safe envelope, and replays them as an error result on later turns. Classification
comes from the execution channel, never from inspecting output fields: a valid
successful result such as `{ error: 'domain value' }` remains successful. Typed
`AppError` codes and safe details may reach the model; arbitrary thrown causes
are retained for local hooks and reduced to `INTERNAL_SERVER_ERROR` outside.

The framework cannot undo an already-started non-cooperative external effect.
Pass the stable call/run idempotency identity into business mutations when the
effect must be replay-safe.

## History, provider metadata and files

`projectAgentHistory` converts canonical engine records into provider-valid AI
SDK messages and pairs tool calls/results in persisted causal order. Adjacent
parallel calls stay in one assistant round and their adjacent results stay in
one tool round; a dependent call after those results starts a new assistant
round, and trailing final text remains after the last tool result. Provider-required metadata is kept
in a versioned opaque envelope and omitted from product delivery by default.
`projectAgentHistoryDetailed` additionally returns one inspectable decision per canonical record;
leading assistant records, crash drafts and unmatched tool chronology are never silently passed to
the provider.

`selectAgentHistory` is the non-destructive context-window selector. It removes only whole oldest
complete turns, protects system/summary, incomplete and configured recent turns, and reports every
keep/remove reason with measured/estimated/unavailable token provenance. An oversized protected turn
returns `oversized`; unavailable accounting returns `unavailable` without invented arithmetic.

`ComposedAgentPrompt.instructions` accepts the AI SDK `Instructions` contract.
Use `adaptInstructions` when a provider needs metadata on the system message:

```ts
prompt({
  context,
  signal,
  adaptInstructions: (content) => ({
    role: 'system',
    content,
    providerOptions: { openrouter: { cacheControl: { type: 'ephemeral' } } },
  }),
})
```

Attachments and generated files remain application-owned. The runtime only
stores a reference returned by `persistGeneratedFile`; it never silently puts
base64 blobs into the neutral history store.

To send a stored attachment back to a multimodal model, configure
`history.resolveFile`. It maps the neutral file reference to AI SDK file data
(URL, bytes, provider reference or text). Without a resolver the
`unresolvedFile` policy applies, and it is **`omit`** by default: the file part
simply does not reach the provider. Choose `text` for a describing placeholder —
the filename or media type, never the storage reference, because that string is
an address inside your infrastructure and this content travels upstream — or
`error` to fail loudly. The `error` message does name the reference: it is
thrown into your process, where you are owed the whole story.

## Compaction

`structuredCompaction` summarizes complete provider-valid turn groups outside
the store lock, then calls `replaceCompactedRange` against the exact snapshot
version. A concurrent input produces `conflict`; the stale summary is not
applied. Summary records use the dedicated `summary` role and project to a
provider system message. The consumer supplies the structured summary schema
and prompt. Pass `previousSummary` for a direct call, or configure
`readPreviousSummary` for runtime-managed compaction, to merge and atomically
replace a leading summary on the next compaction.
Set `maxAttempts` to allow bounded conflict recovery. Every retry reloads the snapshot, reselects the
eligible range and recomputes the summary; the stale summary is never retried.

### Compacting without the store

The *selection* inside compaction needs no store, no version and no runtime, so
it is published on its own as `selectCompactableHistory` — for an application
that drives the model itself and keeps its own history (→ ADR 0142):

```ts
import { selectCompactableHistory } from 'stitchkit/agent-runtime'

const { leadingSummary, compactable, retained } = selectCompactableHistory({
  messages,
  keepRecentTurns: 3,
})
if (compactable.length > 0) {
  const summary = await summarize(compactable, leadingSummary)
  messages = [summary, ...retained]     // replace the old summary, never stack one
}
```

`compactable` is the oldest **whole complete** turns and `retained` is
everything the model must still hear; together they are the input, in order. A
turn holding a tool call whose result never arrived is never eligible, and
neither is anything after it: half a turn hands the provider a call with no
result, which most of them refuse outright.

`structuredCompaction` calls this same function, so the published selection and
the runtime's cannot drift into two behaviours. What it adds is the part that
does need the store — writing the result back under a version check.

Deciding *when* to compact is yours: `AgentRuntimeRunContext.contextUsage`
carries the last step's prompt size beside the model's window, and dividing them
is one line where the threshold is decided. The core does not own that ratio, on
purpose — see ADR 0142 for what it publishes and what it declines to.

## Observability

`createAgentObservability` emits a separate operator-only `AgentRunEvent`. It
reuses the same bounded sink lifecycle as request/tool observability without
sending new event kinds to existing request sinks. Product events omit provider
causes. Operator `internalCause` is also redacted by default; an operator-only sink must explicitly
set `includeInternalCause` and own its retention policy.

### What a run says it spent

Usage values carry `provider-reported`, `computed`, `estimated` or `unavailable`
provenance, **per field**. Cost additionally carries an ISO currency code;
OpenRouter-reported cost is normalized as USD.

Read the provenance before the number (→ ADR 0109):

- **`provider-reported`** — the provider handed us exactly this. On
  `step-finished`, that is what a step's figures are.
- **`computed`** — a total, added up over steps. **Every figure on a terminal
  event is this**, tokens included: the AI SDK's `totalUsage` is a sum it
  performed, not a number a provider reported for the run. It is not a figure to
  bill against unchanged.
- **`unavailable`** — nobody reported it. Not zero.

Two rules follow from that last one, and they differ by field on purpose:

- **A token total with an unreported step is a floor**, labelled `computed`. A
  token count is a diagnostic, and a floor is a useful one.
- **A cost with an unreported step is `unavailable`, not a floor.** Money is what
  people bill against, and "at least $1.00" reported as `$1.00` is the same class
  of lie this whole section exists to remove. One step that did not report its
  cost makes the run's cost unknown — not smaller. It also stays unknown: later
  steps reporting normally cannot revive it.

**A terminal event always carries `usage`.** A run that ended before the provider
reported anything — superseded, interrupted, timed out, shut down, failed —
carries every field `unavailable`. That is deliberately different from a run that
spent nothing, and an omitted object could not tell you which one you had.

Two costs in different currencies do not add: the sum reports `unavailable`
rather than picking a label. The core records a currency and never converts one.

`step-finished.response` identifies the provider response for that one step. The terminal event
does not copy the last response: response identity remains per-step, while every terminal usage
figure remains a `computed` aggregate.

**A run's figure is durable, and that is where to read it when a channel loses
it.** `AgentRun.usage` is written at every checkpoint and again with the terminal
record, so a crashed process leaves behind what it had already spent and a
dropped event is not a lost number:

```ts
const snapshot = await store.loadSnapshot(conversationId)
const spent = snapshot.runs.find((run) => run.id === runId)?.usage
```

Two gaps are open and are stated rather than left to be discovered:

- **Both event sinks are bounded and drop under load**, by arrival order — the
  event carrying the money is exactly as droppable as the one carrying nothing.
  The run record is the recovery, and this paragraph is the only place that says
  so.
- **`AgentRuntimeResult.metrics` is `undefined` when this executor did not win
  the terminal race.** Read `result.run.usage` — the durable figure — rather than
  `result.metrics`, which is the channel that can go missing.
- **Compaction spend is invisible unless you report it.** `config.history.compact`
  calls a model inside the turn and produces no step and no event of its own.
  Return `usage` from `AgentCompactionResult` and it joins the run's figure;
  omit it and the run under-reports by whatever summarising cost.

What stitchkit does **not** keep is a ledger: one figure, on the run that
produced it, never aggregated and never reconciled against a provider invoice
(→ ADR 0110).

### Reconciling with the provider's own accounting

Whether a provider bills for a call that was aborted mid-flight cannot be known
inside the process: the authoritative number arrives later, from the provider's
accounting. **stitchkit does not accept it back** (→ ADR 0110) — a terminal run
is an absorbing state, and a write that reached it through the conversation
aggregate could conflict a concurrent compaction into discarding a summary it had
just paid a model to produce.

The join is yours, and `runId` is the key:

```ts
// when the run terminates — write what the runtime observed
await ledger.record({
  runId: terminal.run.id,
  conversationId: terminal.run.conversationId,
  // The durable figure, not `terminal.metrics` — that one is absent whenever
  // this executor did not win the terminal race.
  costUsd: terminal.run.usage?.cost?.value ?? null,        // null when `unavailable`
  provenance: terminal.run.usage?.cost?.provenance ?? 'unavailable',
})

// later — the provider's accounting names the same generation
await ledger.reconcile({ runId, costUsd: billed, provenance: 'provider-reported' })
```

Record the row even when the figure is `unavailable`: that row is the evidence
that a run happened and cost something nobody has counted yet, and it is what the
provider's later figure attaches to. A run with no row is a run you cannot
reconcile.

The sink deduplicates stable event IDs by default. Cross-crash exactly-once still requires a durable
outbox.

## Deterministic race and adapter proof

`stitchkit/agent-runtime/testing` exports `createAgentRaceBarrier`, `createAgentRaceDriver`,
`createAgentRaceTrace` and `runAgentStoreConformance` — agent testing tools live beside the agent
runtime, and `stitchkit/testing` imports none of it. Barriers have bounded teardown, traces assert exact partial order, and the
helpers are exercised from packed Bun and Node consumers. `runAgentStoreConformance` runs duplicate,
coalescing, collision, stale checkpoint, replay safety, terminal race, absorption, bounded reads,
causal queued-history order, durable interrupt priority, compaction and recovery invariants against
any fresh durable adapter.

It picks its conversation identities itself and passes them to `createStore(context)` **before the
first mutation**, so an adapter whose runtime rows reference an application-owned conversation row
can provision those parents; the optional `cleanup(context)` removes them again, and runs once
whether the scenario passed or failed. A factory that owns no fixture state ignores the argument and
keeps working unchanged.

## Plain completions and shared invocation receipts

Use `createModelInvocationLedger` from `stitchkit/agent-runtime` when a direct text
completion and an agent must share causal provider evidence. It uses the existing
runtime store, not a second database. The completion path sends exactly one user
prompt, no framework instructions and no tools. It never starts an agent loop.

```ts
import { createModelInvocationLedger } from 'stitchkit/agent-runtime';

const invocations = createModelInvocationLedger({
  store, // the same AgentRuntimeStore object passed to createAgentRuntime
  models, // an existing defineModelRegistry result
  payloadKey, // 32 bytes from the host's secret store; retain separately from archives
  authorize: async ({ context, conversationId, action, trace }) => {
    const session = await authenticate(context);
    await authorizeConversation(session, conversationId, action, trace);
    return { subject: session.subject }; // verified identity, never a body field
  },
});

const completion = await invocations.complete({
  conversationId: 'evaluation-42',
  idempotencyKey: 'summary-case-3',
  trace: {
    operationId: 'summary-42', purpose: 'summarize', project: 'example',
    parent: { traceId: 'trace-42' }, experiment: 'evaluation', case: 'case-3',
  },
  prompt: 'Summarize these supplied facts.',
  models: ['primary', 'fallback'], // registry keys; repeat a key for an explicit retry
  settings: { maxOutputTokens: 500 },
  timeoutMs: 30_000,
}, authenticatedContext);

// Add to the existing createAgentRuntime configuration:
const receiptConfig = {
  ledger: invocations,
  trace: ({ run }) => ({
    operationId: run.id, purpose: 'assistant-turn', project: 'example',
  }),
};
// createAgentRuntime({ ...existingConfig, invocations: receiptConfig });

const page = await invocations.read({
  conversationId: 'evaluation-42', limit: 100,
}, authenticatedContext);
// Continue with fromSeq: page.nextSeq while it is defined.
```

`complete` returns `invocationId`, `outcome` and, on success, `text`. `duplicate`
returns the existing ID without provider execution or replaying plaintext. Changed
input, trace or caller under the same key is a conflict. Model attempts are explicit
and bounded to eight; SDK retries are disabled. Only provider failures trigger the
next attempt. Abort, audit refusal and configuration errors do not. An admitted call
whose process died stays claimed: inspect the receipt before choosing a new key.

The agent retains its existing prompt, tools and loop. Both modes produce
`invocation/started`, `provider/request`, `provider/response` and
`invocation/finished` records through `read`. Each attempt has its own ID; response
usage is counted once and keeps `unavailable` distinct from measured zero. Requested
registry selection, sent adapter identity and effective provider identity are
separate. Effective identity is null when not reported. A gateway's hidden retries
are not visible as additional attempts.

`currentModelInvocationAttempt()` returns the causal context inside a provider's
`fetch` callback or streamed read. Use it to join a host transport audit to
`operationId`, `invocationId`, `attemptId`, optional `runId` and `step`.
`onAttempt` is an awaited admission hook; a rejected hook prevents the provider call.
SDK payload hashes are not represented as hashes of HTTP wire bytes.

Prompts and output are AES-256-GCM payload artifacts. `read` returns references and
hashes; `readPayload({ conversationId, artifactId }, context)` separately authorizes
`read-payload` and checks authentication tags and hashes. `payloadKey` must be 32
bytes and is copied at construction. Keep it outside the event store/backups; retain
the appropriate key when restoring an archive. `maxPayloadBytes` defaults to 4 MiB
(range 1 KiB–64 MiB). Streaming output evidence keeps parts up to half of it; past
that the evidence carries `truncated: { parts, sha256 }` over the whole stream, and
the answer itself is never cut. A receipt that cannot be written never changes an
outcome: `complete()` returns it with `receiptError` beside it, and a succeeded
answer keeps its `text`. Request headers
are excluded. Protect existing agent history independently: enabling receipts does
not encrypt its ordinary conversation records.

The built-in memory and SQLite stores implement `appendEventOnce`. Custom stores
must implement its once-only check and append atomically, returning the original
event on a duplicate key scoped by conversation and kind. A factory without this
capability refuses receipt setup. The driver-backed implementation finds the keyed
event by its identity through the driver's optional `events.find` — one index probe
in SQLite — so admission costs the same on a long conversation as on a new one; a
custom driver without `find` falls back to paging the log in one transaction.
`store.findEventOnce({ conversationId, kind, key })` reads the same event back.
Invocation payload artifacts are keyed by their artifact id the same way.

### Migration from direct SDK completions

**Who must act:** applications opting into shared completion/agent receipts.
Replace the direct `generateText` call with `invocations.complete`, configure host
authorization and a retained payload key, and pass the same ledger in the agent's
`invocations` option. Use the returned invocation ID and the paginated `read` method
for receipt queries; do not import runtime internals. Available since 0.100.0;
existing runtime configurations need no migration.
