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 0Point 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_
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//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.