Claude Code stream-json event cheatsheet
claude -p --output-format stream-json --input-format stream-json --verbose writes one JSON object per line (JSONL) with a required type field.
claude -p --output-format stream-json --input-format stream-json --verbose writes
one JSON object per line (JSONL) with a required type field.
In permission mode (without -p), the same flags apply but the prompt is sent via
stdin as JSON rather than as a CLI argument.
This cheatsheet is derived from humanlayer/claudecode-go/types.go and
client_test.go.
Top-level event lines
system (init)
Fields:
type:"system"subtype:"init"session_idtools: array of tool namesmcp_servers: array of{name, status}cwd,model,permissionMode,apiKeySource,output_style(optional)
Example:
{"type":"system","subtype":"init","session_id":"session_01","cwd":"/repo","model":"sonnet","permissionMode":"auto","apiKeySource":"env","tools":["Bash","Read","Write","WebSearch"],"mcp_servers":[{"name":"approvals","status":"connected"}]}
assistant / user
Fields:
type:"assistant"or"user"session_idmessage(see below)
Example (assistant text):
{"type":"assistant","session_id":"session_01","message":{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"Planning next steps."}],"usage":{"input_tokens":120,"output_tokens":45}}}
Example (assistant tool use):
{"type":"assistant","session_id":"session_01","message":{"id":"msg_2","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls -la"}}]}}
Example (user tool result, string content):
{"type":"user","session_id":"session_01","message":{"id":"msg_3","type":"message","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"total 2\nREADME.md\nsrc\n"}]}}
Example (user tool result, array content):
{"type":"user","session_id":"session_01","message":{"id":"msg_4","type":"message","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_2","content":[{"type":"text","text":"Task completed"}]}]}}
Example (assistant frame stopped by Anthropic’s safeguards, #814 — message.id,
stop_reason and stop_details are decoded since 0.35.5rc14):
{"type":"assistant","session_id":"session_01","parent_tool_use_id":null,"message":{"id":"msg_5","type":"message","role":"assistant","model":"claude-opus-5-5","content":[],"stop_reason":"refusal","stop_details":{"type":"refusal","category":"cyber","explanation":"…"}}}
stop_details.category ∈ cyber | bio | frontier_llm | reasoning_extraction |
general_harms | null. The CLI forwards the API message verbatim; one API response can
span several frames, so Untether dedupes on message.id. A frame truncated by an interrupt
carries aborted: true and no stop_reason.
Optional parent field (for nested tool usage):
{"type":"assistant","parent_tool_use_id":"toolu_parent","session_id":"session_01", ...}
result
Fields (success path):
type:"result"subtype:"success"(or"completion")session_idtotal_cost_usd,is_error,duration_ms,duration_api_ms,num_turnsresult: final answer stringusage: usage objectmodelUsage: optional per-model usage, keyed by model id; each entry carriescontextWindow(decoded since 0.35.5rc15 for the% ctxdenominator, #819)
Example (success):
{"type":"result","subtype":"success","session_id":"session_01","total_cost_usd":0.0123,"is_error":false,"duration_ms":12345,"duration_api_ms":12000,"num_turns":2,"result":"Done.","usage":{"input_tokens":150,"output_tokens":70,"service_tier":"standard","server_tool_use":{"web_search_requests":0}}}
Fields (error path):
-
Same as success, but
is_error:trueand an errorsubtype(e.g.error_during_execution, which can carry anerrorsarray). Untether keys success onis_error;subtypeis informational -
resultmay be empty or contain an error description -
API-error fields (decoded since 0.35.5rc20, CLI 2.1.289; all optional, typed
Any, so a type change upstream never drops the line):api_error_status: HTTP status of the API error that ended the turn (public SDK field)api_error_code: the server’serror.details.error_code, e.g.credits_required(@internal)api_error: the CLI’s typed kind, e.g.model_requires_usage_creditsorlong_context_credits_required(@internal; the CLI says the text “stays the fallback”)
The #701 action-required cap latch reads
api_error, thenapi_error_code, then theresulttext: ”… Switch to another model[, or manage usage credits at <url>,] to continue.” in headless mode, or the older “Run /usage-credits …” wording (#922). The long-context kind never latches: its remedy is different.
Example (error):
{"type":"result","subtype":"error","session_id":"session_02","total_cost_usd":0.001,"is_error":true,"duration_ms":2000,"duration_api_ms":1800,"num_turns":1,"result":""}
Turn-ending fields (decoded since 0.35.5rc14, all optional):
terminal_reason: why the turn ended.aborted_streaming/aborted_toolsmean it was interrupted; the subtype is then usuallysuccess, sometimeserror_during_executionwith an[ede_diagnostic]error, so classify a cancel onterminal_reason, never onsubtype/is_error(#806;CLAUDE_ABORTED_TERMINAL_REASONSinschemas/claude.py).origin: what started the turn, e.g.{"kind":"task-notification","producer":"session-task"}for a turn the CLI started itself (a background-task finish or anasyncRewakehook, #812). TypedAny: readers check it is an object first. The CLI also writes an empty no-query result (num_turns: 0,duration_api_ms: 0,result: "",origin.kind: "task-notification") for each notification it answered together with others, and for the agent hand-back notice — no model call, no cost (SDK docs,SDKResultMessage.origin). Untether absorbs these (#928).local_command: set when a local command (e.g./compact) finished without entering the agent loop; such a 0-turn result is never a no-query result (#928). TypedAny.stop_reason: passed through, typedAny.
Optional fields that may appear in upstream Claude Code CLI output but are not captured
by Untether’s StreamResultMessage schema:
error/errors: error description(s)permission_denials: array of{tool_name, tool_use_id, tool_input}
structured_output (arbitrary structured output) is captured by the schema but unused.
rate_limit_event
A quota-status snapshot, not a throttle notice (#790).
The CLI emits one whenever an API response moves the rounded utilization or a reset
time of the account’s subscription windows — so a healthy session sees a steady trickle
of status: "allowed" events. The event never terminates the session.
Real payload (captured on CLI 2.1.283, one-turn Haiku probe):
{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1790578200,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"out_of_credits","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0.09,"resetsAt":1790578200},"seven_day":{"utilization":0.15,"resetsAt":1791036000}}},"uuid":"…","session_id":"…"}
rate_limit_info fields (upstream zod, CLI 2.1.283):
| Field | Type | Notes |
|---|---|---|
status | "allowed" | "allowed_warning" | "rejected" | always present |
resetsAt | int (epoch s) | reset time of the window named by rateLimitType |
rateLimitType | five_hour | seven_day | seven_day_opus | seven_day_sonnet | seven_day_overage_included | overage | optional |
utilization | number (0–1) | optional; the warning window’s utilization |
unifiedWindows | {five_hour?, seven_day?, seven_day_overage_included?}: {utilization, resetsAt} | absent for API-key / Bedrock / Vertex sessions |
overageStatus | same 3 values as status | paid extra-usage state |
overageResetsAt | int | optional |
overageDisabledReason | enum (13 values, e.g. out_of_credits, org_level_disabled) | optional |
isUsingOverage | bool | rejected + isUsingOverage is not a throttle — extra usage covers it |
errorCode | "credits_required" | optional |
limitScope, surpassedThreshold, overageInUse, … | ignored by Untether |
The enum lists are mirrored as CLAUDE_RATE_LIMIT_STATUSES / CLAUDE_RATE_LIMIT_TYPES /
CLAUDE_OVERAGE_STATUSES in schemas/claude.py and pinned by the zero-token drift test
tests/test_claude_cli_schema_drift.py, which reads them out of the installed CLI.
Legacy shape. Earlier docs described requests_limit / requests_remaining /
requests_reset / tokens_limit / tokens_remaining / tokens_reset / retry_after_ms.
No real CLI has been observed sending these; the schema keeps them optional so such an
emitter still gets a precise countdown (#518).
Untether handling (_translate_rate_limit_event in runners/claude.py):
| Snapshot | Untether |
|---|---|
allowed | Snapshot only — unifiedWindows stashed on ClaudeStreamState.rate_limit_windows, DEBUG claude.rate_limit_snapshot. No note, no latch, no cumulative_s. |
allowed_warning | One ⚠️ 5h limit 85% used — resets 17:30 AEST note per (window, reset) when utilization ≥ 0.7 (or absent) and extra usage isn’t covering it. No latch. |
rejected, not isUsingOverage, resetsAt in the future | Throttle: note ⏳ Rate limited until 17:30 AEST (~30 min), rate_limit_wait_until latched to resetsAt (clamped to 24 h), extension-only accounting so repeats don’t double-count, and repeats update the same note. Beats the #692 result-text parse. |
rejected without resetsAt | Legacy timing if present → errorCode: credits_required / overage / seven_day_overage_included → #701 remedy title naming the cap kind (⛔ Fable limit reached — …, ⛔ Usage credits used up — …, ⛔ Monthly spend limit reached — …, ⛔ Team budget reached — …; #922) → #692 harvested reset → #701 latch → ⏳ Rate limited — waiting to retry (~60s). |
rejected with isUsingOverage, or resetsAt already past | Not a throttle (INFO log retry_after_source=covered_by_overage / stale). |
unknown status | WARN claude.rate_limit_event.unknown_status once per value; no latch. |
no status, legacy timing | #518 path: ⏳ Rate limited — retrying in Ns. |
truly bare ({"type":"rate_limit_event"}) | Nothing — INFO retry_after_source=bare. The #657 “bare = 60 s throttle” guess is retired: bare was an artefact of the old schema decoding every real snapshot to all-None. |
system / api_retry (#792)
Emitted when an API request fails with a retryable error and the CLI will retry after a
delay — the wire twin of the REPL’s retry banner (SDKAPIRetryMessage, CLI 2.1.283).
This, not rate_limit_event, is the real “backing off, retrying in N s” signal.
{"type":"system","subtype":"api_retry","attempt":2,"max_retries":10,"retry_delay_ms":8000,"error_status":529,"error":"overloaded","uuid":"…","session_id":"…"}
| Field | Type | Notes |
|---|---|---|
attempt / max_retries | int | retry counters |
retry_delay_ms | int | back-off before the next attempt |
error_status | int | null | HTTP status; null for connection errors (timeouts) with no response |
error | string | category: rate_limit, overloaded, server_error, authentication_failed, billing_error, invalid_request, unknown, … |
no_response | {waited_ms, retry_wait_ms} (optional) | only when no response headers arrived within CLAUDE_STREAM_FIRST_BYTE_TIMEOUT_MS; max_retries is then this cause’s own cap (normally 1) |
A sibling system/control_request_progress with status: "api_retry" carries the same
counters for client-originated control requests (side questions only) — not handled.
Untether handling (_translate_api_retry in runners/claude.py): decoded into the
flat StreamSystemMessage (all fields optional, error typed Any). Renders one note per
retry sequence, updated in place as attempts climb — 🔁 API error 529 (overloaded) — retrying in 8s (attempt 2/10), 🔁 API unreachable — retrying in 5s (attempt 1/10)
(no status), or 🔁 No response from API after 45s — retrying in 2s (attempt 1/1)
(no_response). Latches ClaudeStreamState.api_retry_wait_until = now + delay (+ the
retry’s retry_wait_ms header window); the bridge’s awaiting_api_retry() probe treats that
window as an expected wait (reason=api_retry_waiting), like a rate-limit window. Logs
claude.api_retry at INFO (WARN, and a warning-level note, on the final attempt). Retry
time accrues in api_retry_total_s, kept separate from rate-limit time.
tool_progress
Heartbeat emitted periodically while a long-running tool is in flight (verified on CLI
2.1.214 by running a Bash command longer than ~30 s). Purely informational — it carries no
result data and the tool’s real tool_result still arrives separately.
Fields (all optional in Untether’s schema):
type:"tool_progress"tool_use_id: the heartbeat’s own id, of the form<parent_tool_use_id>-heartbeat-<n>tool_name: the in-flight tool, e.g."Bash"parent_tool_use_id: thetool_usethis heartbeat belongs toelapsed_time_seconds: seconds since the tool startedheartbeat:truesession_id,uuid
Example:
{"type":"tool_progress","tool_use_id":"toolu_011cbTyUrSBE4D28tMCVqRSt-heartbeat-0","tool_name":"Bash","parent_tool_use_id":"toolu_011cbTyUrSBE4D28tMCVqRSt","elapsed_time_seconds":30,"heartbeat":true,"session_id":"8e8245e8-952c-4b70-9c6f-4c1cb4d4a687","uuid":"a9786562-4e78-418e-b48a-b14e57a1076d"}
Untether handling: Decoded by StreamToolProgressMessage schema, silently skipped in
translate_claude_event (no Untether events emitted). Untether renders its own elapsed-time
tail on long-running actions from its own clock (#481), so the upstream heartbeat is
redundant for progress rendering — the schema entry exists so the line decodes instead of
being dropped with a jsonl.msgspec.invalid warning (#637).
stream_event
Partial-message deltas ({"type":"stream_event","uuid","session_id","event":{…},"parent_tool_use_id"})
appear only with --include-partial-messages, which Untether doesn’t pass. The schema
decodes them (StreamEventMessage), and translate_claude_event ignores them
(DEBUG claude.event.unrecognised).
Background-task lifecycle (system subtypes, CLI ≥ 2.1.28x) — #776
Verified on 2.1.283 (see docs/findings/2026-09-27-claude-live-session-probes.md).
{"type":"system","subtype":"background_tasks_changed","tasks":[{"task_id":"b1","task_type":"local_bash","description":"…"}]}
{"type":"system","subtype":"task_started","task_id":"b1","tool_use_id":"toolu_…","description":"…","is_backgrounded":true,"task_type":"local_bash"}
{"type":"system","subtype":"task_started","task_id":"a1","tool_use_id":"toolu_…","subagent_type":"general-purpose","is_backgrounded":true,"spawn_depth":1,"task_type":"local_agent","prompt":"…"}
{"type":"system","subtype":"task_started","task_id":"f1","owned_by_subagent":true,"is_backgrounded":false,"task_type":"local_bash"}
{"type":"system","subtype":"task_progress","task_id":"a1","usage":{"total_tokens":52470,"tool_uses":2,"duration_ms":4314},"last_tool_name":"Bash"}
{"type":"system","subtype":"task_updated","task_id":"b1","patch":{"status":"completed","end_time":1790500249636}}
{"type":"system","subtype":"task_notification","task_id":"b1","tool_use_id":"toolu_…","status":"completed","output_file":"…","summary":"…"}
background_tasks_changedis a full snapshot of live background tasks ([]when none) and arrives just beforetask_started/task_updated.owned_by_subagent: truewithis_backgrounded: falseis a subagent’s own foreground tool — not background work.task_updated.patch.status:completed|killed;task_notification.status:completed|stopped.Monitoristask_type: local_bash; each streamed line starts a new turn with no per-line task event; the stream end emitstask_updated+task_notification.ScheduleWakeup/RemoteTriggeremit no task events.- Resuming a finished background agent (e.g.
SendMessageto it) reuses itstask_id: a fresh snapshot lists it again and a newtask_startedfollows. Untether revives the ended task instead of leaving it terminal (#801). - Closing stdin stops live background tasks (
killed/stopped) and the CLI exits rc=0 a few seconds later. - On
--resumeafter such a stop, the CLI first emitstask_notification{status:"stopped", output_file:""}, thensystem/init, then a 0-turn result, then the real turn.
command_lifecycle (#776)
One line per input command (user line or scheduled wake-up):
{"type":"command_lifecycle","command_uuid":"<uuid>","state":"queued"}
{"type":"command_lifecycle","command_uuid":"<uuid>","state":"started"}
{"type":"command_lifecycle","command_uuid":"<uuid>","state":"completed"}
command_uuid echoes the uuid field of the stream-json user line that was written to stdin, so a turn can be attributed to the message that caused it. A ScheduleWakeup firing appears as started with a uuid Untether never wrote. completed for command N can arrive lazily (when command N+1 is queued).
Multi-result streams (#776)
In control-channel mode the process does not exit after result: background-task completions, Monitor lines, ScheduleWakeup firings and user lines written while idle each produce another system/init → … → result. total_cost_usd is cumulative per session (including across --resume); num_turns is per result.
system / status — permission-mode edges (#383)
Emitted on every change of the CLI’s permission mode — an approved ExitPlanMode (mode becomes prePlanMode ?? "default", so a session started in plan lands in default; emitted before the tool_result) and a successful set_permission_mode. A no-op change emits nothing. (The same subtype also carries status:"compacting" during compaction.)
{"type":"system","subtype":"status","status":null,"permissionMode":"default","uuid":"…","session_id":"…"}
system/init.permissionMode reports the mode each turn starts in (live follow-up turns included on 2.1.285). Untether tracks the effective mode from both (claude.permission_mode.changed); neither produces an Untether event.
Untether’s plan re-arm (host → CLI, handled inline by the CLI’s stdin reader, not queued behind a turn) and its answers:
{"type":"control_request","request_id":"ut_plan_rearm_<sid>_1","request":{"subtype":"set_permission_mode","mode":"plan"}}
{"type":"control_response","response":{"subtype":"success","request_id":"ut_plan_rearm_<sid>_1","response":{"mode":"plan"}}}
{"type":"control_response","response":{"subtype":"error","request_id":"…","error":"…","error_code":"invalid_mode"}}
A real change is followed by the system/status frame above; plan while already plan is acked with no status frame. Refusal codes (2.1.285): invalid_mode, bypass_* (target bypassPermissions) and auto_mode_* (target auto) — plan is never refused.
control_request / control_response (permission prompts)
In control-channel mode (--permission-prompt-tool stdio) the CLI asks the host before
running a tool the earlier permission stages didn’t decide:
{"type":"control_request","request_id":"<id>","request":{"subtype":"can_use_tool","tool_name":"Edit","input":{"file_path":"…","old_string":"…","new_string":"…"},"permission_suggestions":[…]}}
Other request subtypes Untether decodes: initialize, set_permission_mode,
hook_callback, mcp_message, rewind_files, interrupt (ControlRequest in
schemas/claude.py). Untether answers on stdin:
{"type":"control_response","response":{"subtype":"success","request_id":"<id>","response":{"behavior":"allow","updatedInput":{…}}}}
{"type":"control_response","response":{"subtype":"success","request_id":"<id>","response":{"behavior":"deny","message":"User denied"}}}
updatedInput echoes the request’s input (AskUserQuestion adds answers). Which requests
become Telegram buttons depends on the permission mode
(#749); see the
runner spec, “Permission modes”. The CLI ignores a second answer to the same
request, so Untether tracks answered requests itself
(#685). control_response lines
from the CLI are decoded too; only the acks of Untether’s own set_permission_mode
(ut_plan_rearm_…, above) are acted on.
control_cancel_request (CLI → host) — #684
The CLI withdraws a pending can_use_tool it no longer needs (interrupt, turn abort). No
session_id, and no reply is expected — a control_response that still arrives for that id is
ignored (CLI 2.1.285, findings 2026-09-30 probe Z4). It is followed by a synthetic rejection
tool_result. Closing stdin with a request pending sends no cancel frame (probe Z5).
{"type":"control_cancel_request","request_id":"<id>"}
Untether retires the request (registries, keyboard, cancelled record) and writes nothing; see
untether-events.md §4.1.
Hook lifecycle (system subtypes, --include-hook-events) — #812
Emitted only when Untether passes --include-hook-events (control-channel mode, CLI lists the
flag, [watchdog] hold_for_async_hooks on). SessionStart / Setup hook frames are emitted
even without the flag. Source: docs/findings/2026-09-29-claude-rc14-cli-surface.md §A2/§A3.
{"type":"system","subtype":"hook_started","hook_id":"<uuid>","hook_name":"Stop","hook_event":"Stop","uuid":"…","session_id":"…"}
{"type":"system","subtype":"hook_progress","hook_id":"<uuid>","hook_name":"Stop","hook_event":"Stop","stdout":"…","stderr":"…","output":"…","uuid":"…","session_id":"…"}
{"type":"system","subtype":"hook_response","hook_id":"<uuid>","hook_name":"Stop","hook_event":"Stop","outcome":"error","exit_code":2,"stdout":"","stderr":"<findings>","output":"…","uuid":"…","session_id":"…"}
hook_idpairshook_startedwithhook_response.hook_namecan carry a matcher suffix (SessionStart:startup).outcome:success|error|cancelled;exit_codeis omitted when undefined. Exit 2 (outcome: "error") is a blocking error, which for anasyncRewakehook is the wake signal.- No field marks a hook as async. A background hook shows up only as a
hook_startedwhosehook_responsearrives after the turn’sresult. - A plain
asynchook’shook_responseis withheld while the session is idle and delivered at the next turn or at stdin close, even though its process exited long ago (probed on 2.1.285).asyncRewakeand synchronous hooks report as soon as they exit. - An
asyncRewakehook that exits 2 while idle starts a new turn with nocommand_lifecycleframes:hook_response{exit_code:2}→system/init→system/informational(“Original prompt:<task-notification>… Stop hook feedback”) → … →resultwithorigin: {"kind":"task-notification"}. - After stdin closes the CLI kills plain
asynchooks (outcome: "cancelled"), waits up to 30 s for pendingasyncRewakehooks, and drops any rewake they produce.
Untether handling: decoded into the flat StreamSystemMessage (hook_id, hook_name,
hook_event, outcome, exit_code, all typed Any; stdout / stderr / output are
deliberately not declared, so hook output is never held in memory). No Untether events; the
frames feed the live-session hook hold (see the runner spec, “Async hooks”) and
don’t overwrite JsonlStreamState.last_event_type.
Safeguard stops and model fallback (#814)
{"type":"system","subtype":"informational","content":"Opus 5.5's safeguards stopped the response above · continuing once with that noted","level":"notice","uuid":"…","session_id":"…"}
{"type":"system","subtype":"model_refusal_fallback","trigger":"refusal","direction":"retry","scope":"session","original_model":"claude-opus-5-5","fallback_model":"claude-opus-4-8","api_refusal_category":"cyber","api_refusal_explanation":"…","content":"…","session_id":"…","uuid":"…"}
{"type":"system","subtype":"model_refusal_no_fallback","original_model":"claude-opus-5-5","api_refusal_category":"bio","api_refusal_explanation":"…","content":"…","session_id":"…","uuid":"…"}
{"type":"system","subtype":"model_fallback","trigger":"overloaded","original_model":"claude-opus-5-5","fallback_model":"claude-sonnet-5-5","content":"…","session_id":"…","uuid":"…"}
informational(SDKInformationalMessage):content,level(info|notice|suggestion|warning), optionaltool_use_id/prevent_continuation. It is a general banner (hook blocks, notices, …); the safeguard notice is one of these at levelnotice. Its position relative to the turn’sresultis unverified, so Untether parses it on either side (and between turns in a live session).model_refusal_fallback/model_refusal_no_fallback/model_fallbackare undocumented in the SDK reference; shapes come from the CLI binary (2.1.284/2.1.285).scope: "local"on a refusal fallback means a subagent or side question fell back and the session model is unchanged. Other optional keys seen:request_id,refused_user_message_uuid,saw_cyber_refusal,retracted_message_uuids.- The drift test (
tests/test_claude_cli_schema_drift.py) probes the installed binary for these subtypes and the safeguard notice text at zero token cost.
Untether handling: all fields decode into StreamSystemMessage typed Any (about 40
system subtypes share the struct, so a type clash must never drop the line). Dispatch is
_SYSTEM_SUBTYPE_HANDLERS in runners/claude.py; the mapping is in
untether-events.md §4.1 and the runner spec’s “Safeguard stops”.
Source: docs/findings/2026-09-29-claude-rc14-cli-surface.md §B.
Context usage and compaction (#819)
The context-window numbers ride on frames already listed above (CLI 2.1.285; zero-token
probes in tests/test_claude_cli_schema_drift.py, research in
docs/findings/2026-09-30-claude-sdk-control-permissions-context.md Q4/Q5):
{"type":"assistant","message":{"model":"claude-haiku-4-5","usage":{"input_tokens":1234,"cache_creation_input_tokens":100,"cache_read_input_tokens":185000,"output_tokens":5},"content":[…]},"parent_tool_use_id":null,"session_id":"…"}
{"type":"result","subtype":"success",…,"modelUsage":{"claude-haiku-4-5":{"inputTokens":…,"contextWindow":200000,"maxOutputTokens":64000,…}}}
{"type":"system","subtype":"compact_boundary","compact_metadata":{"trigger":"manual","pre_tokens":6336,"post_tokens":277,"cumulative_dropped_tokens":6059,"duration_ms":47},"logical_parent_uuid":"…","session_id":"…"}
modelUsageis keyed by the model id the CLI used;contextWindowis the only place the window appears. Decoded asStreamResultMessage.modelUsage(Any).- Assistant
message.usagedecodes asStreamAssistantMessageBody.usage(Any). - All these keys decode as
AnyonStreamSystemMessage/StreamUserMessage.
Compaction frames as captured on CLI 2.1.285 (Haiku, 2026-10-01; redacted transcripts in
tests/fixtures/claude_{compaction,autocompact,compact_empty}_2.1.285.jsonl):
{"type":"system","subtype":"status","status":"compacting","session_id":"…"}
{"type":"system","subtype":"status","status":null,"compact_result":"success","session_id":"…"}
{"type":"system","subtype":"init",…}
{"type":"system","subtype":"compact_boundary","compact_metadata":{"trigger":"manual","pre_tokens":51305,"post_tokens":2228,"cumulative_dropped_tokens":49077,"duration_ms":21942,"preserved_segment":{…},"preserved_messages":{…}},"logical_parent_uuid":"…","session_id":"…"}
{"type":"user","isReplay":false,"isSynthetic":true,"message":{"role":"user","content":"This session is being continued from a previous conversation that ran out of context. …"}}
{"type":"user","isReplay":true,"message":{"role":"user","content":"<local-command-stdout>Compacted </local-command-stdout>"}}
{"type":"result","subtype":"success","is_error":false,"num_turns":0,"duration_api_ms":0,"result":"",…}
- That is a manual
/compact. Theinitline and the last two lines appear only for/compact. - Auto compaction fires inside a turn, between a
tool_resultand the next API request. It sends no freshinit, and the boundary frame has nosession_id({"trigger":"auto","pre_tokens":79267,"post_tokens":15092,…}). The summaryuserframe has list content, and the turn carries on. status: "compacting"is re-sent every 30 s while compacting (drift probe). A 22 s compaction sent none.- A failure ends with
status:null,compact_result:"failed"and an optionalcompact_error, with no boundary. A PreCompact-hook skip ends with a plainstatus:null.status:null+permissionModeis #383’s mode-change edge, not compaction. post_tokensexcludes the system prompt and tools. After this manual compaction the next response’s input side was 21 320 againstpost_tokens2 228.isCompactSummarywas not on the wire; the summary frame carriesisSynthetic: true./compacton a session with no history:init→ a<synthetic>assistantError: No messages to compact→ a 0-turn, 0-ms result withresult: ""andmodelUsage: {}, and no compaction frames.
Untether handling: % ctx (C2), the 🗜️ rows, the liveness latch and the manual-only
0-turn exemption. See untether-events.md §6.1 and the runner spec’s
“Context usage” and “Compaction” sections.
Message object (message field)
Fields:
id,type,rolemodel(optional)content: array of content blocksusage(assistant messages)
Content block shapes (in message.content[])
Text
{"type":"text","text":"Hello"}
Thinking
{"type":"thinking","thinking":"…","signature":"…"}
Untether emits each non-empty thinking block as a completed note action
(claude.thinking.<n>).
Tool use
{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls -la"}}
server_tool_use (Anthropic server-side tools such as web_search) has the same shape and
is translated like tool_use; advisor_tool_result has the tool_result shape and is
translated like it (#489).
ScheduleWakeup (session-scoped scheduling)
Claude Code 2.1.x emits ScheduleWakeup tool_use blocks when the model parks itself for a delay (/loop’s no-interval dynamic mode and similar). Untether observes these for Loop mode and the long-running tool tail.
{"type":"tool_use","id":"toolu_3","name":"ScheduleWakeup","input":{"delaySeconds":300,"reason":"build check","prompt":"check if the build finished"}}
delaySeconds(int, 60–3600) — wall-clock delay before wakeup. Untether reads this field for countdown rendering (#481 fixed; pre-rc7 readsdelay_ms/timeout_mswere always 0.0 in production).reason(string) — short human label rendered in verbose mode (→ fires in 4m 12s · "build check").prompt(string) — the prompt the model wants to fire on wakeup (loop iteration body).
CronCreate follows the same shape with a cron field (5-field expression) instead of delaySeconds. The Loop observer parses cron (not cron_expression) and id (not taskId/cronId); upstream 8-character cron IDs bind via \bjob ([0-9a-f]{8})\b from the assistant text.
Tool result
String content:
{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"}
Array content (Task tool format):
{"type":"tool_result","tool_use_id":"toolu_2","content":[{"type":"text","text":"Task done"}]}
Image / document blocks (binary media echoes)
Read on an image or PDF echoes the content back inside the user-role
message as image / document blocks alongside the tool_result
(#597 — the schema
must accept them or msgspec drops the whole line):
{"type":"image","source":{"type":"base64","media_type":"image/jpeg","data":"…"}}
{"type":"document","source":{"type":"base64","media_type":"application/pdf","data":"…"},"title":"report.pdf"}
source—{"type":"base64"|"url", "media_type": …, "data"|"url": …}. Untether never renders the payload; the blocks exist so the surrounding message parses. Translation treats them as non-tool_resultcontent.