Five event types that make Claude Code, Codex and a raw provider SDK look identical to the app consuming them.
TypeScript
type AgentEventType = "delta" | "thinking" | "status" | "done" | "error";
The five type values are stable
New information arrives as optional fields on the existing types, never as a new
type, so a client written against this list stays correct.
The envelope is deliberately tiny. Anything specific to one runtime is normalised away
before it reaches you.
AgentEvent#
TypeScript
interface AgentEvent {
type: AgentEventType;
text?: string; // `delta`: the new text. `thinking`: the RUNNING TOTAL
message?: string; // `status` / `error`: human-readable, safe to show a user
tool?: string; // on a `status` raised by a tool: a stable slug
usage?: RunUsage; // on `done`
tier?: CostClass; // on the opening `status`
}
| Type |
Carries |
Notes |
delta |
text |
The new text only. Append it |
thinking |
text |
The running total of reasoning text. Replace it |
status |
message, tool?, tier? |
Progress, safe to display |
done |
usage? |
Terminal |
error |
message |
Terminal |
The delta / thinking asymmetry is the one thing to get right in a client:
delta appends, thinking replaces.
TypeScript
function isTerminal(e: AgentEvent): boolean // done | error
The opening status carries the tier#
JSON
{"type":"status","message":"Starting","tier":"user-subscription"}
Announced before any output. A cheaper tier covering for a sleeping laptop will
visibly underperform, and a visible downgrade beats a silent one. See
Cost classes.
Adapters map their runtime's native tool names onto these, so the app never needs to
know whether it is talking to Claude Code or Codex:
TypeScript
const TOOL_SLUGS = [
"search", "fetch", "read", "write", "edit", "bash",
"files", "agent", "todo", "think", "sources", "tool",
] as const;
Present on a status raised by a tool; absent on non-tool statuses. Map them to
icons.
RunUsage#
TypeScript
interface RunUsage {
costClass?: CostClass; // who pays for this run
costMicros?: number; // absent when nothing was spent
model: string; // provider-neutral alias, e.g. "sonnet", "gpt-5-codex"
provider?: string; // needed to price it, since aliases collide
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
webSearches: number;
}
usage is absent on done when the runtime cannot report it. A
subscription-backed CLI often can't, which is exactly why those runs are free.
costClass is present so a usage frame is self-describing: a meter should never have
to remember what it routed to in order to know whose money was spent.
A whole run#
JSON
{"type":"event","runId":"r_01","event":{"type":"status","message":"Starting","tier":"user-subscription"}}
{"type":"event","runId":"r_01","event":{"type":"status","message":"Reading README.md","tool":"read"}}
{"type":"event","runId":"r_01","event":{"type":"delta","text":"Soba routes agent runs "}}
{"type":"event","runId":"r_01","event":{"type":"delta","text":"to compute the user owns."}}
{"type":"event","runId":"r_01","event":{"type":"done","usage":{"model":"sonnet","costClass":"user-subscription","inputTokens":1840,"outputTokens":210,"cacheReadTokens":0,"cacheWriteTokens":0,"webSearches":0}}}