
Hooks
Overview
This chapter covers:
- Why a hook is the answer whenever “usually” is not good enough
- Where each event fires in a turn, and the subset that can actually block
- The exit-code contract — and why exit
2beats any JSON you also print - The asymmetry that makes hooks safe to hand to an organisation: they tighten, never loosen
- The three failure modes that account for most hooks that “don’t fire”
Instructions versus guarantees
Chapter 6 kept deferring to this chapter, and Chapter 11 did too. Both for the same reason: CLAUDE.md and skills are context. Claude reads them and tries to comply. That is usually enough, and occasionally it is not.
A hook is a command Claude Code runs at a fixed point in its lifecycle, regardless of what Claude decides. “Run the formatter after every edit” as a CLAUDE.md line is a suggestion followed most of the time. As a PostToolUse hook it is a fact.
The test from Chapter 6, restated: does this need to happen every time, or usually? Every time is a hook.
And there is a second difference that Chapter 11’s table was building towards. CLAUDE.md, rules and skills all occupy your context window — that is what “Claude reads them” means. A hook does not. It runs as an external process, outside the window entirely, and costs you nothing until it chooses to speak:
| Where it lives | Context cost | |
|---|---|---|
CLAUDE.md, rules | In the window, from session start | Always |
| Skills | In the window, from invocation | When used |
| Hooks | Outside the window | Zero, unless the hook returns additionalContext |
So a PostToolUse hook that runs your formatter after every edit is free, forever. That is a different kind of cheap from a well-scoped rule, and it is why “automate it” and “instruct it” are not competing answers to the same question.
Where hooks fire
There are 33 events. Rather than list them, here is a turn with the main ones in place:
The distinction that matters is the Blocks column. Most events are observational — PostToolUse cannot undo anything, because the tool already ran. The blocking set is small, and it is where enforcement lives: PreToolUse, UserPromptSubmit, Stop, PreModelSwitch, SubagentStop, PostToolBatch, ConfigChange, and a few others.
Configuration
Hooks are a hooks block in any settings file, so Chapter 5’s precedence and scopes apply unchanged:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format.sh",
"timeout": 30
}
]
}
]
}
}
${CLAUDE_PROJECT_DIR} resolves to the project root and stays constant across worktrees — use it rather than a relative path, which resolves against wherever Claude happens to have cd’d.
Matchers are three syntaxes wearing one hat
The characters in a matcher decide how it is interpreted, which is not obvious and is a common source of “my hook never fires”:
| Matcher contains | Treated as |
|---|---|
*, empty, or omitted | Match everything |
Letters, digits, _, -, spaces, ,, ` | ` |
| Anything else | An unanchored JavaScript regex — ^Notebook, mcp__memory__.* |
Matchers are case-sensitive, and what they match against depends on the event: a tool name for the tool events, but startup|resume|clear|compact|fork for SessionStart, manual|auto for PreCompact, and a model name for the model-switch events. Several events — Stop, UserPromptSubmit, PostToolBatch, CwdChanged — take no matcher at all.
Five handler types
command is the one you will write. The others exist for cases a shell script handles badly:
| Type | Runs |
|---|---|
command | A shell command; JSON on stdin, JSON or text on stdout |
http | A POST to a URL, event as the body |
mcp_tool | A tool on a connected MCP server |
prompt | A single-turn LLM evaluation |
agent | A multi-turn subagent with tools — experimental |
prompt and agent are the interesting pair: they exist for decisions that need judgment rather than a rule, which is otherwise the gap between a hook and a permission rule.
What to write them in
Hooks mostly run synchronously — Claude Code waits for them before continuing. So the interpreter’s startup time, not your script’s logic, is usually what you pay. On a PreToolUse hook that can fire dozens of times in a session, a 300ms interpreter start is 300ms added to every tool call.
| Startup | Use for | |
|---|---|---|
| Bash | ~10–20ms | Simple, high-frequency checks |
| Node.js | ~50–100ms | The default for PreToolUse and PostToolUse |
| Python | ~200–400ms | SessionStart, SessionEnd, and anything you are still debugging |
The rule of thumb: frequency decides the language. Something firing once a session can be written in whatever you think fastest in; something firing on every tool call should be Bash or Node. If a hook must be slow, "async": true takes it off the critical path — at the cost of no longer being able to block.
The contract
Your hook gets JSON on stdin — session_id, transcript_path, cwd, permission_mode, hook_event_name, plus event-specific fields like tool_name and tool_input. It answers through its exit code and stdout.
| Exit | Meaning |
|---|---|
0 | Success. Stdout is parsed as JSON if it looks like an object, otherwise treated as text |
2 | Blocking error. Blocks the action where the event supports it; the reason comes from stderr |
| Anything else | Non-blocking. Valid JSON decision fields are still honoured |
Exit 2 wins over JSON on the same invocation. If your script exits 2, whatever it printed to stdout cannot override that. Pick one mechanism per hook.
The JSON form is more expressive:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Migrations run through the CLI, not psql",
"additionalContext": "Use `npm run migrate` instead."
}
}
additionalContext is how a hook talks to Claude — it arrives as a system reminder Claude reads as plain text. Hooks cannot call tools or trigger slash commands; stdout, stderr and the exit code are the whole interface.
When several hooks match one event they all run in parallel, to completion. One returning deny does not stop the others — so never rely on a sibling’s deny to suppress your side effects. Results are merged most-restrictive-first: deny, defer, ask, allow.
Hooks tighten; they never loosen
This is the property that makes hooks deployable as organisation policy, and it is worth stating precisely.
PreToolUse hooks fire before any permission-mode check, in every mode — including dontAsk and bypassPermissions. A hook returning permissionDecision: "deny" blocks the tool even under --dangerously-skip-permissions. Users cannot escape it by changing their permission mode.
The reverse does not hold. A hook returning "allow" does not bypass a deny rule from settings, and cannot suppress the prompt for MCP tools marked requiresUserInteraction. This is Chapter 3’s evaluation order holding: deny still wins from anywhere.
Which puts hooks precisely one notch above modes and one notch below deny rules — and explains allowManagedHooksOnly, which lets an administrator run managed hooks while blocking everyone else’s.
Three that earn their keep
Format after every edit. The canonical one, and the reason PostToolUse exists:
{ "matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "prettier --write \"$CLAUDE_FILE_PATH\"" }] }
Block edits to files nothing should touch. A PreToolUse hook exiting 2 with a reason on stderr, which holds regardless of permission mode — the enforcement CLAUDE.md cannot give you.
Tell you when Claude wants you. The first hook most people actually keep, because the failure it fixes is you making coffee while a permission prompt sits unanswered:
{ "hooks": { "Notification": [{ "matcher": "", "hooks": [{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
}] }] } }
Swap osascript for notify-send on Linux. The Notification event is observational, so there is no exit code to get right — it just fires.
Re-inject context after a compact. Chapter 8’s compaction table has a row for this: a SessionStart hook matching compact runs and its output is added to the compacted context. It is the supported way to make something survive compaction that otherwise would not.
Where they can live
Beyond settings files, two scopes are worth knowing because they are bounded:
- Skill frontmatter — registered when the skill is invoked, and they stay for the rest of the session.
once: trueremoves the hook after its first success. - Subagent frontmatter — run only while that subagent runs, then are removed. A
Stophook there becomesSubagentStop.
Plugins ship hooks in hooks/hooks.json (Chapter 13). disableAllHooks turns everything off, except managed hooks.
When a hook doesn’t fire
Three causes account for most of it:
- The matcher. Run
/hooksand check the hook appears under the right event. Matchers are case-sensitive, and a stray character turns your exact string into a regex. - The path. “command not found” means a relative path resolved somewhere unexpected. Use
${CLAUDE_PROJECT_DIR}, or add"args": []to switch to exec form, which spawns the script directly with no shell quoting at all. - It ran and failed quietly. Test it by hand — that is the whole interface, so it is easy:
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh; echo $?
Two subtler ones. Stdout that looks like JSON but is malformed reports a parse error even on exit 0 — build payloads with jq rather than string concatenation. And a Stop hook that keeps blocking is overridden after eight consecutive blocks; check the stop_hook_active field in your input and exit early when it is true.
Summary
- A hook runs regardless of what Claude decides. That is the entire difference from
CLAUDE.mdand skills. - Hooks run outside the context window and cost nothing unless they return
additionalContext. - Hooks are synchronous, so interpreter startup is the cost: Bash ~10–20ms, Node ~50–100ms, Python ~200–400ms. Frequency decides the language.
- 33 events, but only a small subset can block —
PreToolUse,UserPromptSubmit,Stop,PreModelSwitchand a few more.PostToolUsecannot undo anything. - Matcher syntax is decided by its characters: an unexpected one silently makes it a regex. Case-sensitive.
- Exit
2blocks and beats any JSON you also printed. Exit0plus JSON is the expressive path. - Matching hooks all run in parallel to completion; results merge most-restrictive-first.
- A
PreToolUsedeny holds even underbypassPermissions. An allow never overrides a deny rule. Hooks tighten, never loosen. - A
SessionStarthook matchingcompactis the supported way to re-inject context after compaction. - Full reference: hooks guide, event and schema reference.
Chapter 13 is Plugins — the wrapper that packages skills, hooks, subagents and MCP servers into one installable thing, and the marketplaces that distribute them.