SpuntoSpunto/@spunto/build

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:

  1. 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.
  2. 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
ProtocolThe harness speaksNotes
noneanythingThe project opts out of streaming. The session is not read as events.
claude-jsonClaude Code --output-format stream-json --verboseOne-shot: the harness exits after one prompt. applyProtocol appends the flags.
claude-streamThe same output dialect, plus --input-format stream-jsonInteractive: 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.
jsonlOur vocabulary, nativelyThe 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:

TypePayload
session.startedharness ("claude-code"), model, contextWindow, cwd, sessionId, tools (string array)
session.titletitle: the name the harness gave the conversation
messagerole ("assistant" or "user"), text, truncated
thinkingtext, truncated
tool.callcallId, name, input (a string: JSON of the tool input), truncated
tool.resultcallId, isError, output (string), truncated
plansteps: an array of { text, status }
usagescope: "call": model, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, contextTokens. scope: "session": inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, costUsd, turns, durationMs.
session.endedstatus ("succeeded" or "failed"), stopReason, summary
session.backgroundtasks: the whole current list of { id, kind, description } still running in the background
rawtext (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 + cacheCreationTokens is 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's result line. It is its own running total, cumulative over the whole process. With an interactive protocol, every turn ends with a result that 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 from

parseStreamLine(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.

Example (jsonl passthrough)

input
{"type":"message","role":"assistant","text":"Done — tests pass."}
output
[{ "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. summarizeUsage already does this: events stored before contextWindow existed 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.
Edit on GitHub

On this page