Agent events
The normalized vocabulary an agent session is stored in — TASK_EVENT_TYPES, the NormalizedEvent shape, the supported protocols, and how a harness line becomes events.
A delegated task runs a harness, such as Claude Code, Codex or a shell script, with a prompt on stdin. To show the session while it runs, the control plane reads the harness's stdout and stores what it says. The harness's own JSON is not stored as is: an output format is not an API. Persisting a CLI's shape would date the history the first time the vendor reshuffles a field.
Instead, one adapter per harness dialect translates each line into a small vocabulary of our own, and that vocabulary is what gets stored. Events written months ago are read by today's UI, so this vocabulary is a format like the others in this tab. Module reference: agent-stream.
Two rules hold for all of it:
- Anything unmapped becomes
raw, rather than being dropped. An unknown event is still information about the harness, and the UI keeps it behind a toggle. - A malformed line is never fatal. This parses a third-party CLI's output on a read path. Garbage in a line costs that line, not the task.
Protocols
export const AGENT_PROTOCOLS = ["none", "claude-json", "claude-stream", "jsonl"] as const| Protocol | The harness speaks | Notes |
|---|---|---|
none | anything | The project opts out of streaming. The session is not read as events. |
claude-json | Claude Code --output-format stream-json --verbose | One-shot: the harness exits after one prompt. applyProtocol appends the flags. |
claude-stream | The same output dialect, plus --input-format stream-json | Interactive: the harness stays alive between turns, so it can be answered mid-flight (userTurnLine) and interrupted (interruptLine). It uses the same adapter as claude-json. |
jsonl | Our vocabulary, natively | The escape hatch. A home-made agent prints one JSON object per line, with type set to one of the event types below. It keeps the vocabulary honest, because it is a format someone can write to, not just one we read from. |
applyProtocol(command, protocol) adds --output-format stream-json --verbose (plus
--input-format stream-json for claude-stream) to the project's command. It leaves the command
alone if it already contains --output-format, so a project that wants different flags keeps full
control by spelling them out. The platform appends the flags instead of asking projects to, because
getting them wrong fails silently. Without --verbose, Claude Code refuses stream-json, and
without --output-format, it produces prose that parses into nothing.
The vocabulary
export const TASK_EVENT_TYPES = [
"session.started",
"session.title",
"message",
"thinking",
"tool.call",
"tool.result",
"plan",
"usage",
"session.ended",
"session.background",
"raw",
] as const
export type NormalizedEvent = {
type: TaskEventType
payload: Record<string, unknown>
}It is deliberately short. It has to be translatable from several harnesses, so anything specific to one of them does not belong in it. These are the payloads, as the Claude adapter actually emits them:
| Type | Payload |
|---|---|
session.started | harness ("claude-code"), model, contextWindow, cwd, sessionId, tools (string array) |
session.title | title: the name the harness gave the conversation |
message | role ("assistant" or "user"), text, truncated |
thinking | text, truncated |
tool.call | callId, name, input (a string: JSON of the tool input), truncated |
tool.result | callId, isError, output (string), truncated |
plan | steps: an array of { text, status } |
usage | scope: "call": model, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, contextTokens. scope: "session": inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, costUsd, turns, durationMs. |
session.ended | status ("succeeded" or "failed"), stopReason, summary |
session.background | tasks: the whole current list of { id, kind, description } still running in the background |
raw | text (a non-JSON line) or json (an object no adapter could place) |
Missing values are null rather than absent. Each text field (text, input, output, summary,
plan step text, raw text) is clipped to 8,000 characters. A single tool.result can be a 2 MB file
read, and the timeline needs enough of it to be readable, not all of it. Where the payload has a
truncated flag, it is set, so the UI can say so instead of quietly misrepresenting what the agent saw.
Two kinds of usage
scope: "call"is read off each assistant message, and it describes what one model call consumed. Summed over a session, these events give what the session has spent, and they exist during a turn. The latest one alone gives the context:contextTokens = inputTokens + cacheReadTokens + cacheCreationTokensis the prompt that was just sent. Calls whose counters are all zero are skipped (the CLI emits those for an API error, with model<synthetic>).scope: "session"comes from the harness'sresultline. It is its own running total, cumulative over the whole process. With an interactive protocol, every turn ends with aresultthat restates the session so far, so readers take the latest one and never add them up.
summarizeUsage folds these into the recap a UI shows. It sums tokens from calls (and falls back to
the session total), and it takes cost, turns and duration from the harness only, because a platform
that invented a rate card would quote a number it cannot stand behind.
session.title is out of band for Claude
Claude Code names its conversations, but it does not print the name on stdout. The name only exists
in the session transcript, as {"type":"ai-title","aiTitle":…} lines. For claude-json and
claude-stream, sessionTitleProbe(protocol, sessionId) therefore returns a shell snippet to run in
the worker, and parseSessionTitle reads its output. A jsonl harness just prints
{"type":"session.title","title":"…"} in band.
From a line to events
import { parseStreamLine, parseLines, splitCompleteLines } from "@spunto/build/agent-stream"
const { lines, consumed } = splitCompleteLines(bytes) // whole lines only; advance the cursor by `consumed`
const events = parseLines("claude-stream", lines, budget) // SourcedEvent[]: each event + the line it came fromparseStreamLine(protocol, line) returns zero or more events. Zero is a normal answer: a blank line,
a JSON array, or null. parseLines attaches source (the trimmed line) to each event, which is what
makes normalization reversible. If the reading of a line is wrong, the line is still there to read again.
It also stops at budget events, and it skips a line whose adapter throws.
splitCompleteLines only consumes up to the last \n. A 3 KB event split across two reads is
parsed once, whole, instead of twice as two invalid halves.
Examples (Claude adapter)
These inputs are lines recorded from real sessions (shortened), taken from the package's tests.
{"type":"system","subtype":"init","cwd":"/workspace/demo","session_id":"ef45799e-d353-4d47-9cab-edac253e5cb4","tools":["Task","Bash","Read"],"model":"claude-sonnet-5","permissionMode":"bypassPermissions"}[
{
"type": "session.started",
"payload": {
"harness": "claude-code",
"model": "claude-sonnet-5",
"contextWindow": 1000000,
"cwd": "/workspace/demo",
"sessionId": "ef45799e-d353-4d47-9cab-edac253e5cb4",
"tools": ["Task", "Bash", "Read"]
}
}
]contextWindow comes from the package's built-in model catalogue only (contextWindowOf), so the
adapter stays a pure function. An unknown model gives null.
{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_01Su1gEqceMZJJCvrQCx71oT","name":"Bash","input":{"command":"cat README.md 2>/dev/null || ls"}}]}}[
{
"type": "tool.call",
"payload": {
"callId": "toolu_01Su1gEqceMZJJCvrQCx71oT",
"name": "Bash",
"input": "{\"command\":\"cat README.md 2>/dev/null || ls\"}",
"truncated": false
}
}
]{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_01Su1gEqceMZJJCvrQCx71oT","is_error":false,"content":"# demo\n\nDépôt bac à sable"}]}}[
{
"type": "tool.result",
"payload": {
"callId": "toolu_01Su1gEqceMZJJCvrQCx71oT",
"isError": false,
"output": "# demo\n\nDépôt bac à sable",
"truncated": false
}
}
]{"type":"assistant","message":{"model":"claude-opus-5","usage":{"input_tokens":4,"cache_creation_input_tokens":1183,"cache_read_input_tokens":41207,"output_tokens":126},"content":[{"type":"text","text":"C'est poussé."}]}}[
{ "type": "message", "payload": { "role": "assistant", "text": "C'est poussé.", "truncated": false } },
{
"type": "usage",
"payload": {
"scope": "call",
"model": "claude-opus-5",
"inputTokens": 4,
"outputTokens": 126,
"cacheReadTokens": 41207,
"cacheCreationTokens": 1183,
"contextTokens": 42394
}
}
]{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_todo","name":"TodoWrite","input":{"todos":[{"content":"Lire le README","status":"completed"},{"content":"Écrire STREAM.md","status":"in_progress"}]}}]}}[
{ "type": "tool.call", "payload": { "callId": "toolu_todo", "name": "TodoWrite", "input": "{\"todos\":[…]}", "truncated": false } },
{
"type": "plan",
"payload": {
"steps": [
{ "text": "Lire le README", "status": "completed" },
{ "text": "Écrire STREAM.md", "status": "in_progress" }
]
}
}
]A todo list is emitted alongside the tool call, not instead of it. The call is what happened, and
the plan is what it means. (The input string is shortened here.)
{"type":"result","subtype":"success","result":"STREAM.md créé.","num_turns":4,"duration_ms":8807,"total_cost_usd":0.0667396,"usage":{"input_tokens":8,"output_tokens":456},"stop_reason":"end_turn"}[
{
"type": "usage",
"payload": {
"scope": "session",
"inputTokens": 8,
"outputTokens": 456,
"cacheReadTokens": null,
"cacheCreationTokens": null,
"costUsd": 0.0667396,
"turns": 4,
"durationMs": 8807
}
},
{
"type": "session.ended",
"payload": { "status": "succeeded", "stopReason": "end_turn", "summary": "STREAM.md créé." }
}
]Any subtype other than "success" gives status: "failed".
A result ends the turn, not the work. If a turn leaves a background task running, the harness
wakes itself up for another turn when that task finishes. This line restates the whole set of live
background tasks on every change, with task_id, task_type and description mapped to id,
kind and description. It is the only way to tell whether a result really handed control back.
A line that is not JSON (a banner or a warning printed to stdout) becomes
{ "type": "raw", "payload": { "text": "<the line>" } }. A JSON object that the adapter does not
map (such as rate_limit_event or system/thinking_tokens, or whatever the CLI adds next) becomes
{ "type": "raw", "payload": { "json": { … } } }.
Example (jsonl passthrough)
{"type":"message","role":"assistant","text":"Done — tests pass."}[{ "type": "message", "payload": { "role": "assistant", "text": "Done — tests pass." } }]The type is lifted out, and every other key becomes the payload, unchanged. A type that is not in
TASK_EVENT_TYPES, or a missing type, gives raw with json.
In jsonl mode, payloads are passed through as written. Nothing clips them and nothing checks their
fields. The vocabulary fixes the event types, and the payload shapes above are what the Claude
adapter produces. A jsonl harness should follow them so that the UI renders its events the same way.
Compatibility
- Adding an event type is safe for stored history. Older events never carry it, and an older
reader that meets it should treat it like
raw. - Renaming or removing a type, or a payload key, makes stored events unreadable by the UI that
reads them. Add a new key instead, and read the old one as a fallback.
summarizeUsagealready does this: events stored beforecontextWindowexisted fall back to re-reading the model string. - A change in a harness's output is not a format change on our side. It is a failing adapter test (they replay recorded lines), and the fix belongs in the adapter.