Chapter 14. Claude Code Hooks: Every Event and the Contract Each One Has

The documentation defines 30 hook event names, and a hook is code the harness runs at one of those events on its own schedule, with a documented input, a documented output, and defined blocking behavior. A CLAUDE.md instruction is advice the model can ignore under pressure. The failure mode of a half-understood hook is a gate that looks armed and never fires.

The 30 events are SessionStart, Setup, UserPromptSubmit, UserPromptExpansion, PreToolUse, PermissionRequest, PermissionDenied, PostToolUse, PostToolUseFailure, PostToolBatch, Notification, MessageDisplay, SubagentStart, SubagentStop, TaskCreated, TaskCompleted, Stop, StopFailure, TeammateIdle, InstructionsLoaded, ConfigChange, CwdChanged, FileChanged, WorktreeCreate, WorktreeRemove, PreCompact, PostCompact, Elicitation, ElicitationResult, and SessionEnd. Each one carries a contract: when it fires, what arrives on stdin, what an exit code means there, and what JSON it honors on the way back.

The machine I verified against runs Claude Code 2.1.211. Features gated at v2.1.212 or higher (automatic MCP tool-call backgrounding), at v2.1.214 (exit-2 JSON validation still blocking, the SessionStart fork source, list_changed refresh retention), and at v2.1.216 and v2.1.218 are documented but not available in this environment.

What every hook receives on stdin

Every hook, regardless of event, receives a JSON object on stdin (or as the HTTP POST body for http hooks) with these common fields: session_id, prompt_id (a UUID, requires v2.1.196 or later, absent until first user input), transcript_path, cwd, permission_mode, effort (an object with a level field), and hook_event_name. Inside subagents or with --agent, agent_id and agent_type are added.

Two of those fields have trap values. permission_mode arrives as one of “default”, “plan”, “acceptEdits”, “auto”, “dontAsk”, or “bypassPermissions”, and the mode labeled Manual in the UI arrives as “default”, never as “manual”. A script that string-matches “manual” matches nothing. effort.level is one of “low”, “medium”, “high”, “xhigh”, “max”; Ultracode is not a distinct level and reports as “xhigh”. The same value reaches hook commands and the Bash tool as the $CLAUDE_EFFORT environment variable.

Hook exit codes: what 0, 1, and 2 each do

Exit 0 succeeds, exit 2 blocks, and any other exit code is a non-blocking error, which is the part the obvious guess gets wrong.

On exit 0 the harness parses stdout for JSON output, and JSON is only processed on exit 0. On exit 2 stdout and any JSON are ignored, and stderr is fed to Claude. Any other exit code shows a <hook name> hook error notice plus the first stderr line. Exit code 1 does not block. A lint script that fails with exit 1 and expects to stop the write will produce a notice and then watch the write proceed. The sole exception to this table is WorktreeCreate, where any non-zero exit aborts worktree creation.

As of v2.1.214, a hook that exits 2 while printing JSON that fails output-schema validation still blocks, using stderr as the blocking reason; before v2.1.214 that combination was treated as a non-blocking error and the action proceeded. On my 2.1.211 install, a blocking hook with a malformed JSON body is not a blocking hook.

Exit 2 blocks on these events, with the documented effect: PreToolUse (blocks the tool call), PermissionRequest (denies permission), UserPromptSubmit (blocks and erases the prompt), UserPromptExpansion, Stop, SubagentStop, TeammateIdle, TaskCreated (rolls back creation), TaskCompleted, ConfigChange (except policy_settings), PostToolBatch (stops the agentic loop before the next model call), PreCompact, Elicitation, ElicitationResult (the response becomes decline), and WorktreeCreate.

Exit 2 does not block on: PostToolUse, PostToolUseFailure, PermissionDenied (exit code and stderr ignored; use JSON hookSpecificOutput.retry), StopFailure (output and exit code ignored), Notification, SubagentStart, SessionStart, Setup, SessionEnd, CwdChanged, FileChanged, PostCompact, WorktreeRemove (failures logged in debug only), InstructionsLoaded (exit code ignored), and MessageDisplay (the original text is displayed). The pattern to internalize: pre-events gate, post-events observe. If your enforcement runs after the action, it is logging, not enforcement.

The five JSON fields every hook can return

Five fields work in every hook’s JSON output: continue (default true; false stops Claude entirely and takes precedence over event-specific decision fields), stopReason (shown to the user, not to Claude), suppressOutput (default false), systemMessage, and terminalSequence. That last one requires v2.1.141 or later and is restricted to OSC 0, 1, 2, 9 (including 9;4 taskbar progress), 99, 777, and bare BEL; anything outside the allowlist (CSI cursor or color sequences, OSC palette, OSC 8 hyperlinks, OSC 52 clipboard writes, OSC 1337) causes the field to be ignored.

Output strings, including additionalContext, systemMessage, and plain stdout, are capped at 10,000 characters; output past the cap is saved to a file and replaced with a preview plus the file path. And additionalContext has a placement rule: it must be nested inside hookSpecificOutput alongside a hookEventName field. At the top level of the JSON it is silently ignored. When accepted, Claude Code wraps the string in a system reminder rather than a chat message.

How the matcher field is parsed

The matcher field decides which occurrences of an event fire your hook, and its parsing rule is character-driven. A matcher of "*", an empty string, or an omitted field matches all. A value containing only letters, digits, _, -, spaces, , and | is treated as an exact string or a |/,-separated list of exact strings. Any other character flips the whole value into an unanchored JavaScript RegExp tested with RegExp.prototype.test.

Unanchored is the word to respect. The docs give the version history here: comma separators and surrounding-whitespace tolerance require v2.1.191 or later, and hyphens in the exact-match set require v2.1.195 or later. On earlier versions, code-reviewer is an unanchored regex, and an unanchored regex for code-reviewer also fires for senior-code-reviewer. Two events keep a narrower exact-match set: FileChanged and StopFailure accept only letters, digits, _ and | as exact-match characters, so a hyphen, space, or comma in a matcher for those two stays on the regex path.

Ten events have no matcher support at all and always fire, with any matcher field silently ignored: UserPromptSubmit, PostToolBatch, Stop, TeammateIdle, TaskCreated, TaskCompleted, WorktreeCreate, WorktreeRemove, MessageDisplay, CwdChanged. A matcher on Stop does not error; it does nothing, and you find out when the hook fires on turns you meant to exclude.

What the matcher matches against differs per event:

Event(s) Matcher target and values
PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied tool_name
SessionStart source: startup, resume, clear, compact, fork
Setup init or maintenance
SessionEnd clear, resume, logout, prompt_input_exit, bypass_permissions_disabled, other
PreCompact, PostCompact manual or auto
ConfigChange user_settings, project_settings, local_settings, policy_settings, skills
Elicitation, ElicitationResult the MCP server name
UserPromptExpansion the command name
SubagentStart, SubagentStop agent type
Notification permission_prompt, idle_prompt, auth_success, elicitation_dialog, elicitation_complete, elicitation_response, agent_needs_input, agent_completed
StopFailure error_type: rate_limit, overloaded, authentication_failed, oauth_org_not_allowed, billing_error, invalid_request, model_not_found, server_error, max_output_tokens, unknown

The Notification matchers agent_needs_input and agent_completed require v2.1.198 or later and fire only while agent view is open.

MCP tools reach the matcher under the pattern mcp__<server>__<tool>. Matching a whole server requires appending .*, as in mcp__memory__.*; a bare prefix like mcp__memory contains only exact-match characters and therefore matches no tool. Plugin-bundled MCP servers add a layer: their tools are named mcp__plugin_<plugin-name>_<server-name>__<tool>, with any character outside A-Z, a-z, 0-9, _ and - replaced by _, so a matcher written against the bare server key never fires for them. The server itself registers under the scoped name plugin:<plugin-name>:<server-name>, which is what an mcp_tool hook’s server field must use.

Handler types: command, http, mcp_tool, prompt, and agent

The type field accepts five values, so a hook is not limited to a shell command: command, http, mcp_tool, prompt, and agent, with agent documented as experimental and subject to change.

Type support is uneven across events. Thirteen events support all five types: PermissionDenied, PermissionRequest, PostToolBatch, PostToolUse, PostToolUseFailure, PreToolUse, Stop, SubagentStop, TaskCompleted, TaskCreated, TeammateIdle, UserPromptExpansion, UserPromptSubmit. Another set supports only command, http, and mcp_tool: ConfigChange, CwdChanged, Elicitation, ElicitationResult, FileChanged, InstructionsLoaded, Notification, PostCompact, PreCompact, SessionEnd, StopFailure, SubagentStart, WorktreeCreate, WorktreeRemove. SessionStart and Setup support only command and mcp_tool. The source I verified against states a count of thirteen for that second group while enumerating fourteen events, and MessageDisplay appears in none of the three groups; the count and the enumeration disagree. I resolved the MessageDisplay half by measurement rather than by reading harder. I wrote a command hook on MessageDisplay into a throwaway settings file, ran a session against it, and the hook fired and received a payload. So MessageDisplay supports command handlers whatever the grouping table says, and the table is incomplete rather than the event being special. The thirteen-versus-fourteen discrepancy in the source stands unresolved, and I would not script against that count.

if, timeout, statusMessage, and once

Beyond type, all handlers accept if, timeout, statusMessage, and once. Two of these carry restrictions that the field names do not advertise.

once: true runs the hook one time per session and then removes it, but it is only honored in skill frontmatter; in settings files and in agent frontmatter it is ignored. if holds exactly one permission rule (for example "Bash(git *)" or "Edit(*.ts)"), with no &&, ||, or list syntax, and it is only evaluated on the five tool events: PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied. On any other event, a hook with if set never runs. An if copied onto a Stop hook disarms it.

timeout is in seconds, with defaults of 600 for command, http, and mcp_tool hooks, 30 for prompt, and 60 for agent. Two events lower the command/http/mcp_tool default: UserPromptSubmit to 30 seconds and MessageDisplay to 10.

Command hooks: shell form, exec form, and async

Fields beyond the common set: command (required), args, async, asyncRewake, and shell (accepts “bash” or “powershell”). The presence of args switches the hook to exec form: no shell, each args element passed verbatim. Without args you get shell form: sh -c on macOS and Linux, Git Bash on Windows, or PowerShell when Git Bash is absent. If your command has quoting problems, moving to exec form deletes the whole problem class.

As of v2.1.139, command hooks run in their own session with no controlling terminal on macOS and Linux, so they cannot open /dev/tty or emit escape sequences directly; systemMessage or terminalSequence in JSON output is the supported path. They otherwise run with the user’s full permissions, which is the security posture to design around: a hook is your account, unattended.

async: true is available only on command hooks. An async hook cannot block or return decisions (decision, permissionDecision, and continue have no effect), and its output arrives on the next conversation turn. asyncRewake: true implies async and wakes Claude on exit code 2, showing stderr (or stdout when stderr is empty) as a system reminder. There is no deduplication across multiple firings of the same async hook, so a hot FileChanged event can stack a pile of them.

HTTP hooks, and why a down gate is an open gate

Fields: url (required), headers, allowedEnvVars. Claude Code POSTs the hook JSON input with Content-Type: application/json. Header values interpolate $VAR_NAME or ${VAR_NAME} only for variables listed in allowedEnvVars; unlisted references become empty strings, so a misspelled allowlist entry turns an auth header into Bearer with nothing after it. The blocking rule inverts intuition: non-2xx responses, connection failures, and timeouts are all non-blocking errors. Blocking requires a 2xx response carrying a blocking JSON body. An HTTP gate that is down is a gate that is open.

mcp_tool hooks and the connected-server requirement

Fields: server (required), tool (required), input. String values in input support ${path} substitution from the hook JSON input, such as "${tool_input.file_path}". The server must already be connected; the hook never triggers an OAuth or connection flow, and a disconnected server, like a tool returning isError: true, produces a non-blocking error. Same shape as the HTTP failure mode: the gate fails open.

Prompt and agent hooks: the decision schema

Both accept prompt (required), model (defaults to a fast model, Haiku by default), timeout, and continueOnBlock (default false). Inside the prompt, $ARGUMENTS is the placeholder for the hook input JSON; when $ARGUMENTS is absent, the input JSON is appended. The model must answer in the schema {"ok": true|false, "reason": "..."}, with reason required when ok is false.

An agent hook goes further: it spawns a subagent with Read, Grep, and Glob access that returns a decision after up to 50 turns, default timeout 60 seconds. That is enough rope to check whether an edited file still imports cleanly before letting a Stop through, and enough rope to burn a minute per turn if you aim it badly.

What each event receives and what it can block

PreToolUse, PostToolUse, and the two permission events

PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, and PermissionDenied all match on tool_name and all receive tool_name, tool_input, and tool_use_id in their input. PostToolUse additionally receives tool_response, the tool’s structured Output object (for Write, an object like {filePath, success}). The field is named tool_response, not tool_output; two automated summarization passes over the hooks page got that name wrong, and a check against the raw markdown settled it.

PreToolUse is the gate with the richest decision surface. Its decision output lives in hookSpecificOutput with permissionDecision (“allow”, “deny”, “ask”, “defer”), permissionDecisionReason, updatedInput (which replaces the entire input object, not a patch), and additionalContext. When multiple PreToolUse hooks disagree, precedence is deny > defer > ask > allow. The top-level decision/reason fields are deprecated for this event, with the old values “approve” and “block” mapping to “allow” and “deny”. PostToolUse and Stop, meanwhile, still use top-level decision/reason as their current format, so the same settings file legitimately carries both styles.

“defer” is the odd one out. It is honored only in non-interactive mode with -p; the process exits with stop_reason: "tool_deferred" and a deferred_tool_use object carrying the tool’s id, name, and input. It works only when Claude makes a single tool call in the turn, and if the tool is missing on resume, you get stop_reason: "tool_deferred_unavailable" with is_error: true.

PermissionRequest answers with a different shape: hookSpecificOutput.decision.behavior (“allow” or “deny”), decision.updatedInput to rewrite arguments, and decision.updatedPermissions accepting entries such as { "type": "setMode", "mode": "acceptEdits", "destination": "session" }. PermissionRequest hooks do not fire in non-interactive mode with -p, which rules them out for CI gating. The setMode entry above is the only shape I verified. Other entry types exist and I did not confirm their names, so treat the example as one known-good form rather than the whole grammar.

PostToolUse can return top-level decision: "block" with reason, and can also return updatedToolOutput to rewrite what the model sees. PermissionDenied ignores exit codes and stderr entirely; its one lever is hookSpecificOutput.retry: true.

PostToolBatch receives tool_calls, an array where each entry has tool_name, tool_input, tool_use_id, and tool_response. Note the shape shift: here tool_response is the serialized tool_result content the model sees, a different thing from PostToolUse’s structured Output object. Exit 2 on PostToolBatch stops the agentic loop before the next model call, which makes it the last chance to halt a runaway sequence between turns.

SessionStart, Setup, and SessionEnd

SessionStart matches on source (startup, resume, clear, compact, fork; before v2.1.214 forked sessions reported “resume”) and receives source, model, agent_type, and session_title as input. Its output fields are the most constructive of any event: additionalContext, initialUserMessage, sessionTitle (applied when source is startup, resume, or fork; ignored on clear and compact), watchPaths (an array of absolute paths that arms FileChanged), and reloadSkills (a boolean re-scan of skill and command directories). Exit 2 does not block here; SessionStart is for provisioning, not vetoes.

Setup is not a synonym for SessionStart. Its input field is trigger, not source, with values “init” or “maintenance”, and it fires only with claude --init-only, or with --init or --maintenance in -p mode. It never fires on normal startup.

Four events get CLAUDE_ENV_FILE: SessionStart, Setup, CwdChanged, and FileChanged. It is an environment variable holding a file path where the hook can persist export statements for subsequent Bash commands. That is the sanctioned way to load direnv-style state into a session.

SessionEnd matches on reason (clear, resume, logout, prompt_input_exit, bypass_permissions_disabled, other) and runs under the tightest clock in the system: a default timeout of 1.5 seconds. The overall budget rises to the highest per-hook timeout configured in settings files, capped at 60 seconds, and CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS overrides it in milliseconds. Anything slow belongs elsewhere; SessionEnd is for a fast flush, not a build.

Stop, StopFailure, and the block cap

Stop fires when Claude wants to end its turn, and exit 2 (or decision: "block" with reason) sends it back to work. Input includes stop_hook_active (true when Claude Code is already continuing because of a stop hook; check it unless you enjoy loops), last_assistant_message, and, requiring v2.1.145 or later, the background_tasks and session_crons arrays. background_tasks entries carry id, type, status, description, command, agent_type, server, tool, and name, where type is one of shell, subagent, monitor, workflow, teammate, cloud session, MCP task. session_crons entries carry id, schedule, recurring. A Stop hook can therefore refuse to let a turn end while a deploy is still running in the background, which is the single most useful gate I know of for unattended sessions.

The harness protects itself: after 8 consecutive blocks, Claude Code overrides the hook and ends the turn. CLAUDE_CODE_STOP_HOOK_BLOCK_CAP raises the cap.

StopFailure is the API-failure sibling, matching on error_type with the ten values listed in the matcher table. Its output and exit code are ignored; it is a pure observer, useful for paging yourself on rate_limit or billing_error and for nothing else.

SubagentStart, SubagentStop, and the task events

SubagentStart and SubagentStop match on agent type. SubagentStop can block with exit 2; SubagentStart cannot. TaskCreated and TaskCompleted both receive task_id, task_subject, task_description, teammate_name, and team_name, with team_name documented as deprecated and slated for removal. Exit 2 on TaskCreated rolls back the creation; on TaskCompleted it blocks. TeammateIdle supports all five handler types, takes no matcher, and blocks on exit 2. Their event-specific input fields are not in this book. The docs did not enumerate them and my probe session died before it spawned a subagent, so I have neither a documented list nor a captured one. Dump a payload before you write a hook against these three.

UserPromptSubmit, UserPromptExpansion, and MessageDisplay

UserPromptSubmit fires on every user prompt, takes no matcher, and is the one event where exit 2 both blocks and erases the prompt. It uses top-level decision: "block" plus reason, and supports hookSpecificOutput.additionalContext for injecting context alongside the prompt. Its command/http/mcp_tool timeout default drops to 30 seconds because the user is waiting on it.

UserPromptExpansion matches on the command name and blocks on exit 2. MessageDisplay takes no matcher, has a 10-second default timeout, cannot block (exit 2 leaves the original text displayed), and returns displayContent, a display-only rewrite of the message. MessageDisplay’s payload I can give you exactly, because I captured one. Beyond the four common fields it carries prompt_id, turn_id, message_id, index, final, and delta, where delta holds the message text and final marks whether the message is complete. UserPromptExpansion I never triggered, so its field names stay out of this book rather than going in as a guess.

Three hook payloads I captured from a throwaway session

The field lists above come from documentation. These three come from a hook I ran. I wrote a command handler for ten events into a throwaway settings file, pointed it at a script that appends stdin to a log, and ran one session against it. Every event below carries session_id, transcript_path, cwd, and hook_event_name; the table lists what each adds on top.

Event Additional fields observed
SessionStart source (value startup)
UserPromptSubmit prompt_id, permission_mode, prompt
MessageDisplay prompt_id, turn_id, message_id, index, final, delta

Twenty lines of shell answered questions the documentation left open, and it is the technique to reach for whenever a payload matters:

#!/bin/bash
payload=$(cat)
printf '%s\n' "$payload" >> /tmp/hookprobe/events.jsonl
exit 0

Point every event you care about at that script, run one throwaway session, and read the log. The answer takes a minute and it is true for the version you are running.

ConfigChange, InstructionsLoaded, CwdChanged, and FileChanged

ConfigChange matches on which settings source changed (user_settings, project_settings, local_settings, policy_settings, skills) and blocks on exit 2 except for policy_settings, which no hook gets to veto. InstructionsLoaded matches on load_reason (session_start, nested_traversal, path_glob_match, include, compact) and supports no blocking or decision control; its exit code is ignored.

CwdChanged receives old_cwd and new_cwd. FileChanged receives file_path and event (“change”, “add”, “unlink”). Neither has decision control, and both can return watchPaths (absolute paths) to replace the dynamic watch list, so a FileChanged hook can re-aim what it watches as the project shifts under it.

WorktreeCreate and WorktreeRemove

WorktreeCreate is the one event that breaks the exit-code table: any non-zero exit aborts the creation. Its input carries a name slug (the docs’ example is bold-oak-a3f2). A command hook returns the worktree path as the last non-empty line of stdout; an HTTP hook returns hookSpecificOutput.worktreePath. Configuring a WorktreeCreate hook replaces the default git worktree behavior entirely, and .worktreeinclude is not processed, so this is how you swap in your own checkout mechanics, at the price of owning everything the default did for you. WorktreeRemove receives worktree_path and cannot block; its failures are logged in debug only.

PreCompact and PostCompact

PreCompact receives trigger (manual or auto) and custom_instructions (what the user passed to /compact; empty for auto), and it can block with exit 2 or decision: "block". PostCompact receives trigger and compact_summary and cannot block. Blocking auto-compaction buys you a beat to snapshot state before the transcript gets rewritten; PostCompact tells you what survived.

Elicitation and ElicitationResult

Elicitation and ElicitationResult match on the MCP server name. Elicitation input carries mcp_server_name, message, and optionally mode, url, elicitation_id, and requested_schema. Both events answer with action (“accept”, “decline”, “cancel”) plus content, and both block on exit 2, with ElicitationResult’s block turning the response into a decline. This is the machinery for auto-answering an MCP server’s questions, or for refusing them wholesale.

Notification

Notification matches on the eight notification kinds in the matcher table, supports command/http/mcp_tool only, and cannot block. It exists so a permission_prompt or idle_prompt can reach you on another surface.

Parallel execution, where hooks load from, and the off switches

All matching hooks run in parallel, and identical handlers are deduplicated: command hooks by command string plus args, HTTP hooks by URL. Parallelism has two sharp edges. A deny from one hook does not prevent sibling hooks from executing, so side effects in a sibling still happen on a denied call. And when multiple PreToolUse hooks return updatedInput, the last one to finish wins, non-deterministically. One input-rewriting hook per matcher is the only sane policy.

Hooks load from ~/.claude/settings.json (all projects), .claude/settings.json (project, shareable), .claude/settings.local.json (project, gitignored), managed policy settings, plugin hooks/hooks.json, and skill or agent frontmatter. Entries merge across levels rather than replacing each other, which is why a project hook cannot silence a user hook by redefining it.

Four settings keys govern the whole subsystem: disableAllHooks (cannot disable managed hooks unless set in managed settings), allowManagedHooksOnly (blocks user, project, and plugin hooks, exempting plugins force-enabled via managed enabledPlugins), allowedHttpHookUrls (a merged allowlist of HTTP hook URLs), and httpHookAllowedEnvVars.

The environment a hook runs in includes CLAUDE_PROJECT_DIR, CLAUDE_PLUGIN_ROOT, CLAUDE_PLUGIN_DATA, CLAUDE_PLUGIN_OPTION_, CLAUDE_CODE_REMOTE (“true” in remote web environments), CLAUDE_CODE_BRIDGE_SESSION_ID (v2.1.199 or later), CLAUDE_EFFORT, and CLAUDE_ENV_FILE on its four supported events. OTEL_* exporter variables are stripped from every subprocess Claude Code spawns. There is no $CLAUDE_MODEL variable.

When a hook misbehaves, the debugging path is: claude --debug-file <path> writes the log where you point it, claude --debug writes to ~/.claude/debug/.txt without printing to the terminal, /debug enables logging mid-session, and CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose adds matcher-count and query-matching lines. The /hooks menu is read-only and shows event, matcher, type, source file, and command. The menu tells you what is registered; the debug log tells you what fired. The gap between those two is where hook bugs live.