Explainer
What Claude Code hooks are, and why they matter
A Claude Code hook is a handler the agent runs when a lifecycle event fires, with a JSON
object describing that event piped to its stdin. You register hooks in
settings.json keyed by event name. There is no SDK and nothing to register with: a
script that reads stdin and sets an exit code is a valid hook. There are 30 documented
events; three of them — PreToolUse, Notification and
Stop — carry most of the practical value. The single rule people get wrong:
exit 2 blocks, exit 1 does not.
Most people use Claude Code as a black box: you give it a task, it works, you read the output. But the agent is broadcasting events the whole time it runs — it's about to run a command, it's waiting on you, it just finished its turn. Hooks are how you tap that stream. They're the difference between staring at a terminal hoping to catch the moment it needs you, and having the agent tell you.
How a hook actually works
You register hooks in your Claude Code settings (settings.json), keyed by event name. When
that event fires, Claude Code spawns your command and pipes a JSON object into its stdin describing what
just happened — which tool, which session, the working directory, and so on. Your command does whatever
it wants with that, and for some events its exit code and stdout feed a decision back into the
agent.
A minimal handler is genuinely a one-liner. Read stdin, pull a field, do something:
#!/usr/bin/env bash
payload="$(cat)"
tool="$(echo "$payload" | jq -r '.tool_name')"
echo "Claude wants to run: $tool" That's it — no special runtime. The important nuance is that hooks aren't all read-only. Some are pure side-effect (fire a notification, write a log); others are decision points where what your script returns changes what the agent does next. That second category is what makes hooks more than a logging mechanism.
command is one of five handler types. The others are http (Claude Code POSTs
the same JSON to a URL you control), mcp_tool (call a tool on an MCP server),
prompt (hand the event to a model rather than a script), and agent, which the
docs still label experimental and subject to change. Everything below describes the
command contract, because it is the one that has stayed stable.
One caution up front: event names, payload fields and decision semantics do move between
releases. Everything here was verified against the reference on July 30, 2026, against Claude
Code 2.1.220 (npm latest; stable was 2.1.212). Several behaviours below carry
explicit minimum versions, and they are noted inline. Before wiring something up, check
Anthropic's hooks reference —
note that the old docs.anthropic.com/en/docs/claude-code/hooks URL now 301s to
code.claude.com/docs/en/hooks.
All 30 hook events
The reference documents 30. You will still see "9 hook events" in older write-ups; that framing predates
PermissionRequest, PostToolBatch, MessageDisplay,
TeammateIdle and the Task* / Worktree* families. Cadence varies:
SessionStart and SessionEnd fire once per session,
UserPromptSubmit / Stop / StopFailure once per turn, and
PreToolUse / PostToolUse on every tool call except
EndConversation, which skips both. Two columns tell you more than a description of each
would: what the matcher is tested against, and whether exiting 2 there stops anything.
| Event | Matcher matches on | Can exit 2 block? |
|---|---|---|
| SessionStart | session source: startup, resume, clear, compact, fork | No |
| Setup | CLI flag: init, maintenance | No |
| UserPromptSubmit | no matcher | Yes — blocks processing and erases the prompt |
| UserPromptExpansion | see reference | Yes — blocks the expansion |
| PreToolUse | tool name | Yes — blocks the tool call |
| PermissionRequest | tool name | Yes — denies the permission |
| PermissionDenied | tool name | No — exit code and stderr ignored |
| PostToolUse | tool name | No — the tool already ran |
| PostToolUseFailure | tool name | No |
| PostToolBatch | no matcher | Yes — stops the loop before the next model call |
| Notification | notification type (8 values, below) | No |
| MessageDisplay | no matcher | No |
| SubagentStart | agent type | No |
| SubagentStop | agent type | Yes |
| TaskCreated | no matcher | Yes — rolls back creation |
| TaskCompleted | no matcher | Yes |
| Stop | no matcher | Yes — prevents stopping, continues the turn |
| StopFailure | error type: rate_limit, overloaded, authentication_failed, billing_error, … | No — output and exit code ignored |
| TeammateIdle | no matcher | Yes |
| InstructionsLoaded | load reason | No |
| ConfigChange | config source: user_settings, project_settings, local_settings, policy_settings, skills | Yes — except policy_settings |
| CwdChanged | no matcher | No |
| FileChanged | narrower exact-match set (letters, digits, _ and | only) | No |
| WorktreeCreate | no matcher | Yes — and any non-zero exit aborts |
| WorktreeRemove | no matcher | No |
| PreCompact | manual | auto | Yes |
| PostCompact | manual | auto | No |
| Elicitation | see reference | Yes |
| ElicitationResult | see reference | Yes — action becomes decline |
| SessionEnd | exit reason: clear, resume, logout, prompt_input_exit, bypass_permissions_disabled, other | No |
A 31st event, DirectoryAdded, appears in the 2.1.219 changelog — it fires after
/add-dir or an SDK register_repo_root request registers a new working
directory mid-session — but it has not reached the reference page yet and no payload schema is
published. Treat it as shipped-but-undocumented.
What your script actually reads on stdin
Every event delivers a JSON object with a common core: session_id,
prompt_id (the UUID of the current user prompt, matching the OpenTelemetry
prompt.id; absent until the first user input, v2.1.196+), transcript_path,
cwd and hook_event_name. Two fields are conditional and worth not assuming:
permission_mode (the docs say not all events receive it — the published
Notification, SessionStart, SessionEnd and Worktree*
examples omit it) and effort, documented as present for events that fire inside a tool-use
context when the model supports the parameter. Inside a subagent, or with --agent, you also
get agent_id and agent_type.
A trap on permission_mode: the UI mode labelled Manual arrives as
"default", never "manual". The full set is default,
plan, acceptEdits, auto, dontAsk,
bypassPermissions.
Here is the shape PreToolUse receives, verbatim from the docs:
{
"session_id": "abc123",
"prompt_id": "550e8400-e29b-41d4-a716-446655440000",
"transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
"cwd": "/home/user/my-project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test",
"description": "Run test suite",
"timeout": 120000,
"run_in_background": false
},
"tool_use_id": "toolu_01ABC123..."
} tool_input is the per-tool part, and its shape is the thing you actually parse:
Bash gives command / description / timeout / run_in_background;
Write gives file_path / content; Edit gives
file_path / old_string / new_string / replace_all; Read gives
file_path / offset / limit; WebFetch gives url / prompt;
Agent gives prompt / description / subagent_type / model.
PostToolUse adds tool_response and duration_ms;
PostToolUseFailure adds error and is_interrupt.
Hooks also get environment variables: $CLAUDE_PROJECT_DIR,
$CLAUDE_PLUGIN_ROOT, $CLAUDE_PLUGIN_DATA, $CLAUDE_CODE_REMOTE,
$CLAUDE_EFFORT, and $CLAUDE_ENV_FILE (SessionStart, Setup, CwdChanged and
FileChanged only). There is no $CLAUDE_MODEL; only SessionStart can receive a
model field, and even there it is not guaranteed. All OTEL_* exporter
variables are stripped from hook subprocesses.
Matcher syntax: three paths, decided by one character
matcher looks like a regex field and usually isn't. Claude Code picks an evaluation path
based on which characters the string contains:
"*",""or omitted — match everything.- Only letters, digits,
_,-, spaces,,and|— exact string, or a list of exact strings separated by|or,:Bash,Edit|Write,Edit, Write,code-reviewer. Comma separators and whitespace tolerance need v2.1.191+; hyphens joined the exact-match set in v2.1.195+ (before that,code-reviewerwas treated as a regex and also fired forsenior-code-reviewer). - Anything else — an unanchored JavaScript regex run through
RegExp.prototype.test. SoEdit.*matchesEditand alsoNotebookEdit. Wrap it in^...$if you meant whole-string.
MCP tools are matched as ordinary tool names in the form mcp__<server>__<tool>,
and this is where people lose an afternoon: the trailing .* is required. mcp__memory alone is exact-matched and matches nothing; you want
mcp__memory__.* or mcp__.*__write.*. Plugin-bundled servers appear as
mcp__plugin_<plugin>_<server>__<tool>. Matchers are case-sensitive
throughout. For narrowing inside a matched event, the per-handler if field takes
permission-rule syntax: "Bash(git *)", "Edit(*.ts)". It holds exactly one
rule — there is no && or || — and it is only evaluated on tool events
(PreToolUse, PostToolUse, PostToolUseFailure,
PermissionRequest, PermissionDenied); on any other event, a handler with
if set never runs.
Exit codes: the part everyone gets wrong
Exit 1 does not block anything. Claude Code proceeds. Policy hooks must exit 2. The
full contract for command hooks:
| Exit code | Meaning | What Claude sees |
|---|---|---|
| 0 | Success — and the only case where stdout is parsed as JSON | Nothing, except for UserPromptSubmit, UserPromptExpansion and SessionStart, where plain stdout is injected as context |
| 2 | Blocking error; stdout and any JSON in it are ignored | stderr, fed back as the error message |
| anything else (incl. 1) | Non-blocking error; execution continues | Nothing — the user sees a <hook name> hook error notice plus the first line of stderr |
As of v2.1.214, a hook that exits 2 while printing schema-invalid JSON still blocks, with stderr as the
reason; before that release the combination silently fell through as non-blocking. And for
SessionStart, Setup and SubagentStart, an exit-2 stderr renders as
a hook-error notice the model never sees (v2.1.199+).
Decision control: how a hook says allow or deny
Pick one approach per hook — exit codes alone, or exit 0 and print JSON on stdout. Mixing them fails silently, because JSON is only processed on exit 0.
PreToolUse is the important one, and it does not use the top-level
decision field. It returns:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "npm test is on the safe list",
"updatedInput": { "command": "npm test --silent" }
}
} allow skips the permission prompt. deny prevents the call. ask
forces a user prompt, labelled with its source ([User], [Project],
[Plugin], [Local]). defer exits gracefully for later resume and is
honoured only in -p headless mode. When several hooks disagree the precedence is
deny > defer > ask > allow.
Reason visibility differs per decision, which matters if you want the model to learn from a block: for
allow and ask the reason is shown to the user but not to Claude; for
deny it is shown to Claude; for defer it is ignored.
updatedInput replaces the entire input object, so include unchanged fields. The old
top-level decision: "approve" | "block" is deprecated for this event and maps onto
allow/deny.
The asymmetry worth internalising: hooks can tighten permissions, never loosen them. A
hook deny blocks even under bypassPermissions /
--dangerously-skip-permissions, but a hook allow cannot override a settings
deny rule. And files pulled in with an @ reference in a prompt fire no
PreToolUse hook at all, because no tool call happens — if you need to gate those, use a
Read deny rule. Our notes on
pre-approval policies go into where that boundary
should sit.
Other events use the top-level form {"decision": "block", "reason": "..."} —
block is the only accepted value, and to allow you omit it. That set:
UserPromptSubmit, UserPromptExpansion, PostToolUse,
PostToolUseFailure, PostToolBatch, Stop,
SubagentStop, ConfigChange, PreCompact.
Four JSON fields work anywhere. continue: false stops Claude entirely and outranks any
event-specific decision (stopReason explains it to the user);
suppressOutput hides stdout from the transcript; systemMessage shows the user
a warning; and terminalSequence (v2.1.141+) has Claude Code emit an allowlisted escape
sequence for you — OSC 0/1/2 titles, OSC 9, OSC 99, OSC 777 and bare BEL are permitted, while CSI,
palette changes and OSC 8/52/1337 are rejected. Every output string, additionalContext
included, is capped at 10,000 characters; longer output goes to a file and is replaced by a preview and
its path.
The three events that matter most
Claude Code fires hooks across its lifecycle, but three carry almost all the value for staying out of the terminal. Learn these and the rest are variations.
- PreToolUse — fires before the agent runs a tool (a shell command, a file edit, a fetch). This is the decision hook: your script can inspect the proposed action and return an allow or deny verdict, so the tool runs, gets blocked, or gets routed to you for approval. This is the machinery behind approval gating — "Allow this command?" is a PreToolUse decision surfaced to the user.
- Notification — fires when the agent is waiting on you: a permission prompt it can't
proceed past, or an idle nudge once it's done and waiting on your next prompt. This is your "it needs a
decision" signal, and it's the single most useful event for not babysitting, because it fires exactly
at the moment your attention has actual value. It carries
message,titleandnotification_type, and the matcher takes one of eight values:permission_prompt,idle_prompt,auth_success,elicitation_dialog,elicitation_complete,elicitation_response, and — for background sessions, v2.1.198+ —agent_needs_inputandagent_completed. Notification hooks are side-effect only; they cannot block or rewrite the notification. - Stop — fires when the agent finishes its turn and hands control back to you. This is
how you know a long task is done without watching the scrollback. Its payload carries
last_assistant_message— use that rather than readingtranscript_path, which is written asynchronously and may not contain the final message yet — plusstop_hook_active, and (v2.1.145+)background_tasks[]andsession_crons[]. Those last two are how you tell "session is genuinely done" from "session is paused waiting on background work". Note that Stop does not fire on user interrupt, and API errors raiseStopFailureinstead. If a Stop hook blocks repeatedly, Claude Code overrides it after 8 consecutive blocks (raise the ceiling withCLAUDE_CODE_STOP_HOOK_BLOCK_CAP).
There are more — events around subagents finishing, sessions ending, prompts being submitted — but if you only ever wire up these three, you've covered "is it asking me something," "is it done," and "should this even be allowed to run." Those are the three questions that otherwise force you to keep eyes on the terminal.
What each event is good for
The split is roughly: PreToolUse is for control, Notification and Stop are for awareness. Knowing which bucket you're in tells you whether your hook needs to return a decision or just fire and forget.
- Guardrails with PreToolUse. Auto-deny anything that touches
~/.ssh, blockrm -rfagainst paths outside the repo, or force a manual approve ongit push. Because the hook sees the command before it runs and can veto it, this is real enforcement, not a warning. - Attention routing with Notification. When the agent stalls on a permission prompt, pop a banner, play a chime, or push to your phone if you've stepped away. The point is to fire only on a real waiting state, not on every line of output.
- Completion signals with Stop. Kick off the next step in a pipeline, log how long the turn took, or just tell you it's safe to come back. Combined with running it in the background, this is what lets you launch a task and walk away.
Where hooks live, and how the scopes combine
Hook entries merge across settings levels rather than overriding each other. That is different from ordinary settings keys, where precedence runs managed > CLI args > local > project > user. If a hook fires twice, this is usually why.
| File | Scope | Shared? |
|---|---|---|
~/.claude/settings.json | All your projects (User Settings) | No |
.claude/settings.json | This project (Project Settings) | Yes — committed |
.claude/settings.local.json | This project, just you (Local Settings) | No — gitignored |
| Managed policy settings | Organisation-wide, admin-controlled: /Library/Application Support/ClaudeCode/managed-settings.json (macOS), /etc/claude-code/managed-settings.json (Linux), C:\Program Files\ClaudeCode\managed-settings.json (Windows), plus a managed-settings.d/ drop-in merged alphabetically | Enforced |
Plugin hooks/hooks.json | Active while the plugin is enabled | Ships with the plugin |
disableAllHooks: true removes user, project and plugin hooks, but cannot disable managed
hooks unless it is set at the managed level. There is no way to disable a single hook while keeping it in
config — you delete the entry. Enterprise deployments get three more keys:
allowManagedHooksOnly, allowedHttpHookUrls (an allowlist supporting
*; undefined means unrestricted, an empty array blocks all) and
httpHookAllowedEnvVars.
Security: a hook config is executable code that arrives with a repo
The vendor's own warning is blunt: "Command hooks execute shell commands with your full user
permissions. They can modify, delete, or access any files your user account can access." That is the
real caveat, and it has a specific shape — a .claude/settings.json is executable
code that arrives when you clone a repository or install a plugin, and
PreToolUse hooks run before any permission-mode check. Read hook config in unfamiliar repos
the way you'd read a postinstall script.
Anthropic's five practices are worth following literally: validate and sanitise inputs rather than
trusting the payload; always quote shell variables ("$VAR", not $VAR); reject
.. in file paths; use absolute paths for scripts (${CLAUDE_PROJECT_DIR}
needs no quoting in exec form, but does in shell form); and skip sensitive files —
.env, .git/, key material. Anthropic ships a reference implementation of a
Bash-command validator in the claude-code repo if you want a starting point.
Two structural details sharpen the picture. Since v2.1.139, hooks run in their own session with
no controlling terminal on macOS and Linux — they cannot open /dev/tty,
which is precisely why systemMessage and terminalSequence exist as output
channels. And v2.1.218 fixed agent-frontmatter hooks running from untrusted folders; frontmatter hooks
now require the agent file's own folder to have accepted workspace trust.
Debugging: why your hook did nothing
Hook success is silent, so "no output" tells you nothing about whether it ran. Work through these in order:
-
/hooks— a read-only browser listing every event with a count of configured hooks. Drill in for the matcher, type, source file and full command, labelled User Settings, Project Settings, Local Settings, Plugin Hooks, Session Hooks or Built-in Hooks. It cannot edit anything; you change hooks by editing the JSON. -
Ctrl+O— the transcript view, one line per hook. Blocking errors show stderr; non-blocking errors show<hook name> hook errorplus the first stderr line. - The debug log — the only place with the full record: which hooks matched, exit codes,
complete stdout and stderr. Run
claude --debug-file /tmp/claude.logandtail -fit, orclaude --debugand read~/.claude/debug/<session-id>.txt. Note that--debugdoes not print to the terminal. Mid-session,/debugenables logging and shows the path.CLAUDE_CODE_DEBUG_LOG_LEVEL=verboseadds matcher-count and query-matching lines. - Test the handler outside Claude entirely:
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh; echo $?
The silent failures that account for most reports, from the vendor's own troubleshooting page: a shell
profile that echos unconditionally prepends text to your JSON and breaks parsing (guard it
with if [[ $- == *i* ]]; then …; fi); the script isn't executable; jq isn't
installed; a bare command name isn't on PATH (use an absolute path, or add
"args": [] to switch to exec form and skip the shell); a matcher that doesn't match
case-exactly; invalid JSON in settings.json — trailing commas and comments are not allowed
— which makes /hooks show nothing at all.
Then the timeouts, which discard output silently. command, http and
mcp_tool hooks default to 600s, but UserPromptSubmit is capped at 30s and
MessageDisplay at 10s; prompt hooks get 30s, agent hooks 60s, and all SessionEnd hooks share a 1.5s budget. A timed-out UserPromptSubmit hook is
cancelled and its additionalContext thrown away — the prompt still reaches Claude, just
without it (v2.1.196+ at least shows a transcript notice). Two more: hooks run in
parallel, so if two PreToolUse hooks both return updatedInput the
last to finish wins — never let two hooks rewrite the same tool's input. And
async: true makes decision, permissionDecision and
continue no-ops entirely.
One macOS-specific trap worth its own line: osascript routes through Script Editor, and if
Script Editor lacks notification permission the command fails silently and macOS never prompts
you. Run osascript -e 'display notification "test"' once, then enable Script Editor under
System Settings → Notifications.
How hooks power gating and notifications
Put the two halves together and you have the whole "stop babysitting" pattern, built from primitives the agent already ships. Approval gating is PreToolUse returning a verdict — allow the safe stuff automatically, deny the dangerous stuff outright, and escalate the gray-area stuff to a human. Awareness is Notification and Stop shelling out to whatever can get your attention.
On macOS that's often terminal-notifier;
on Linux, notify-send. A bare-bones Stop hook is just:
terminal-notifier -title "Claude Code" -message "Agent finished" -sound default
For working configs rather than shapes, we keep five of them in
Claude Code hooks: 5 copy-paste examples, and the free
notification builder generates the
settings.json block and the notify scripts from a form — no signup, nothing to install.
This is the right architecture, and it's underused. The catch is what it costs to maintain: you're writing and re-deploying shell glue per machine, parsing payloads by hand to say anything useful, and a bare toast still drops you back to square one — you see "something happened," then you still have to find the right terminal, focus it, and read the scrollback to learn what it wants. The hooks give you the signal; turning that signal into something you can act on without a context switch is the part that takes work. If you want the full ladder of notification options ranked honestly, we covered that in how to actually know when Claude Code needs you.
Frequently asked questions
What are Claude Code hooks?
A hook is a handler that Claude Code runs when a lifecycle event fires, with a JSON object describing the event delivered on stdin (or as an HTTP POST body). You register them in settings.json keyed by event name. There is no SDK: if you can write a script that reads stdin and sets an exit code, you can hook Claude Code. Some hooks are pure side effects — fire a notification, write a log — and some are decision points whose output changes what the agent does next.
Which exit code blocks a tool call in Claude Code?
Exit 2, not exit 1. Exit 0 means success and is the only case where stdout is parsed as JSON. Exit 2 is a blocking error: stdout is ignored and stderr is fed back to Claude as the reason. Any other code, including 1, is a non-blocking error — the transcript shows a hook error notice and execution continues. This trips people up constantly, because 1 is the conventional Unix failure code. The one exception is WorktreeCreate, where any non-zero exit aborts the worktree.
How do I check whether my Claude Code hook actually fired?
Three places, in order. Run /hooks for a read-only list of every configured hook and its source file. Press Ctrl+O for the transcript view, which shows one line per hook — but note that success is silent, so no line can mean "worked" or "never matched". For the full record, including which hooks matched, their exit codes and complete stdout and stderr, run claude --debug-file /tmp/claude.log and tail that file. You can also test a handler outside Claude entirely by piping a fake payload into it and echoing $?.
Can a hook allow something my permission rules deny?
No. Hooks can tighten permissions but never loosen them. A PreToolUse hook returning permissionDecision "deny" blocks a tool call even in bypassPermissions mode, but a hook returning "allow" cannot override a deny rule in settings. Files pulled into a prompt with an @ reference fire no PreToolUse hook at all, because no tool call happens — use a Read deny rule for those.
Where Backgrind fits
Backgrind consumes these same PreToolUse / Notification /
Stop hooks for you, so you don't assemble the shell glue yourself. It wraps your real Claude
Code CLI in an always-on-top overlay: when the
agent needs a decision, asks a question, or finishes, the window flashes and the right tab gets an accent
ring — and because the terminal is already floating over your editor, answering is just typing, no
hunting for a buried window. See it in action in the demo, and if you haven't yet,
install Claude Code first so the hooks have something to fire on.
Backgrind is not a model and not an agent — it's a desktop overlay for macOS and Windows that PTY-wraps the CLI you already run (Claude Code, Cursor, Codex, OpenCode) or a managed endpoint. It is an always-on-top window, so it stays above ordinary apps and borderless-fullscreen games — not over exclusive-fullscreen ones, which take over the display. In BYO-CLI mode your agent's content never touches our servers, and Live mode lets you answer a held permission prompt from a phone or browser instead of walking back to the desk.
Sources
Event list, payload schemas, matcher rules, exit-code table and security guidance: Claude Code — hooks reference (the old docs.anthropic.com/en/docs/claude-code/hooks URL now redirects here). Troubleshooting, timeouts and debug techniques: hooks guide. Settings scopes and managed policy paths: settings. Version-gated behaviours (2.1.139, 2.1.141, 2.1.145, 2.1.191, 2.1.195, 2.1.196, 2.1.198, 2.1.199, 2.1.214, 2.1.218, 2.1.219): CHANGELOG.md. Current release numbers: @anthropic-ai/claude-code on npm (latest 2.1.220, stable 2.1.212, checked July 30, 2026). Reference validator implementation: bash_command_validator_example.py. Community write-ups consulted for framing, not for figures: Morph, ClaudeFast, PromptLayer.