Verified against Claude Code 2.1.178.
Claude Code runs user/plugin-defined commands at fixed lifecycle points. Configuration lives under a top-level hooks key in settings; each event maps to an array of { matcher, hooks: [{ type, command, timeout, statusMessage }] } groups. Ground truth here is the bundled Zod input/output schemas (k.object(...) / k.literal(...)) plus the embedded ## Hooks Configuration documentation block — both are shipped verbatim in the bundle.
The documented shape (from the in-bundle docs block, recovered via strings.txt):
"hooks": { "EVENT_NAME": [ { "matcher": "ToolName|OtherTool",
"hooks": [ { "type": "command", "command": "...", "timeout": 60, "statusMessage": "Running..." } ] } ] }
There are four hook types. Three are documented (command, prompt, agent); a fourth (http) and an internal mcp_tool form exist in code:
**1. Command Hook** - Runs a shell command:
{ "type": "command", "command": "prettier --write $FILE", "timeout": 30 }
**2. Prompt Hook** - Evaluates a condition with LLM:
{ "type": "prompt", "prompt": "Is this safe? $ARGUMENTS" }
**3. Agent Hook** - Runs an agent with tools:
{ "type": "agent", "prompt": "Verify tests pass: $ARGUMENTS" }
Only available for tool events: PreToolUse, PostToolUse, PermissionRequest.
type:"http",command:_.url // ...else if(_.type==="mcp_tool")return{type:"mcp_tool",command:`${_.server}/${_.tool}`}
prompt/agent hooks are restricted to the tool events (PreToolUse, PostToolUse, PermissionRequest).
Documented event table (verbatim):
| PermissionRequest | Tool name | Run before permission prompt |
| PreToolUse | Tool name | Run before tool, can block |
| PostToolUse | Tool name | Run after successful tool |
| PostToolUseFailure | Tool name | Run after tool fails |
| Notification | Notification type | Run on notifications |
| Stop | - | Run when Claude stops (including clear, resume, compact) |
| PreCompact | "manual"/"auto" | Before compaction |
| PostCompact | "manual"/"auto" | After compaction (receives summary) |
| UserPromptSubmit | - | When user submits |
| SessionStart | - | When session starts |
The schema layer registers a broader set than the docs expose, including PostToolBatch, PermissionDenied, SubagentStart/SubagentStop, StopFailure, Setup, UserPromptExpansion, TeammateIdle, TaskCreated/TaskCompleted, Elicitation/ElicitationResult:
hook_event_name:k.literal("PostToolBatch"),tool_calls:k.array(...) // "Fired once after every tool call in a batch has resolved, before the next model request. PostToolUse fires per-tool and may run concurrently for parallel tool calls; PostToolBatch fires exactly once with the full batch."
hook_event_name:k.literal("PermissionDenied"),tool_name,tool_input,tool_use_id,reason
A Stop hook running inside a subagent is rewritten to SubagentStop:
Converting Stop hook to SubagentStop for (subagents trigger SubagentStop)
Every event extends a common base object (Aj()) carrying session context, then adds event-specific fields:
session_id:k.string(),transcript_path:k.string(),cwd:k.string(),permission_mode:k.string().optional(),
agent_id:k.string().optional()... // "Present only when the hook fires from within a subagent... Use this field (not agent_type) to distinguish subagent calls from main-thread calls."
agent_type:k.string().optional()...
effort:k.object({level:k.string()...}).optional() // "...exposed to hook commands and Bash as the CLAUDE_EFFORT env var."
Per-event additions (selected, verbatim):
PreToolUse: hook_event_name:k.literal("PreToolUse"),tool_name,tool_input,tool_use_id
PostToolUse: hook_event_name:k.literal("PostToolUse"),tool_name,tool_input,tool_response,tool_use_id,duration_ms // "Excludes permission-prompt and hook time."
PostToolUseFailure: ...tool_name,tool_input,tool_use_id,error:k.string(),is_interrupt,duration_ms
UserPromptSubmit: ...prompt:k.string(),session_title?
SessionStart: ...source:k.enum(["startup","resume","clear","compact"]),agent_type?,model?,session_title?
Stop: ...stop_hook_active:k.boolean(),last_assistant_message?,background_tasks?,session_crons?
SubagentStop: ...stop_hook_active,agent_id,agent_transcript_path,agent_type,last_assistant_message?,background_tasks?,session_crons?
PreCompact: ...trigger:k.enum(["manual","auto"]),custom_instructions:k.string().nullable()
PostCompact: ...trigger:k.enum(["manual","auto"]),compact_summary:k.string() // "The conversation summary produced by compaction"
Notification: ...message:k.string(),title?,notification_type:k.string()
The docs show the minimal stdin example with the // PostToolUse only annotation on tool_response:
"session_id": "abc123", "tool_name": "Write",
"tool_input": { "file_path": "...", "content": "..." },
"tool_response": { "success": true } // PostToolUse only
background_tasks and session_crons on Stop/SubagentStop let a hook tell "session done" from "paused waiting on background work" (describe(...) strings confirm this).
The hook may print JSON to stdout. Top-level fields:
continue:k.boolean().optional(),suppressOutput:k.boolean().optional(),stopReason:k.string().optional(),
decision:k.enum(["approve","block"]).optional(),systemMessage:k.string().optional(),
terminalSequence:k.string().optional()... // "Only notification/title OSCs (0,1,2,9,99,777) and BEL are permitted; anything else is dropped."
reason:k.string().optional(),hookSpecificOutput:k.union([...])
Documented field semantics (verbatim):
- `continue` - Set to `false` to block/stop (default: true)
- `stopReason` - Message shown when `continue` is false
- `suppressOutput` - Hide stdout from transcript (default: false)
- `decision` - "block" for PostToolUse/Stop/UserPromptSubmit hooks (deprecated for PreToolUse, use hookSpecificOutput.permissionDecision instead)
- `hookSpecificOutput` ... must include `hookEventName`
hookSpecificOutput is a discriminated union keyed by hookEventName. Key per-event outputs:
PreToolUse: permissionDecision:k.enum(["allow","deny","ask","defer"]).optional(),permissionDecisionReason?,updatedInput?,additionalContext?
UserPromptSubmit: additionalContext?,sessionTitle?,suppressOriginalPrompt? // 'When decision is "block", omit the original prompt from the block message'
SessionStart: additionalContext?,initialUserMessage?,sessionTitle?,watchPaths?,reloadSkills? // "Re-scan skill and command directories after SessionStart hooks complete"
PostToolUse: additionalContext?,updatedToolOutput? // "Replaces the tool output before it is sent to the model"; updatedMCPToolOutput? (MCP only)
Stop: additionalContext? // "non-error feedback delivered to the model; the conversation continues so the model can act on it."
permissionDecision carries a fourth value defer beyond the documented allow/deny/ask. It is print-mode-only and solo-only — ignored interactively or when more than one tool call is in the batch:
Hook ... returned permissionDecision=defer in interactive mode; ignoring (defer is print-mode only)
Hook ... returned permissionDecision=defer but N tool calls are in this batch; ignoring (defer is solo-only — siblings would be orphaned on resume)
Besides JSON output, a non-zero exit feeds stderr back as feedback; exit code 2 is the blocking signal. Confirmed for the Stop path:
if(J.code===2){let P=`Stop hook blocking error from command "${T}":`,Z="Stop hook feedback";...}
PostToolUse blocking surfaces as Execution stopped by PostToolUse hook and a tengu_post_tool_hook_error event.
Hook commands are spawned with a per-hook timeout and a controlled environment. Default timeout is WO=600000 ms (600 s); per-hook timeout is in seconds and multiplied by 1000:
S=H.timeout?H.timeout*1000:WO // WO=600000
I={...qV(),...I8_(O),CLAUDE_PROJECT_DIR:G(R)}; if(u)I.COLUMNS=String(u); if(b)I.LINES=String(b);
if(Y){I.CLAUDE_PLUGIN_ROOT=G(Y); if(A)I.CLAUDE_PLUGIN_DATA=G(d7H(A))}
... I[`CLAUDE_PLUGIN_OPTION_${PH}`]=String(MH) ...
if(!Z&&(_==="SessionStart"||_==="Setup"||_==="CwdChanged"||_==="FileChanged")&&$!==void 0)I.CLAUDE_ENV_FILE=await CE7(_,$)
The child-session env builder injects the session/effort/trace vars:
function I8_(H){let _={CLAUDECODE:"1",CLAUDE_CODE_SESSION_ID:H.sessionId,CLAUDE_CODE_CHILD_SESSION:"1"};
if(H.source==="agent")_.AI_AGENT=Qt6("agent");
if(H.effortLevel!==void 0)_.CLAUDE_EFFORT=H.effortLevel;
if(wX_()){let q=a06();if(q!==void 0)_.TRACEPARENT=q}return _}
cwd is resolved with a fallback if the session cwd no longer exists:
Hooks: cwd ${B} not found, falling back to original cwd
Timeouts abort per-hook (not the whole batch); each event has its own message, e.g. PostToolUse hook timed out (per-hook abort), PostToolUseFailure hook cancelled (parent abort). Internal executors are named per event (executeUserPromptSubmitHooks, executeSessionStartHooks, executePostToolUseFailureHooks, executePostCompactHooks).
$ARGUMENTS / $FILE substitution and ${CLAUDE_PROJECT_DIR} / ${CLAUDE_EFFORT} expansion are supported in command strings (both tokens appear in shipped command templates).
Hooks can be globally disabled or restricted to managed (admin-policy) hooks via settings:
n8T(){return I6("policySettings")?.allowManagedHooksOnly===!0}
i8T(){return rq()?.disableAllHooks===!0&&I6("policySettings")?.disableAllHooks===!0}
... // "are restricted (disableAllHooks or allowManagedHooksOnly is set in settings or by policy)."
Plugin SessionStart hooks fail soft:
Warning: Failed to load plugin hooks. SessionStart hooks from plugins will not execute. Error:
HTTP hooks are gated by an allowlist; outbound URLs and forwarded env are restricted:
... does not match any pattern in allowedHttpHookUrls // settings keys: allowedHttpHookUrls, httpHookAllowedEnvVars
- Timeout units differ from the field name: config
timeoutis in seconds (*1000internally); the global default is 600 s (WO=600000). A hook that hangs blocks that event for up to 10 minutes unless you settimeout. - Blocking has two channels: exit code
2(stderr → fed back as feedback) and JSON ({"continue":false}or, on tool events,hookSpecificOutput.permissionDecision:"deny"/"ask"). ForPreToolUse, preferpermissionDecision; top-leveldecision:"block"is deprecated there. permissionDecisionhas a 4th valuedefer— only honored in print/non-interactive mode and only when exactly one tool call is in the batch; silently ignored otherwise.- PostToolUse can mutate tool output via
updatedToolOutput(all tools) /updatedMCPToolOutput(MCP only), andPreToolUsecan rewrite tool input viaupdatedInputbefore execution. - Env available to hook commands:
CLAUDE_PROJECT_DIR,CLAUDE_EFFORT,CLAUDE_CODE_SESSION_ID,CLAUDECODE=1,CLAUDE_CODE_CHILD_SESSION=1, plusCLAUDE_PLUGIN_ROOT/_DATA/_OPTION_*for plugin hooks andCLAUDE_ENV_FILE(SessionStart/Setup/CwdChanged/FileChanged only).CLAUDE_CODE_SHELL_PREFIXis read to wrap the shell command. - Subagents: a configured
Stophook fires asSubagentStopinside a subagent;agent_id(notagent_type) is the reliable "this came from a subagent" signal. Uselast_assistant_messageinstead of parsing the transcript. Stop/SubagentStopgetbackground_tasksandsession_cronsso a stop hook can distinguish a finished session from one parked waiting on background work or a scheduled wakeup.- Policy kill-switches:
disableAllHooks(settings + policy must both set it) andallowManagedHooksOnly(admin-policy hooks only). HTTP hooks needallowedHttpHookUrlsand env passthrough is filtered byhttpHookAllowedEnvVars. reloadSkills:trueon SessionStart output re-scans skill/command dirs so a hook that installs skills makes them usable in the same session.terminalSequencelets a hook emit a desktop-notification OSC, but only OSC 0/1/2/9/99/777 and BEL pass the filter.