When the parsed aictrl run --format json handler starts, the CLI emits newline-delimited JSON (NDJSON) events to stdout. Each line is a self-contained JSON object with the following base shape:
{
"type": "<event_type>",
"timestamp": 1741500000000,
"invocationID": "7d142250-8bdc-43df-99af-efa252db62a7",
"sessionID": "session_01abc..."
}invocationID is present on every event from run --format json. sessionID is present only after a real session has been created; invocation events never fabricate one.
The schema is versioned via invocation_start.schemaVersion and session_start.schemaVersion. This document describes schema version "1". Consumers should pin to this version and treat unknown fields as forward-compatible additions.
The invocation envelope covers accepted run --format json executions from the first line of the parsed handler through validation, bootstrap, session creation, and execution. Argument-parser failures, other commands, and process-global uncaught exceptions or unhandled rejections are outside this contract.
Emitted once, before piped stdin is read and before run validation or bootstrap begins.
{
"type": "invocation_start",
"timestamp": 1741500000000,
"schemaVersion": "1",
"invocationID": "7d142250-8bdc-43df-99af-efa252db62a7"
}This event intentionally has no sessionID, because a session does not exist yet.
Emitted for a fatal error before session creation, immediately before invocation_complete.
{
"type": "invocation_error",
"timestamp": 1741500000001,
"schemaVersion": "1",
"invocationID": "7d142250-8bdc-43df-99af-efa252db62a7",
"phase": "validation",
"code": "INVOCATION_FILE_NOT_FOUND",
"message": "Invocation failed during validation"
}phase(string, required) — one ofvalidation,stdin,bootstrap, orsession.code(string, required) — stable machine-readable error category. Known failures useINVOCATION_INVALID_DIRECTORY,INVOCATION_FILE_NOT_FOUND,INVOCATION_EMPTY_INPUT,INVOCATION_INVALID_ARGUMENTS, orINVOCATION_SESSION_CREATE_FAILED. Unexpected failures useINVOCATION_<PHASE>_FAILED, where<PHASE>isVALIDATION,STDIN,BOOTSTRAP, orSESSION.message(string, required) — sanitized human-readable phase summary. Details remain on stderr and in the log.
Errors after a real session has been created use session_error instead. They also set
invocation_complete.status to error, so the invocation result always agrees with the process result.
Emitted exactly once for every started invocation.
{
"type": "invocation_complete",
"timestamp": 1741500000123,
"schemaVersion": "1",
"invocationID": "7d142250-8bdc-43df-99af-efa252db62a7",
"sessionID": "session_01abc...",
"status": "completed",
"durationMs": 123
}status(string, required) —completedorerror.durationMs(number, required) — elapsed invocation time.sessionID(string, optional) — included only when a real session was created.
Emitted once when the session begins.
{
"type": "session_start",
"schemaVersion": "1",
"model": "anthropic/claude-sonnet-4-20250514",
"agent": "default",
"permissions": [
{ "permission": "bash", "pattern": "*", "action": "allow" },
{ "permission": "write", "pattern": "*", "action": "ask" }
]
}schemaVersion(string, required) — pinned contract version. Currently"1".permissions(array, required) — the fully resolved permission ruleset for the session (merge of agent + session rules). In headless mode, any permission not matching anallowrule is auto-rejected.
Emitted once per session, immediately after session_start and before the first model turn (present even for zero-turn or immediately-failing runs). Lists every tool and skill that was resolved for the session so consumers can structurally verify tool exposure without string-matching the model's prose.
{
"type": "tool_catalog",
"sessionID": "ses_…",
"timestamp": 1741500000000,
"tools": [
{ "name": "record_finding", "source": "mcp", "server": "aictrl" },
{ "name": "record_review_completed", "source": "mcp", "server": "aictrl" },
{ "name": "bash", "source": "builtin" },
{ "name": "read", "source": "builtin" }
],
"skills": [
{ "name": "code-review", "version": "1.4.0" },
{ "name": "fullstack-code-review", "version": null }
]
}name(string, required) — the tool/function name as exposed to the model. Mirrors upstreamToolListItem.id. For MCP tools this is{serverName}_{toolName}(both sanitised to[a-zA-Z0-9_-]).source(string, required) —"builtin"for tools built into the CLI;"mcp"for tools provided by a connected MCP server.server(string, optional) — the MCP server (client name from config) that provides this tool. Present only whensourceis"mcp".
description and parameters are intentionally omitted to keep the event lean. The primary consumer only needs name + source to verify tool exposure.
name(string, required) — skill name fromSKILL.mdfrontmatter.version(string or null, required) — version string from theversionfield inSKILL.mdfrontmatter.nullwhen no version is declared.
Use case: The server-side completion gate (aictrl-dev/aictrl #3216) uses
tools[]to verify thatrecord_findingandrecord_review_completedwere actually in the model's function list at dispatch time, andskills[]to record which skill version produced the review. Detects the "silent success" failure mode where a missing MCP server lets a run complete green without ever having the review tools available.Note on builtin tool filtering: The
source: "builtin"entries intools[]reflect the instance-level superset registered inToolRegistry. Per-model filters (e.g.apply_patchonly on gpt-*,codesearch/websearchonly for aictrl provider) are applied at dispatch time insideresolveToolsand are not reflected here. Consumers should treat the presence of a builtin tool name as "registered and potentially available", not as "guaranteed to appear in the model's function list". The strong structural guarantee (tool present ↔ tool in dispatch list) applies only tosource: "mcp"entries.
Emitted once when the session ends (success or failure).
{
"type": "session_complete",
"durationMs": 12345,
"error": null
}error is null on success, or a string describing the failure.
Deprecated in v1. Prefer the structured
session_errorevent for new consumers.session_complete.errorremains populated for back-compat and will be removed in a future schema version.Note:
session_erroris only emitted when the session terminates abnormally (provider/auth/rate-limit/timeout/OOM/interruption/termination). Non-fatal errors that accumulate during an otherwise-successful run surface as individualerrorevents and may also appear concatenated insession_complete.errorwithout a precedingsession_error. Alerting logic should key onsession_error, not onsession_complete.error.
Emitted immediately before session_complete when the session terminates abnormally.
{
"type": "session_error",
"reason": "rate_limit",
"code": "429",
"message": "Rate limit exceeded"
}reason(string, required) — one ofrate_limit,auth,timeout,oom,provider,interrupted,terminated,unknown.SIGINTproducesinterrupted;SIGTERMproducesterminated. Signals are not inferred to be timeouts.code(string, optional) — provider HTTP status code, error code, or conventional signal-derived exit code (130forSIGINT,143forSIGTERM) when available.message(string, required) — human-readable error message.
Emitted when a primary-session assistant message finishes (one per LLM turn). Child-session assistant messages are excluded; use the child-session lifecycle events when tracking subagents.
{
"type": "message_complete",
"messageID": "msg_01abc...",
"modelID": "claude-sonnet-4-20250514",
"providerID": "anthropic",
"agent": "default",
"status": "completed",
"usageStatus": "reported",
"cost": { "input": 0.003, "output": 0.012, "cache": { "read": 0, "write": 0 } },
"tokens": {
"total": 11360,
"input": 1024,
"output": 512,
"reasoning": 0,
"cache": { "read": 8800, "write": 1024 }
},
"context": { "used": 10848, "limit": 200000, "ratio": 0.05424 },
"finish": "tool-calls"
}messageID(string, required) — stable assistant-message identity. A message produces at most onemessage_complete; consumers should use this field rather than timestamps for identity.status(string, required) —"completed","error", or"aborted".usageStatus(string, required) —"reported"when the provider supplied usage,"missing"when it did not, or"estimated"for an explicitly estimated future source. The CLI does not currently estimate usage.finish(string, optional) — provider finish reason, such as"tool-calls","end_turn", or"max_tokens". It can be absent on failed or aborted turns.
tokens (5-way breakdown, mirrors upstream LLM.Usage):
total(number) — provider-reported total, or a finite total computed from sufficient reported components.input(number) — raw input tokens billed at the standard input rate.output(number) — output (completion) tokens.reasoning(number) — extended-thinking / reasoning tokens (0 when thinking is off).cache.read(number) — tokens served from the prompt cache (billed at cache-read rate). Distinguishes cache hits from fresh input.cache.write(number) — tokens written to the prompt cache (billed at cache-write rate).
These fields are non-overlapping: a token is counted in exactly one bucket.
tokens is null when usageStatus is "missing". This distinguishes unavailable usage from a provider-reported zero, which has usageStatus: "reported" and numeric zero fields. The additional identity, status, and provenance fields are additive to schema v1; successful events retain the existing modelID, providerID, agent, cost, tokens, context, and finish fields.
For compatibility with sessions written before usage provenance was persisted, a legacy terminal message with a finish reason is treated as "reported"; one without a finish reason is treated as "missing".
context (context-window utilization):
used(number) — tokens occupying the model's context window this turn:input + cache.read + cache.write.limit(number) — the model's total context-window size in tokens, sourced from the models.dev registry (model.limit.context).ratio(number) —used / limit(≥0; may exceed 1 if usage exceeds the model's registered limit). A value approaching or exceeding 1 signals context-exhaustion risk.null— emitted when the model's context limit is not known (e.g. unregistered custom endpoint), or usage is missing.
Emitted when a text block from the assistant is complete.
{
"type": "text",
"part": {
"type": "text",
"text": "Here is the result...",
"time": { "start": 1741500000, "end": 1741500001 }
}
}sequenceNum(number, required) — monotonic per-session counter shared acrosstext,reasoning, andtool_useevents. Use it to render a correctly-ordered trace without relying on timestamp ties. Subagent sessions have their own independent counters keyed onpart.sessionID. Note: in JSON mode onlytool_useevents are emitted for subagent sessions, so subagent counters increment only on tool_use.
Emitted when extended thinking content is complete (requires --thinking flag).
{
"type": "reasoning",
"part": {
"type": "reasoning",
"text": "Let me think about this...",
"time": { "start": 1741500000, "end": 1741500001 }
}
}sequenceNum(number, required) — monotonic per-session counter shared acrosstext,reasoning, andtool_useevents. Use it to render a correctly-ordered trace without relying on timestamp ties. Subagent sessions have their own independent counters keyed onpart.sessionID. Note: in JSON mode onlytool_useevents are emitted for subagent sessions, so subagent counters increment only on tool_use.- One event is emitted per complete reasoning block.
part.texthas no size cap; consumers must accept arbitrarily large strings.
Emitted when a tool call completes (success or error). This includes tools executed within subagent sessions.
{
"type": "tool_use",
"part": {
"type": "tool",
"tool": "bash",
"sessionID": "session_01abc...",
"state": {
"status": "completed",
"input": { "command": "ls" },
"metadata": { "exit": 0, "output": "..." }
}
}
}sequenceNum(number, required) — monotonic per-session counter shared acrosstext,reasoning, andtool_useevents. Use it to render a correctly-ordered trace without relying on timestamp ties. Subagent sessions have their own independent counters keyed onpart.sessionID. Note: in JSON mode onlytool_useevents are emitted for subagent sessions, so subagent counters increment only on tool_use.
state.status is "completed" or "error". On error, state.error contains the error message.
For tools executed inside a subagent, part.sessionID will differ from the top-level sessionID — compare the two to identify subagent tool calls.
Emitted at step boundaries during multi-step tool use.
{ "type": "step_start", "part": { "type": "step-start" } }
{ "type": "step_finish", "part": { "type": "step-finish" } }Skills are loaded progressively. The model first sees skill names and descriptions in the tool schema. Full skill content only enters context when the model explicitly invokes the skill tool.
Emitted per skill when the skill tool is initialized and skill descriptions are registered in the tool schema. This happens at the start of each LLM turn.
{
"type": "skill_discovered",
"name": "commit",
"description": "Create well-structured git commits",
"location": "/home/user/.claude/skills/commit/SKILL.md"
}Emitted when the model invokes the skill tool and the full SKILL.md content enters context.
{
"type": "skill_loaded",
"name": "commit",
"location": "/home/user/.claude/skills/commit/SKILL.md"
}Emitted when the model reads a file that belongs to a skill directory (e.g., a script, template, or reference file bundled with the skill).
{
"type": "skill_resource_loaded",
"skillName": "commit",
"filePath": "/home/user/.claude/skills/commit/templates/conventional.md"
}Emitted when a child session (subagent) is spawned.
{
"type": "subagent_start",
"subagentSessionID": "session_01xyz...",
"parentSessionID": "session_01abc...",
"title": "Research codebase"
}Emitted when a child session completes.
{
"type": "subagent_complete",
"subagentSessionID": "session_01xyz...",
"parentSessionID": "session_01abc..."
}Emitted when a session error occurs.
{
"type": "error",
"error": {
"name": "Unknown",
"data": { "message": "Something went wrong" }
},
"sourceSessionID": "session_01abc..."
}Emitted when a permission request is auto-rejected (headless mode has no user to approve).
{
"type": "permission_rejected",
"callID": "call_01xyz...",
"tool": "bash",
"permission": "bash",
"patterns": ["rm -rf /"],
"input": { "command": "rm -rf /" }
}tool(string, required) — the tool that requested the permission. Falls back to thepermissionstring when the request did not originate from a tool execution.permission(string, required) — permission category (e.g.bash,write).patterns(string[], required) — matched patterns.input(any, required) — the arguments the tool tried to invoke.nullwhen unavailable (e.g. subagent task permission checks).callID(string, optional) — tool call id; present when the permission originated from a tool execute closure.sessionID— present on the base envelope; identifies the session that made the request (may be a subagent's session id).
Emitted when a permission request resolves to allow (either by matching an allow rule or a cached always approval). Same shape as permission_rejected.
{
"type": "permission_granted",
"callID": "call_01xyz...",
"tool": "bash",
"permission": "bash",
"patterns": ["ls"],
"input": { "command": "ls" }
}