← Blog

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

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. The model calls the tool, and the handler snapshots the workspace, runs a check, writes evidence and asks to complete the task:

    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.calls, typed in 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.

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). 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.

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).

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:

  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 (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:

  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.

The host-call loop in 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.

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 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 chooses by what the first attempt did:

        // 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 (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).

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). A mod never intercepts its own host calls, and sessions.* and evidence.* calls can't be intercepted at all (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 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).

Replacing Node with omd-runner

Until 1.0.1 each runner was a Node process. PR #266 replaced it with omd-runner, shipped in OMD 1.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 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, 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.