Skip to content

Claude Code Hook Payload Spec

Official documentation: https://code.claude.com/docs/en/hooks

This document organizes stdin payloads focusing on the Claude hook subset currently used by Agent Tracer. The [Observed] notation indicates parts where official spec and actual behavior differ.

Agent Tracer's Claude plugin currently handles 27 of the 28 official hook events. Payload readers for every handled event live at packages/runtime/src/shared/hooks/claude/payloads.ts and are invoked by each hook via the shared runHook() wrapper at packages/runtime/src/shared/hook-runtime/.

Events currently in official docs but not yet handled by the plugin: TeammateIdle (experimental agent teams), Elicitation, ElicitationResult (MCP form input).

Privacy contract

The plugin captures action-side data only — what the agent invoked, with which arguments — and never the result body. Specifically, every PostToolUse handler ignores tool_response entirely. No stdout, stderr, file content, web response body, MCP tool result, search result list, or grep snippet ever leaves the host. Tool inputs (commands, queries, prompts, file paths, offsets, glob filters, domain allowlists, etc.) are captured because they describe the agent's action.

The same contract applies to the Codex adapter — see packages/runtime/CODEX_DATA_FLOW.md.

Async hooks

Stateless event-emitting hooks (PostToolUse and its variants, PostToolBatch, PostToolUseFailure, Stop / StopFailure / SessionEnd, Notification, SubagentStop, TaskCreated / TaskCompleted, ConfigChange / CwdChanged / FileChanged, PreCompact / PostCompact, PermissionDenied, WorktreeRemove, UserPromptExpansion, InstructionsLoaded) are registered with "async": true in hooks.json so they fire-and-forget without blocking Claude Code's main loop.

The remaining hooks stay synchronous because their effect must be observed before the next step proceeds: PreToolUse, SessionStart, Setup, UserPromptSubmit, SubagentStart, PermissionRequest, WorktreeCreate.


Common Fields

Fields included in all hook events per official spec:

FieldTypeDescription
session_idstringCurrent Claude Code session ID
transcript_pathstringPath to conversation transcript JSONL file
cwdstringCurrent working directory
hook_event_namestringEvent name
permission_modestring"default" | "plan" | "acceptEdits" | "dontAsk" | "bypassPermissions"
agent_idstring?Included only inside subagent
agent_typestring?Included when --agent flag or subagent is used

[Observed] transcript_path and permission_mode are actually missing in some events. Missing events: SessionStart, SessionEnd, SubagentStart, PreCompact, PostCompact Hook code should not depend on these fields.

Per-event payload readers surface session_id, cwd, transcript_path, permission_mode, agent_id, and agent_type on a common ClaudeSessionContextBase interface. Each event extends it with its event-specific fields.


Per-Event Payloads

SessionStart

Trigger: After Claude Code startup, resume, /clear, /compact Reader: readSessionStart() Matchers: startup|resume|clear|compact

FieldTypeValue
hook_event_namestring"SessionStart"
sourcestring"startup" | "resume" | "clear" | "compact"
modelstringModel ID in use ([Observed] undocumented in spec)

[Observed] No transcript_path, permission_mode. [Observed] model field actually exists (e.g., "claude-sonnet-4-6").


SessionEnd

Trigger: On session closure Reader: readSessionEnd() Matchers: clear|resume|logout|prompt_input_exit|bypass_permissions_disabled|other

FieldTypeValue
hook_event_namestring"SessionEnd"
reasonstring"clear" | "resume" | "logout" | "prompt_input_exit" | "bypass_permissions_disabled" | "other"

[Observed] No transcript_path, permission_mode.


UserPromptSubmit

Trigger: When user message is submitted Reader: readUserPromptSubmit()

FieldTypeValue
hook_event_namestring"UserPromptSubmit"
promptstringFull user input text

InstructionsLoaded

Trigger: When a CLAUDE.md / .claude/rules/*.md file is loaded into context Reader: readInstructionsLoaded() Matchers: session_start|nested_traversal|path_glob_match|include|compact

FieldTypeValue
hook_event_namestring"InstructionsLoaded"
file_pathstringAbsolute path of the loaded instruction file
memory_typestring"User" | "Project" | "Local" | "Managed"
load_reasonstring`session_start
globsstring[]?Glob patterns that triggered the load
trigger_file_pathstring?File that triggered a lazy include
parent_file_pathstring?File that included this one

PreToolUse

Trigger: Just before tool execution Reader: readPreToolUse()

FieldTypeValue
hook_event_namestring"PreToolUse"
tool_namestringSee "Supported tools" below
tool_inputobjectPer-tool input (see below)
tool_use_idstringUnique tool call ID

[Observed] When tool is called inside subagent, agent_id and agent_type are additionally included. This allows identifying which subagent made the call.

Supported tools (each has a registered PostToolUse matcher): Bash, PowerShell, BashOutput, KillShell, Monitor, Edit, Write, NotebookEdit, Read, Glob, Grep, LSP, WebFetch, WebSearch, Agent, Skill, TaskCreate, TaskUpdate, TodoWrite, AskUserQuestion, ExitPlanMode, EnterPlanMode, EnterWorktree, ExitWorktree, CronCreate, CronDelete, CronList, ToolSearch, mcp__*.

tool_input structure (per tool):

Bash:            { command, description?, timeout?, run_in_background? }
PowerShell:      { command, description?, run_in_background? }       # same shape as Bash
BashOutput:      { bash_id, filter? }
KillShell:       { bash_id }
Monitor:         { command, description?, filter? }
Edit:            { file_path, old_string, new_string, replace_all? }
Write:           { file_path, content }
NotebookEdit:    { notebook_path, cell_id?, new_source, edit_mode? }
Read:            { file_path, offset?, limit? }
Glob:            { pattern, path? }
Grep:            { pattern, path?, glob?, output_mode?, "-i"?, multiline? }
LSP:             { operation, file_path?, line?, column?, symbol? }
WebSearch:       { query, allowed_domains?, blocked_domains? }
WebFetch:        { url, prompt }
Agent:           { description?, prompt, subagent_type?, model?, run_in_background? }
Skill:           { skill, args? }
TaskCreate:      { task_subject, task_description? }
TaskUpdate:      { task_id, status }
TodoWrite:       { todos: [{ content, status, priority }] }
AskUserQuestion: { question, options? }
ExitPlanMode:    { plan }
EnterPlanMode:   {}
EnterWorktree:   { path? }
ExitWorktree:    {}
CronCreate:      { schedule, prompt }
CronDelete:      { id }
CronList:        {}
ToolSearch:      { query }
mcp__*:          Varies by MCP server/tool

The structure above summarizes fields currently used by Agent Tracer. For exhaustive schema, consult the official hooks reference first.

Agent Tracer Custom Metadata

Semantic Metadata Contract

Shared semantic metadata consumed by the UI:

typescript
// Defined in packages/runtime/src/shared/events/metadata.type.ts (mirrored in packages/server/src/activity/event/domain/model/event.semantic.model.ts)
interface EventSemanticMetadata {
  readonly subtypeKey: EventSubtypeKey;  // "read_file", "run_test", "mcp_call", ...
  readonly subtypeLabel?: string;        // UI-friendly label
  readonly subtypeGroup: EventSubtypeGroup;  // "files", "execution", "coordination", ...
  readonly toolFamily: EventToolFamily;  // "explore", "file", "terminal", "coordination"
  readonly operation: string;            // "search", "modify", "execute", "delegate"
  readonly entityType?: string;          // "file", "directory", "command", ...
  readonly entityName?: string;          // Specific filename, command name, etc.
  readonly sourceTool?: string;          // Original tool name
  readonly importance?: string;          // "critical", "normal", "minor"
}

This contract is derived runtime-side by the plugin in packages/runtime/src/shared/semantics/ (the inference.*.ts modules via buildSemanticMetadata) and injected into event metadata before sending; the server ingests these fields rather than deriving them. The derived fields are consumed by the web dashboard through packages/web/src/features/feed/lib/ (extract-metadata.ts, group-acts.ts) and packages/web/src/domain/ (snapshot.ts, classification.ts).

Per-Tool Additional Metadata

The PostToolUse per-tool handlers share ops modules: PostToolUse/{Read,Glob,Grep,WebFetch,WebSearch,AskUserQuestion,ExitPlanMode}.ts_explore.ops.ts; PostToolUse/{Edit,Write,NotebookEdit}.ts_file.ops.ts; PostToolUse/Agent.ts_agent.ops.ts; PostToolUse/Skill.ts_skill.ops.ts; PostToolUse/{TaskCreate,TaskUpdate,TodoWrite}.ts_todo.ops.ts. Each ops module injects per-tool additional information into the metadata field:

ToolAdditional MetadataDescription
Bash / PowerShellcommandAnalysis, timeoutMs, runInBackground, filePathsParsed shell structure (steps, targets, effect); file/path targets surfaced from analysis are promoted to event-level filePaths (so cat foo.ts appears alongside Read events)
BashOutput / KillShellentityName: bash_idBackground shell lifecycle
MonitormonitorScript, monitorDescriptionLong-running watch — emits dedicated monitor.observed KIND
ReadreadOffset, readLimitCaptures requested line range
GrepsearchPattern, searchPath, searchGlob, grepOutputMode, grepCaseInsensitive, grepMultilineFull invocation parameters; grepOutputMode is the strongest signal of how thoroughly the agent investigated
GlobsearchPattern, searchPath
WebSearchwebQuery, webAllowedDomains, webBlockedDomains, webUrlsDomain filters reveal the agent's intent boundaries
WebFetchwebQuery (URL), webPrompt, webUrlswebPrompt records what the agent was looking for in the page
EditeditReplaceAllBulk-replace flag
AgentagentName, agentModel, agentRunInBackground
LSPsubtypeKey: "grep_code", operation: "lsp_<op>"Code-intelligence calls
Cron*entityName: schedule|idScheduled prompt lifecycle
ToolSearchentityName: queryDeferred MCP tool discovery
All explore toolstoolInputSanitised tool_input clone (for debugging)
mcp__*Per-tool custom fieldsUnique metadata per MCP tool
Codex apply_patch / mcp__*crossCheck: { source, dedupeKey }Marker for hook ↔ rollout cross-check; server merges duplicate emissions

These fields are Agent Tracer's own extensions not present in the official Claude Code hook payload spec.


PostToolUse

Trigger: After successful tool execution Reader: readPostToolUse() Matchers: split per official tool — see claude-setup.md

FieldTypeValue
hook_event_namestring"PostToolUse"
tool_namestringTool name
tool_inputobjectSame as PreToolUse
tool_responseobjectTool execution result (varies per tool, can be large)
tool_use_idstringUnique tool call ID

Privacy contract: Agent Tracer's PostToolUse handlers never read tool_response. Result bodies (stdout, stderr, file content, web response, MCP result, search result list, grep snippets) are not captured; only the agent's action (tool_input) and quantitative wrappers (commandAnalysis, evidence reason) are stored.


PostToolUseFailure

Trigger: After tool execution failure Reader: readPostToolUseFailure() Matchers: Bash|Edit|Write|Read|Glob|Grep|WebFetch|WebSearch|Agent|Skill|TaskCreate|TaskUpdate|TodoWrite|AskUserQuestion|ExitPlanMode|mcp__.*

FieldTypeValue
hook_event_namestring"PostToolUseFailure"
tool_namestringTool name
tool_inputobjectSame as PreToolUse
tool_use_idstringUnique tool call ID
errorstringError message
is_interruptboolean?Whether interrupted by user

PostToolBatch

Trigger: After all parallel tool calls in a batch resolve, before the next model call Reader: readPostToolBatch()

FieldTypeValue
hook_event_namestring"PostToolBatch"
tool_use_idsstring[]IDs of tool calls in the resolved batch
tool_callsobject[][{ tool_name, tool_input }] for each call

Posts a context.saved event with trigger: "tool_batch_completed" and itemCount: batchSize so the timeline can draw a boundary between parallel tool fan-outs.


PermissionDenied

Trigger: When a tool call is denied by the auto-mode classifier Reader: readPermissionDenied() Matchers: same tool-name regex as PostToolUseFailure

FieldTypeValue
hook_event_namestring"PermissionDenied"
tool_namestringTool that was denied
tool_inputobjectInput that would have been used

Posts a rule.logged event with ruleOutcome: "auto_deny" and rulePolicy: "auto_mode_classifier".


SubagentStart

Trigger: When subagent starts Reader: readSubagentStart()

FieldTypeValue
hook_event_namestring"SubagentStart"
agent_idstringUnique subagent ID
subagent_typestringSubagent type name (required, e.g., "general-purpose")

[Observed] No transcript_path, permission_mode. [Observed] The reader requires subagent_type (it does not fall back to agent_type); agent_type is a separate generic session-context field.


SubagentStop

Trigger: When subagent stops Reader: readSubagentStop()Fire order: SubagentStopPostToolUse(Agent)

FieldTypeValue
hook_event_namestring"SubagentStop"
agent_idstringUnique subagent ID
subagent_typestringSubagent type name
last_assistant_messagestringFull last response from subagent (read ad hoc)
stop_reasonstring?Subagent termination reason

The reader surfaces only subagentType and stopReason (plus the shared session base); last_assistant_message is read ad hoc from the raw payload. [Observed] When /compact is performed, a compact-specific subagent runs internally. In this case, subagent_type comes as empty string "" (regular subagents have a type name).


TaskCreated

Trigger: When a task is created via the TaskCreate tool Reader: readTaskCreated()

FieldTypeValue
hook_event_namestring"TaskCreated"
task_namestringTask title
task_descriptionstring?Full description

Posts a todo.logged event with todoState: "added" and a stable todoId derived from the task name. Complements the PostToolUse/TaskCreate.ts handler which uses the same mapping.


TaskCompleted

Trigger: When a task is marked completed Reader: readTaskCompleted()

FieldTypeValue
hook_event_namestring"TaskCompleted"
task_namestringTask title that completed

Posts a todo.logged event with todoState: "completed".


Stop

Trigger: When Claude finishes responding (end of a turn) Reader: readStop()

FieldTypeValue
hook_event_namestring"Stop"
stop_reasonstring?`end_turn
last_assistant_messagestringFinal assistant message text

StopFailure

Trigger: When a turn ends due to an API error Reader: readStopFailure() Matchers: rate_limit|authentication_failed|billing_error|invalid_request|server_error|max_output_tokens|unknown

FieldTypeValue
hook_event_namestring"StopFailure"
error_typestringOne of the matcher values
error_messagestring?Optional human-readable error text

Posts an assistant.response event with stopReason: "error:<error_type>".


PreCompact

Trigger: Just before context compression (/compact or automatic) Reader: readPreCompact() Matchers: manual|auto

FieldTypeValue
hook_event_namestring"PreCompact"
triggerstring"manual" | "auto"
custom_instructionsstringUser compact instructions (empty string "" if not provided)

[Observed] custom_instructions is an implementation extension, not in the official schema. It is empty string "" if not provided, not null.


PostCompact

Trigger: After context compression completes Reader: readPostCompact() Matchers: manual|auto

FieldTypeValue
hook_event_namestring"PostCompact"
triggerstring"manual" | "auto"
compact_summarystringFull compression summary (<analysis>...</analysis><summary>...</summary> format, can be very long)

[Observed] compact_summary is an implementation extension, XML-formatted, several KB in size.


CwdChanged

Trigger: When the working directory changes (e.g. cd command in a Bash tool) Reader: readCwdChanged()

FieldTypeValue
hook_event_namestring"CwdChanged"
old_cwdstring?Previous working directory
new_cwdstring?New working directory

Posts a context.saved event with trigger: "cwd_changed".


FileChanged

Trigger: When a watched file changes on disk Reader: readFileChanged() Matcher: literal pipe-separated filenames (NOT regex), defaults to CLAUDE.md|.env|.envrc|.claude/settings.json|.claude/settings.local.json

FieldTypeValue
hook_event_namestring"FileChanged"
file_pathstringAbsolute path of the changed file

Posts a file.changed event in the background lane. Metadata: filePath, relPath.


WorktreeCreate

Trigger: When a worktree is being created via --worktree or isolation: "worktree" Reader: readWorktree()

FieldTypeValue
hook_event_namestring"WorktreeCreate"
worktree_pathstringTarget worktree directory

Posts a worktree.create event in the background lane. Stays synchronous because the official handler may rely on stdout for path resolution; this plugin never writes stdout, so the default behaviour wins.


WorktreeRemove

Trigger: When a worktree is being removed (session exit / subagent finish) Reader: readWorktree()

FieldTypeValue
hook_event_namestring"WorktreeRemove"
worktree_pathstringRemoved worktree directory

Posts a worktree.remove event in the background lane.


PermissionRequest

Trigger: When a permission dialog is about to show to the user Reader: readPermissionRequest()

FieldTypeValue
hook_event_namestring"PermissionRequest"
tool_namestringTool that triggered the dialog
tool_inputobjectPending tool arguments
tool_use_idstring?Tool-call id
permission_suggestionsarrayAuto-suggested rules to add

Posts a permission.request event in the coordination lane. Metadata: toolName, toolUseId, toolInputSummary (capped string), suggestionCount. Always returns exit 0 — the plugin never overrides the user's choice.


Setup

Trigger: When Claude Code is invoked with --init-only, --init -p, or --maintenance -p Reader: readSetup() Matchers: init|maintenance

FieldTypeValue
hook_event_namestring"Setup"
triggerstring"init" | "maintenance"

Posts a setup.triggered event in the planning lane.


UserPromptExpansion

Trigger: When a user-typed slash command (or MCP prompt) expands into a full prompt before Claude processes it Reader: readUserPromptExpansion()

FieldTypeValue
hook_event_namestring"UserPromptExpansion"
expansion_typestring"slash_command" | "mcp_prompt"
command_namestringSlash command or MCP prompt name
command_argsstring?Command arguments
command_sourcestring?Origin of the command
promptstringFull expanded prompt text

Posts a user.prompt.expansion event in the user lane. Metadata: expansionType, commandName, commandArgs, commandSource, expandedPromptSnippet (first 2 KB of expanded prompt with if truncated), expandedPromptBytes (utf-8 byte length of full prompt).

Why this matters: without this hook the tracer only sees /foo and not what /foo actually told Claude to do — high-value verification signal.


Notification

Trigger: When Claude Code emits a notification to the user Reader: readNotification() Matchers: permission_prompt|idle_prompt|auth_success|elicitation_dialog

FieldTypeValue
hook_event_namestring"Notification"
notification_typestring?One of the matcher values
notification_messagestring?Message shown to the user

Posts a context.saved event with trigger: "notification:<type>".


ConfigChange

Trigger: When a settings source changes during a session Reader: readConfigChange() Matchers: user_settings|project_settings|local_settings|policy_settings|skills

FieldTypeValue
hook_event_namestring"ConfigChange"
config_sourcestring?One of the matcher values

Posts a context.saved event with trigger: "config_change:<source>".

The hook event docs note that ConfigChange can block (except for policy_settings). Agent Tracer never blocks — it only observes.


StatusLine

Trigger: After every API request and on session start. Unlike the other hook events, statusLine is declared as a top-level key (not nested under hooks) in the plugin's hooks.json, so the plugin itself owns registration via ${CLAUDE_PLUGIN_ROOT} — no project .claude/settings.json entry is needed (see claude-setup.md).

Ref: https://code.claude.com/docs/en/statusline

FieldTypeValue
session_idstringCurrent Claude Code session ID
versionstringClaude Code client version
model.idstringModel ID (e.g., "claude-sonnet-4-6")
model.display_namestringHuman-readable model name
context_window.used_percentagenumber | nullContext usage as % of the active model's window
context_window.remaining_percentagenumber | nullRemaining % of the active model's window
context_window.total_input_tokensnumberAccumulated input tokens
context_window.total_output_tokensnumberAccumulated output tokens
context_window.context_window_sizenumberActive model's total window size (varies by model)
context_window.current_usageobject | nullLatest API call breakdown (input / output / cache-creation / cache-read)
cost.total_cost_usdnumberCumulative session cost in USD
rate_limits.five_hour.used_percentagenumber5-hour rate-limit usage (Pro/Max only)
rate_limits.five_hour.resets_atnumberUnix seconds; reset timestamp for 5-hour window
rate_limits.seven_day.used_percentagenumber7-day rate-limit usage (Pro/Max only)
rate_limits.seven_day.resets_atnumberUnix seconds; reset timestamp for 7-day window

[Observed] rate_limits is only present for Pro/Max plans. [Observed] used_percentage is already normalized against the active model's window size.

StatusLine is not an official hook event — it's a separate status-line feature. Its handler (StatusLine.ts) keeps its own specialized validation since the payload differs significantly from hook payloads; it does not go through the shared runHook() wrapper.

Agent Tracer behavior:

  • Posts a context.snapshot event (lane telemetry) on every status refresh.
  • Writes [monitor] ctx N% · 5h N% · $X.XXX to stdout for Claude Code's status bar.

Codex Hook Payloads

Official documentation: https://developers.openai.com/codex/hooks

Codex exposes 6 hook events: SessionStart, PreToolUse, PermissionRequest, PostToolUse, UserPromptSubmit, Stop. Agent Tracer has readers and handlers for all six, all registered in packages/runtime/src/codex/hooks/hooks.json.

Readers live at packages/runtime/src/shared/hooks/codex/payloads.ts.

Common Codex fields

All Codex events include session_id, cwd, hook_event_name, model. Turn-scoped events (PreToolUse, PermissionRequest, PostToolUse, UserPromptSubmit, Stop) additionally include turn_id.

The reader layer exposes these as three nested interfaces:

typescript
interface CodexSessionContextBase {
  sessionId: string;
  cwd?: string;
  transcriptPath?: string;
  model?: string;
}

interface CodexTurnContextBase extends CodexSessionContextBase {
  turnId?: string;
}

interface CodexToolContextBase extends CodexTurnContextBase {
  toolName: string;
  toolInput: object;
  toolUseId?: string;
}

Per-event differences from Claude

  • SessionStart matchers: startup|resume only (no clear|compact).
  • PreToolUse matcher: Bash|apply_patch|Edit|Write — guards every matched PostToolUse event with an idempotent session ensure.
  • PermissionRequest matcher: open (no matcher).
  • PostToolUse matchers: Bash (separate handler), apply_patch|Edit|Write (routes to PostToolUse/ApplyPatch.ts), and mcp__.* (routes to PostToolUse/Mcp.ts).
  • Stop carries stop_hook_active (boolean) instead of Claude's stop_reason. Agent Tracer emits stopReason: "stop_hook" as a synthetic value since Codex does not expose a real reason.

Hook ↔ rollout cross-check (Codex)

Codex's rollout JSONL stream and the official PostToolUse hooks both surface apply_patch and mcp__* events. The adapter attaches a crossCheck: { source, dedupeKey } marker to each emission — "hook" from the PostToolUse handler, "rollout" from the rollout observer (packages/runtime/src/codex/app-server/observe.ts). The server uses (kind, sessionId, dedupeKey) to merge the two within a 60-second window, so duplicates collapse into a single row.

dedupeKey resolution (preferred → fallback):

  • apply_patch: tool_use_idcall_id ; otherwise apply_patch:<primaryFilePath>:<patchInput.length>.
  • mcp__*: tool_use_idcall_id ; otherwise <tool_name>:<turn_id>.

web_search_call events still arrive only via the rollout observer (Codex has no web hook), but the marker is attached for forward-compat.

Codex privacy contract

The Codex adapter follows the same privacy contract as the Claude plugin: PostToolUse handlers never read tool_response. The rollout observer parses apply_patch.input only to extract touched file paths from *** Add File: / *** Update File: / *** Delete File: / *** Move to: headers; the diff body itself is never stored.

  • UserPromptSubmit has no cwd/transcript_path guarantees — only session_id, prompt, model, turn_id.

PermissionRequest

Posts a rule.logged event with ruleStatus: "requested", ruleOutcome: "observed", and rulePolicy: "codex_permission". Agent Tracer is observation-only and never sets decision.behavior — Codex uses its built-in policy.


Event Fire Order

Session start
  └─ SessionStart

User input
  └─ UserPromptSubmit

Tool execution
  ├─ PreToolUse
  ├─ (tool execution)
  └─ PostToolUse | PostToolUseFailure | PermissionDenied

Parallel tool batch
  └─ PostToolBatch (after every call in the batch resolves)

Agent tool execution
  ├─ PreToolUse (tool_name: "Agent")
  ├─ SubagentStart
  ├─ (tools inside subagent: PreToolUse / PostToolUse repeating)
  ├─ SubagentStop
  └─ PostToolUse (tool_name: "Agent")   ← after SubagentStop

Native task tooling
  ├─ PostToolUse (tool_name: "TaskCreate") → TaskCreated
  └─ PostToolUse (tool_name: "TaskUpdate") → TaskCompleted (on completion)

/compact execution
  ├─ PreCompact
  ├─ SubagentStart (agent_type: "")     ← compact-specific internal agent
  ├─ SubagentStop  (agent_type: "")
  └─ PostCompact

Session end
  └─ Stop | StopFailure
  └─ SessionEnd

Subagent Event Routing

The session_id problem

All hooks — including those that fire inside a subagent — receive the parent session's session_id. The session_id field does not change when Claude Code dispatches a subagent. This means a naive resolveSessionIds(session_id) call always returns the parent task's IDs, causing subagent tool events to be recorded under the parent instead of a separate child task.

agent_id is the only field that distinguishes subagent context from parent context.

Virtual session ID pattern (Agent Tracer solution)

Agent Tracer resolves this by mapping every agent_id to a virtual session ID:

virtualId = `sub--${agentId}`

resolveEventSessionIds(sessionId, agentId?, agentType?) is the canonical dispatcher:

  • agentId absent → falls through to resolveSessionIds(sessionId) (parent task, unchanged)
  • agentId present → calls resolveSubagentSessionIds(sessionId, agentId, agentType):
    1. Resolve parent session to obtain parentTaskId
    2. Call ensureRuntimeSession(virtualId, title, { parentTaskId }) → server creates (or reuses) the background child task
    3. Return the child (taskId, sessionId) for this hook invocation

This relies entirely on the server's idempotent ensureRuntimeSession endpoint (EnsureRuntimeSessionUseCase). Since v0.2.0 the plugin no longer persists a session cache to disk — every hook subprocess calls the endpoint directly; repeated calls with the same runtimeSessionId return the same (taskId, sessionId) without creating duplicates.

Lifecycle

EventBehaviour
SubagentStartEagerly calls resolveSubagentSessionIds → child background task created immediately via idempotent ensureRuntimeSession(virtualId, …, { parentTaskId })
PreToolUse (inside subagent)Calls resolveEventSessionIds(sessionId, agentId) → ensures session exists before first tool fires
PostToolUse/* (inside subagent)All tool events routed to child task timeline via resolveEventSessionIds
Stop (inside subagent)assistant.response recorded on child task; session-end skipped (SubagentStop handles it)
SubagentStopCalls POST /ingest/v1/sessions/end for the virtual session with completeTask: false; cursor for sub--{agentId} is deleted

Code Implementation Notes

SituationCurrentRecommended
transcript_path usageCaptured when presentDo not rely on, may be missing
subagent_type (SubagentStart/Stop)Reader requires it; returns { ok: false, reason: "missing subagent_type" } when absent (no || "unknown" defaulting)An empty-string compact-agent type is therefore treated as missing
custom_instructions empty|| "" (already handled)Keep current code
compact_summary logging-Length limit required (several KB)
tool_response loggingRedacted in hookLogPayloadPreserve
session_id in subagent hooksParent's session_id is always sentUse agent_id + resolveEventSessionIds to route to child task
ValidationPayload readers in ~shared/hooks/{claude,codex}/payloads.ts; never throw, return { ok: false, reason } on missing required fieldsAdd new events by defining a reader + invoking runHook(name, { logger, parse, handler })

Local-first documentation for Agent Tracer.