# Every host call leaves a receipt: how OMD runs TypeScript mods it doesn't trust

Published 2026-10-30. URL: https://ohmydiff.me/blog/host-call-receipts

A mod in Oh My Diff (OMD) is a TypeScript package that can run commands, write files, store data and draw panels
inside Codex. If that code ran in the Codex process, a mod that loops, throws or runs out of memory would take the
session with it. So each callback runs in a sandboxed V8 process, and every effect it causes is a request to a Rust
host, which checks the request, performs it and records a receipt. A failing mod loses one callback. After five
failures in a row the host turns the mod off, and the Codex session keeps going.

The host uses those receipts twice: to answer matching calls when retrying a callback after a state conflict,
and to check the runner’s report before accepting its result.

This post describes OMD 1.4.0 on Codex `0.166.0`. OMD is beta and runs on Apple Silicon macOS only,
because the runner sandbox is Seatbelt, the macOS sandbox. Every measurement below comes from an older build on one
Mac, none from `0.166.0`, and each gives its conditions. The authority broker and the code cache aren't covered.

## A mod callback

Here is the body of the `check` tool from the example mod
[`release_check`](https://github.com/dzkchen/oh-my-diff/blob/main/mods/examples/release_check/index.ts). The model
calls the tool, and the handler snapshots the workspace, runs a check, writes evidence and asks to complete the task:

```ts
    const candidate = api.host.call('workspace.snapshot', {}).candidate_sha256;
    const sources = api.host.call('workspace.list', { path: 'src', recursive: true });
    const run = api.host.call('exec.run', { program: 'sh', argv: ['-c', 'test -d src'], timeout_ms: 5000 });
    api.host.call('workspace.snapshot', { expected: candidate });
    const evidence = api.host.call('evidence.write', {
      media_type: 'application/json', label: 'check run', content: { exit_code: run.exit_code, files: sources.entries.length },
    });
    const previous = api.host.call('store.get', { key: 'runs' });
    api.host.call('store.put', {
      key: 'runs', value: Number(previous?.value ?? 0) + 1, expected_revision: previous?.revision ?? null,
    });
    api.host.call('receipts.submit', {
      kind: 'checks', candidate_sha256: candidate, outcome: run.exit_code === 0 ? 'pass' : 'fail', evidence,
    });
    return api.host.call('task.complete', { candidate_sha256: candidate });
```

The handler never opens a file or starts a process. Each of its nine `api.host.call`s, typed in
[`mods/api.d.ts`](https://github.com/dzkchen/oh-my-diff/blob/main/mods/api.d.ts), becomes a JSON frame on the
runner's stdout, and the host answers on its stdin. A call outside the capabilities the manifest declares fails
before the operation runs.

`receipts.submit` in this snippet records a workflow-contract receipt, which is a different feature. In the rest of
this post, a receipt is the host's record of one host call: the call and its value or error.

## Host and runner

![Diagram: Codex core and TUI exchange calls with codex-omd-extension, which talks to the codex-omd host. The host exchanges call frames and receipts with omd-runner, V8 under Seatbelt, which runs the TypeScript mod.](/blog/img/host-call-receipts/diagram-1.svg)

`codex-omd` is the host: packages, state, grants, budgets and the runner protocol. `codex-omd-extension` connects it
to Codex threads, turns and tools. `mise run native:ownership` fails if native code names a feature or a first-party
mod, or if `codex-omd` depends on the extension or on Codex crates
([runtime README](https://github.com/dzkchen/oh-my-diff/blob/main/runtime/README.md)). Feature code stays in mods; the host adds only operations useful to several unrelated mods. The Rust code reaches Codex as
[one patch against tag `rust-v0.166.0`](https://github.com/dzkchen/oh-my-diff/blob/main/docs/how-omd-works.md#omd-is-codex-with-one-patch).

The runner is a separate binary, `omd-runner`, that embeds V8. The host starts it through `/usr/bin/sandbox-exec`
with a `(deny default)` profile that allows exec of the runner only, reads of system library paths, writes to
`/dev/null` and no network, with a cleared environment
([`process.rs`](https://github.com/dzkchen/oh-my-diff/blob/main/runtime/omd/src/mods/process.rs)).

## Replay mode and interactive host calls

The simplest way to let sandboxed code call a host is to stop it at the call and run it again with the answer. OMD
did that until 2026-10-01, and offline runners still do. In this replay mode the runner throws a private
`suspension` value at a call it has no answer for. The host performs the operation, then starts the callback again
from the top, in another Node process at the time, with the receipts so far. This is the
replay path in [`bootstrap.js`](https://github.com/dzkchen/oh-my-diff/blob/main/runtime/omd/runner/src/bootstrap.js):

```js
  const index = calls.length;
  calls.push(call);
  if (index < receipts.length) {
    const receipt = receipts[index];
    if (JSON.stringify(canonical(receipt.call)) !== JSON.stringify(canonical(call))) throw new Error('host_call_replay_diverged');
    if (receipt.error) return { ok: false, error: receipt.error };
    return { ok: true, value: structuredCloneFallback(receipt.value) };
  }
  pending = call;
  throw suspension;
```

A callback with N host calls cost N + 1 runs. For `check`:

| Run | Receipts passed in | Calls answered from receipts | Run ends at |
| --- | ---: | --- | --- |
| 1 | 0 | none | suspends at `workspace.snapshot` |
| 2 | 1 | `workspace.snapshot` | suspends at `workspace.list` |
| 3 | 2 | the first two | suspends at `exec.run` |
| 10 | 9 | all nine | returns |

Commit [`6bee6cff`](https://github.com/dzkchen/oh-my-diff/commit/6bee6cff) (2026-10-01) added interactive host
calls. When a call has no recorded receipt, the runner writes a frame and waits in `exchange` for the host’s answer.
The callback can then continue without restarting:

```js
  if (request.interactive) {
    const receipt = JSON.parse(exchange(JSON.stringify({ call, updates })));
    return receipt.error ? { ok: false, error: receipt.error } : { ok: true, value: receipt.value };
  }
```

The host sets `interactive: true` on every callback it runs. Both modes read the same `receipts` array, and the
state retry below reuses it.

The same commit added a spare runner, a booted process waiting for the next callback. Together the two changes cut
`/mods` in OMD+, the full install profile, from 770 ms to 296 ms p50 (p95 814 to 310 ms), against 11 ms for upstream
`/status`. That run used release builds on Codex `rust-v0.159.1` with the Node runner and its 32-call limit, one
Apple Silicon Mac, a 110×36 synthetic PTY with a mock model, and 10 interleaved launches per arm. It doesn't separate
the two changes. The spare runner cost 68 MiB of idle memory and failed a 60 MiB gate, which was overridden.

## Per-call checks and limits

This is one callback of the `check` tool, with the commit at the end:

![Sequence diagram of one callback of the check tool. Codex asks the host to run release_check/check. The host admits it (source, grants, generation) and sends the runner a request with state, payload, empty receipts and interactive set to true. For each api.host.call, the runner sends the call and updates; the host checks the count, capability and cancellation, performs the operation, appends a receipt, charges budgets and returns the receipt. The runner replies with value, state, registrations and calls. The host checks that the calls match the receipts and that registrations are unchanged, revalidates authority and compare-and-swaps the state. On a state_conflict under 5 seconds it reruns the callback with the recorded receipts; otherwise it returns the tool result to Codex.](/blog/img/host-call-receipts/diagram-2.svg)

The host-call loop in
[`bridge.rs`](https://github.com/dzkchen/oh-my-diff/blob/main/runtime/omd/src/mods/bridge.rs) checks each frame in
this order:

1. The call count. Past 512, the call fails with `host_call_limit`.
2. The capability. The call fails with `capability_denied: X` if the package isn't trusted for `X`,
   `grant_expired: X` if its grant is stale, or `capability_withheld: NS` if policy withholds the namespace.
3. Cancellation. A callback the user cancelled, or whose mod was disabled, gets `callback_cancelled`.
4. Room for the result. The host performs the operation and builds the receipt. A result too large for the
   remaining receipt budget becomes an error that reads "the operation ran, request a smaller page or an artifact".
5. The execution clock, checked after the receipt is recorded.

A failed reply therefore doesn't prove the operation had no effect, and authors should page large reads or ask for
an artifact reference instead of a large value.

The callback has 5 s for JavaScript execution and time spent waiting on terminal or composer operations that need
no user answer. Calls that wait on the user and other host operations keep their own
timeouts. A nested call to another mod's service shares the caller's call count, receipt bytes and 5 s.

| Limit | Value | Where | When exceeded |
| --- | --- | --- | --- |
| Host calls per callback | 512 | `MAX_HOST_CALLS`, `runtime/omd/src/mods.rs` | `host_call_limit` |
| Receipt bytes per callback | 1 MiB | `HOST_RECEIPT_BYTES`, `mods.rs` | `host_result_limit` or `host_receipt_limit` |
| JavaScript plus surface waits | 5 s | `MOD_CALLBACK_TOTAL_BUDGET_MS`, `mods.rs` | `callback_execution_budget` |
| Callback's own JavaScript | `hook_budget_ms`, 25 to 2,000 ms, default 1,000 | `mods.rs`, `process.rs` | `callback_timeout` |
| Service call depth | 8 frames | `MOD_SERVICE_DEPTH_LIMIT`, `mods.rs` | `service_depth_limit`, `service_call_cycle` |
| Failed callbacks in a row | 5 | `MOD_FAULT_LIMIT`, `mods.rs` | mod off until reload; budget errors and `state_conflict` don't count |

## Memory and time limits

The runner starts V8 with `--disallow-code-generation-from-strings`, refuses WebAssembly code generation and sets a
64 MiB old-generation limit. When the heap runs out, an OOM handler calls `_exit(134)`, and the host reports
`mod_out_of_memory`.

The heap flag doesn't bound `ArrayBuffer` memory. So a host watchdog thread polls each runner's peak physical
footprint every 50 ms while a callback is in flight, and the host checks it again on every reply. A runner past
512 MiB gets `SIGKILL`.

`hook_budget_ms` limits the callback's own JavaScript. The host enforces it from outside with a deadline of the
budget plus 250 ms, pushed back by the time spent answering calls. The runner's watchdog thread calls V8's
`terminate_execution` when module evaluation overruns.

Crashes and memory exhaustion are the main reasons mods run in separate processes. In an experiment on 2026-10-11, heap exhaustion in a V8
worker ended the whole process with a fatal signal. The same failure inside the host would take Codex down with the mod. In a
separate runner it costs one callback, and only repeated failures reach the
[fault limit](https://github.com/dzkchen/oh-my-diff/blob/main/mods/reference/host-model.md#fault-limit).

## Fresh contexts and per-package runners

A runner runs one callback in a fresh V8 context and prepares the next one before it reads another request, so
module globals don't survive between general callbacks. The host keeps runners warm to avoid launch cost, but a
runner serves one package only.

I kept the process and dropped the context on purpose, because retained closures would make module globals a second
persistence system that the host can't see, retry or check. I also tested a cache that reused each mod's
registrations. Registration code can read `Date.now()`, and in the test fixture the cached table omitted a
time-dependent admission interceptor, the guard that decides whether a callback may run, without any error, so I
rejected the cache. Render hooks are the one exception: their runner stays activated while the package, settings
and saved state are unchanged, and the reference warns that module variables may survive between renders.

## State conflicts and retries

`api.state` commits use compare-and-swap. Take the
[`counter`](https://github.com/dzkchen/oh-my-diff/blob/main/mods/examples/counter/index.ts) example, open in two
sessions. Both read revision 7, both compute 8, and one commits first. The other session's commit returns
`state_conflict`, and the host reruns that callback against fresh state.

If `check` also set `api.state` and lost the race, a rerun from scratch would write evidence and submit a receipt a
second time. So the host passes the first attempt's receipts into the rerun, and each call that matches its record
returns the recorded value without reaching the host. `Date.now()` returns the callback's start time plus the time
slept, so a timestamp in a call's arguments matches on the rerun. If the rerun diverges, the commit loop in
[`mods.rs`](https://github.com/dzkchen/oh-my-diff/blob/main/runtime/omd/src/mods.rs) chooses by what the first
attempt did:

```rust
        // A retry replays recorded host results; a divergent replay of read-only calls runs fresh.
        if replaying && reply.is_err() {
            if replay.iter().all(read_only_receipt) && started.elapsed() < budget {
                replay.clear();
                continue;
            }
            reply = Err(failure("state_conflict: reload before retry"));
        }
```

| Attempt | Receipts passed in | Outcome |
| --- | --- | --- |
| 1 | none | performs every call, commit returns `state_conflict` |
| 2, calls match | attempt 1's | calls answered from receipts, commit on fresh state |
| 2, diverges, all recorded calls read-only | attempt 1's | receipts cleared, callback runs fresh |
| 2, diverges, a recorded call wrote | attempt 1's | `state_conflict: reload before retry` |

Every attempt stops when the callback's 5 s budget is spent. `read_only` is an explicit list of operations, which
includes `exec.run` only when it requests no writes, network, sockets or unsandboxed mode. In `check`,
`workspace.list`, the `exec.run` of `sh -c 'test -d src'` and `store.get` are read-only. `workspace.snapshot`,
`evidence.write`, `store.put`, `receipts.submit` and `task.complete` aren't, so a divergent rerun of `check` takes
the last row. The host doesn't roll back effects a host call already performed. If the host rejects completion, it discards the callback’s proposed state and result; earlier effects remain.

State commits use ordered writes instead of forcing every write to stable storage. `api.state` and thread state use `F_BARRIERFSYNC` on macOS, which
cut a commit from 8.0 ms with two `F_FULLFSYNC` calls to 0.29 ms in a release runner microbenchmark on one Apple M5
Mac on 2026-10-27 (load about 3; run count and spread not recorded). Since
[`a5496189`](https://github.com/dzkchen/oh-my-diff/commit/a5496189) (2026-10-29), `store.put` uses the same ordered
write. A power failure can lose the last commits. Evidence is still fully synced.

## Checks at completion

When a callback returns, the runner reports its result, state, registrations and the calls it made. The host rejects
the reply with `invalid_host_call_receipts` if the reported calls differ from the calls the host answered, or if the
reply still names a pending call. It rejects `registration_changed_during_callback` if the mod's registrations
changed.

Before accepting the callback’s state or result, the host checks its permissions again. A tool can wait minutes
for an agent or a user answer. If someone cancels the callback, disables the mod, revokes a grant or replaces its
source during that wait, the host rejects completion. A changed runtime generation also invalidates the result
([host lifetime and callback authority](https://github.com/dzkchen/oh-my-diff/blob/main/mods/reference/host-model.md#host-lifetime-and-callback-authority)).

## Interceptor order

Interceptors let a mod wrap native operations such as tool calls, model requests or other mods' host calls.
Ordinary interceptors wrap required ones, and within each tier the order is explicit `before`/`after`, then
dependencies, then mod ID, then registration order. The mod runtime is single-threaded, and an interceptor locks it
only while its handler starts or settles, never while `next` waits, so a nested operation can't deadlock on it.

A required interceptor that fails, or a runtime still busy after 5 s, fails the operation closed with
`intercept_required_failed`. After 2 s busy, the operation runs without its optional interceptors and logs
`intercept_skipped`
([runtime waits](https://github.com/dzkchen/oh-my-diff/blob/main/mods/reference/host-model.md#runtime-waits-and-hook-skips)).
A mod never intercepts its own host calls, and `sessions.*` and `evidence.*` calls can't be intercepted at all
([`host_operation` interceptors](https://github.com/dzkchen/oh-my-diff/blob/main/mods/reference/admission-and-interception.md#host_operation-interceptors)).

## Render hooks

`release_check` also registers a `render` interceptor that turns a native list into rows. Render callbacks get no
host operations, and a state write from one fails with `render_side_effect_refused`. A hook that throws, times out or
returns an invalid tree is skipped, and the native drawing is kept. With no `render` interceptor registered, the host
never calls a mod while drawing. I have no measurement of render cost with many mods active.

## API stability

Mod API `1.0.0` is stable, but only part of it is frozen.
[`mods/conformance/contract.json`](https://github.com/dzkchen/oh-my-diff/blob/main/mods/conformance/contract.json)
lists 365 surfaces, 235 frozen and 130 experimental, and `scripts/mod_conformance.py` checks the file against the
host source. Frozen shapes hold through 2027-10-19, and a breaking change needs a major version and a migration
note
([stability and migration](https://github.com/dzkchen/oh-my-diff/blob/main/mods/reference/stability-and-migration.md)).

## Replacing Node with omd-runner

Until 1.0.1 each runner was a Node process. [PR #266](https://github.com/dzkchen/oh-my-diff/pull/266) replaced it
with `omd-runner`, shipped in [OMD 1.0.1](https://github.com/dzkchen/oh-my-diff/releases/tag/v1.0.1) on
2026-10-20. On that date a harness spawned the release `omd-runner` and Node 24.18.0 the way `process.rs` does and
drove four first-party mods, on one M-series Mac (10 cores, 32 GB, load 2.6 to 4.5). Each figure is the median of 3
interleaved rounds, with no spread recorded. Memory is the physical footprint (`ri_phys_footprint`).

| Runner | Idle footprint | Spawn to ready | Reused callback p50 |
| --- | ---: | ---: | ---: |
| Node 24 | 41.1 MB | 48.5 ms | 1.50 ms |
| `omd-runner` | 6.2 MB | 26.4 ms | 1.27 ms |

Bare Node used 11.5 MB, and warming its TypeScript stripper at spawn added 23 MB to every runner. Reused callbacks
were already fast under Node, so the switch changed them little. The 1.0.1 changelog's "about 43 MB" and "about 6 to
15 MB" per runner come from a different slice, nine runners in an end-to-end PTY run, and its "about twice as fast"
rounds the 1.84× spawn-to-ready ratio.

## Mod command latency and memory

These runs are from 2026-10-27 and 28, with release builds of the branch that became
[PR #326](https://github.com/dzkchen/oh-my-diff/pull/326) and of `main` before it, based on Codex `0.164.0`, against
stock upstream `rust-v0.164.0`. The machine was one Apple M5 Mac with four performance cores at load 1.8 to 2.6. The
harness ran inside Kitty (110×36) and an Orca terminal (109×55) with 10 interleaved repeats, 12 for the callback
probe, a `/latency` command from a test mod. OMD Core is the minimal install profile.

| p50 (p95) | Kitty | Orca |
| --- | ---: | ---: |
| Fresh mod callback, before PR #326 | 7.9 (9.8) ms | 8.0 (9.4) ms |
| Fresh mod callback, after | 2.2 (2.7) ms | 1.7 (2.2) ms |
| Reused callback, after | 3.5 ms | 2.2 ms |
| Upstream `/status`, a native command | 8.8 ms | 8.9 ms |
| Ready to type, OMD+ after / upstream | 39.3 / 43.7 ms | 44.3 / 46.1 ms |
| Footprint 10 s after start, OMD+ / OMD Core | 96.7 / 65.8 MiB | 98.3 / 66.3 MiB |

Most of the drop came from drawing. In one Kitty trace before the change, the path from Enter to the history cell
took 2.5 ms, of which the runner's part was 0.9 ms. The result frame then waited about 6 ms for the 120 fps frame
limiter, and upstream `/status` waits on the same limiter. PR #326 draws a mod command's result as soon as its
history cell is inserted. The same branch cut runner start to a prepared context from 31.7 ms to 7.2 ms by removing
978 KB of weak binds with a linker flag, measured by traces and instruction counts on the same Mac.

OMD uses more memory than upstream. A review of the same PR recorded 98 MiB for OMD+ against 27 MiB for upstream
10 s after start, with OMD+ from synthetic-PTY runs at load 2 to 10 and no conditions given for upstream. After 60 s
idle, reused runners retire and OMD+ fell to 59.8 MiB. The
[cost figures in How OMD works](https://github.com/dzkchen/oh-my-diff/blob/main/docs/how-omd-works.md#what-mods-cost-in-memory-and-startup-time),
from release builds on Codex `0.165` and `0.166`, agree: about 100 MB for OMD+ as installed, with each `omd-runner`
at 7 to 10 MB. Their "about 0.3 s" ready to
type is close to PR #326's synthetic-PTY runs (296 to 304 ms p50), which include about 250 ms of terminal-query
timeouts. I haven't measured a single host-call round trip or an in-process baseline.

## Alternatives I ruled out

Codex `0.166.0` hooks are external commands that return a decision. A hook can't hold state, stream, draw, route a
model or run an agent, and when two `pre_tool_use` hooks rewrite the same call, the last one to finish wins. OMD's ordered
interceptors avoid that race. Hooks and plugins remain the right tool for a reaction to an event or extra tools.

For the engine, a tuned Node runner reached 17.5 MB idle in the 2026-10-20 prototype runs but kept Node as an
install requirement. Bun 1.4.2 lacked the TypeScript-stripping API the runner needed and didn't enforce the heap
flag. QuickJS lacks `Intl`, which first-party mods use for `Intl.Segmenter`. A V8 isolate inside the host would have
the fatal OOM behavior above. I rejected each on one named requirement, so an engine that met it would reopen the
comparison. I didn't evaluate WebAssembly, Lua or in-process native plugins.

## Open problems

In a synthetic PTY on 2026-10-28 at load about 10, the first mod command after about 60 s idle took about 23 ms,
against about 7 ms ten seconds after launch, with runner retirement turned off. The candidates are CPU power state, a
2 s authority refresh and the 60 s warm-runner reap, and I haven't traced it on a quiet machine. The second open problem is Linux: the host refuses to run mods off macOS because no runner sandbox exists there, and Linux support is on the
roadmap. To write a mod against this host, start with
[Write your first mod](https://github.com/dzkchen/oh-my-diff/blob/main/docs/mod-authors/first-mod.md).
