What is ODIF and how does it aid AI agents?
ODIF is the oam Diagnostic Interchange Format. It is one versioned envelope for everything oam reports: parse errors, failed imports, type errors, uncaught exceptions, test results and install events. Pass --json and every diagnostic arrives on stderr as one JSON object per line, while your program's own output stays untouched on stdout.
That is the whole format. What makes it useful to an agent is not the JSON — plenty of tools print JSON — but three promises behind it: the codes are stable, the severity is authoritative, and the human-readable output is rendered from the same object, so the two can never disagree. An agent that reads ODIF branches on fields. An agent that reads stderr from Node, Deno or Bun pattern-matches on prose written for people, and that prose is free to change between releases.
This post covers what a line carries, how an agent should consume it, two assumptions that will catch an agent out, and the parts of the envelope that are designed but not shipped yet. At the end there is a short appendix on a question that comes up beside this one: which JavaScript engine sits under each runtime.
One line, one diagnostic
Here is a file that imports something that does not exist:
// missing.ts
import { x } from "./nope.js";
console.log(x);
Run it under oam v0.17.1 with oam --json run missing.ts, and it exits 1 with two lines on stderr (absolute paths shortened here; everything else is verbatim):
{"odif":"1","code":"OAM-MOD0001","severity":"error","origin":"resolve","message":"cannot resolve './nope.js' from missing.ts (tried ./nope.js, ./nope.ts, ./nope.tsx)","docs":"https://oamjs.org/docs/errors#OAM-MOD0001"}
{"odif":"1","code":"OAM-TS2307","severity":"error","origin":"typecheck","message":"Cannot find module './nope.js' or its corresponding type declarations.","spans":[{"file":"missing.ts","start":{"line":1,"col":19},"end":{"line":1,"col":19}}],"docs":"https://oamjs.org/docs/errors#OAM-TS"}
Two subsystems saw the same mistake. The module resolver reported it as OAM-MOD0001 and listed the paths it tried. The TypeScript checker reported it as OAM-TS2307, which is TypeScript's own TS2307 passed through with oam's prefix. Same envelope, same stream, no second parser.
What is in the envelope
| Field | What it carries |
|---|---|
odif | Envelope version, "1" today. Gate on it. |
code | Stable namespaced code, such as OAM-RT0005. |
severity | "info", "warning" or "error". |
origin | The subsystem that raised it. |
message | Human-readable detail. May be multi-line. |
spans | Source locations, 1-based. Omitted when empty. |
docs | The reference page for this code, anchored at it. |
There are six origins, and each maps to one code family. There is one exception today: OAM-TEST0003, a test run that crashed or never settled, arrives with origin "runtime".
| origin | Family | Raised by |
|---|---|---|
parse | OAM-PARSE* | oxc, parsing and stripping your source |
resolve | OAM-MOD* | module resolution and loading |
typecheck | OAM-TS* | the tsgo (TypeScript 7) checker |
runtime | OAM-RT* | the running program, in V8 |
test | OAM-TEST* | oam test |
install | OAM-PKG* | oam install |
oam's own codes are zero-padded to four digits. A TypeScript code keeps TypeScript's number, so a leading zero means oam raised it and no leading zero means TypeScript did: OAM-TS0004 means tsgo itself exited abnormally, while OAM-TS2322 is an ordinary type mismatch in your code. The full list lives in the code reference, and the field-by-field spec in the ODIF docs.
Why an agent should read this instead of stderr
- Branch on the code, not the wording. Codes are stable, so logic written against
OAM-MOD0001survives a reworded message. Codes are also deliberately coarse: one code covers a class of failure, and the message says which member of the class you hit. - Route on the origin. A resolve error means fix an import; a typecheck error means fix a type. An agent can send each to the right handler without reading the message at all.
- Gate on severity. Severity is authoritative. The
oam testsummary,OAM-TEST0000, is"info"when everything passed and"error"when anything did not, so a consumer never has to parse pass counts out of prose. - Locations point at your source. A span names a file and a 1-based start and end, and for a transpiled
.tsfile it is the position in the TypeScript you wrote, not in the generated JavaScript. - Every line links its own explanation. Each line oam emits carries a
docsURL. TypeScript codes land on the#OAM-TSfamily anchor, because there are thousands of them and they are TypeScript's to define. - The pretty output is the same object. Without
--json, oam renders the same diagnostic asseverity[CODE]: message (file:line:col), with the location only when there is one. An uncaught exception,OAM-RT0005, is printed in Node's own format instead, so the stack stays readable. Either way, a human and an agent watching the same run are looking at the same facts.
One rule for consumers: parse line by line, and keep any line that is not valid JSON rather than dropping it. A panic is not an ODIF diagnostic, and a non-zero exit with zero diagnostics still has to be explainable by something.
Two things an agent must not assume
A zero exit code does not mean the types are clean. By default, oam run type-checks concurrently and never blocks execution. In that default, warn mode, a type error does not change the exit code. This file runs, prints str, and exits 0:
// typed.ts
const n: number = "str";
console.log(n);
Stderr still carries the error, at severity "error":
{"odif":"1","code":"OAM-TS2322","severity":"error","origin":"typecheck","message":"Type 'string' is not assignable to type 'number'.","spans":[{"file":"typed.ts","start":{"line":1,"col":7},"end":{"line":1,"col":7}}],"docs":"https://oamjs.org/docs/errors#OAM-TS"}
An agent that gates on the exit code alone misses it. Read the severities, or run with --check=block, which checks first and refuses to execute when there are type errors. oam check runs the type-check alone and executes nothing.
Type diagnostics arrive after the program, not during it. The checker runs on its own thread while your program executes, but its results are written to stderr once the program has finished, after the run's own diagnostics. oam waits up to 10 seconds for a checker that is still going, a limit set by OAM_CHECK_WAIT_MS. If the check does not finish in time you get OAM-TS0005, a warning, instead of silence. If tsgo is not installed at all you get OAM-TS0000. In warn mode the run is never failed over it, while --check=block refuses to run without a checker.
There are no sequence numbers, stream IDs or timestamps in the envelope. Ordering is simply the order of lines on one stderr stream. In practice that is enough, because every line is self-contained: it names its own code, origin, location and docs page, so a consumer can act on each line without correlating it with any other.
What ODIF does not do yet
You may have read that ODIF carries "typed repair plans". The envelope is designed for them; they are not shipped. Three fields are declared in the v1 envelope type and populated by nothing in oam today, so they are absent from every line it emits:
repairs: an array of{"id","safety","edits","note"}, where each edit is a{"span","new_text"}pair andsafetyis"safe-auto"(mechanical and semantics-preserving),"review"(plausible, needs a human) or"unsafe-hint"(a hint, never an edit plan).fingerprint: a string that stays the same across runs for what is recognisably the same problem, for deduplication and flake correlation.related: secondary spans, such as "declared here".
The shapes are fixed now so that a consumer can be written to ignore these fields safely today, rather than to depend on their absence. Until repairs is populated, the fix is the agent's job. ODIF hands it the code, the origin, the location and a docs link: the input to that job, not the answer.
The agent loop today, over MCP
oam ships an MCP server that speaks ODIF natively. Register it with Claude Code:
claude mcp add oam -- oam mcp
It exposes four tools over stdio:
oam_checktype-checks a file or project, using a warm per-project tsgo daemon and falling back to a one-shot check, and returns ODIF diagnostics.oam_runruns a file under a time budget and returnsexitCode,timedOut,stdout,diagnostics(the parsed ODIF lines) andstderrRaw(any stderr line that was not JSON).oam_explainexplains a code from a table compiled into the binary, with no network.oam_project_inforeports the oam version, the nearesttsconfig.json, and whether tsgo is available.
The loop this is built for is check, fix, run. Call oam_run. For each diagnostic, branch on code and severity. For a resolve error, read the tried paths in the message and fix the import. For a type error, open spans[0].file at the given line and column. Ask oam_explain about any code you do not recognise. Its offline table covers most parse, resolve, runtime and test codes; for the ones it does not know yet, such as OAM-TS0005 and the OAM-PKG* install codes, use the code reference. Then run again, and stop when no diagnostic has severity "error" — not when the exit code is 0.
One exception to that stop rule: OAM-TS0000 (tsgo not installed), OAM-TS0004 (tsgo crashed) and OAM-TS0006 (tsgo timed out) are errors about the environment, not your code. No edit makes them go away, so report them instead of trying to fix them.
An agent without MCP gets the same data from a shell:
oam --json run app.ts 2>diag.jsonl
jq -r 'select(.severity == "error") | .code' diag.jsonl
To be precise about scope: the MCP server runs your file as a child oam --json run process and parses its stderr. It is not attached to a live isolate, so it does not report heap size, isolate health or diagnostics still pending inside a running program. That would be a different tool, and it is not on the roadmap.
Appendix: which engine is under each runtime
ODIF sits a layer above the engine, but the question that often comes up beside it is what each runtime actually runs JavaScript on. Short answer: Node, Deno and oam all run on V8. Bun is the one that does not.
| Runtime | Engine | Written in |
|---|---|---|
| Node.js | V8, via its C++ API | C++, with libuv for I/O |
| Deno | V8, via rusty_v8 | Rust, with Tokio for I/O |
| Bun | JavaScriptCore | Rust since 1.4, Zig before |
| Oam.js | V8, via rusty_v8 | Rust, with Tokio for I/O |
Bun runs on JavaScriptCore, the engine Apple develops for Safari. For packages that call V8's C++ API directly, Bun built its own translation layer from that API onto JavaScriptCore rather than shipping V8. The runtime around the engine was written in Zig until Bun 1.4, released in August 2026, which is a Rust rewrite.
Jarred Sumner, who created Bun, has given his reasons in his own words. In 2021 he wrote that "JavaScriptCore tends to start up & load/parse JS much faster than V8." In 2023 he described V8 as having a "fixed startup time cost that I wasn't excited about", and JavaScriptCore as "this really good balance of fast startup time, but with a very, very good JIT". In 2024 he named the WebKit monorepo, where browser and engine share one codebase, as one of the reasons, against V8 and Chromium's split codebase.
JavaScriptCore's design reflects that startup emphasis. It runs code through four tiers: the LLInt interpreter, which WebKit describes as having "zero start-up cost besides lexing and parsing", then a baseline JIT, the DFG low-latency optimizing JIT, and the FTL high-throughput optimizing JIT. A function moves up the tiers only as it gets hot. V8 is tiered too, and its Sparkplug baseline compiler was added in 2021 with short-lived sessions such as command-line tools in mind.
You will often read that V8 wins long-running CPU-bound work while JavaScriptCore wins startup and memory. That is how both projects describe their design emphasis. We did not find a neutral benchmark that settles it, so treat it as a design claim, not a measurement.
Deno runs on V8, through rusty_v8, the Rust bindings to V8's C++ API that the Deno team maintains. The crate now publishes as v8 and follows Chrome's version numbers. Deno also uses V8 startup snapshots to cut its cold start. Ryan Dahl's 2018 talk "10 Things I Regret About Node.js", which introduced Deno, was about the architecture around V8 — the module system, the build system, the security model. Deno kept V8 and rebuilt the rest.
oam runs on V8 the same way Deno does. v0.17.1 links the v8 crate's 150 series, uses Tokio for async I/O, and uses oxc to strip TypeScript before V8 sees it. The engine is shared with Deno and Node. What is oam's own is the layer above it, and ODIF is the part of that layer an agent touches first.
oam is beta: breaking changes before 1.0 are still possible and are called out in the changelog, and there is no LTS yet, so this is not the runtime for a service you are on call for.
Diagnostic type in crates/oam_diagnostics at tag v0.17.1. The --check modes, render order and MCP tools were read from that tag's oam_cli and oam_mcp sources. The sample runs are live output from an oam 0.17.1 binary, with absolute paths shortened. The engine appendix cites Bun's, WebKit's, V8's and Deno's own documentation and blogs, and Jarred Sumner's public posts and interviews. If oam emits a line that does not match this post or the ODIF spec, that is a bug — file it.