Claude Code Hooks Explained: A Real 35-Hook Setup, Tested
On this page, 9 sections
Claude Code hooks are commands that Claude Code runs for you at fixed points in its lifecycle, such as before a tool call or when a turn ends.
They can also be HTTP endpoints, MCP tool calls, LLM prompts or subagents. Unlike a line in CLAUDE.md, a hook runs every time its event fires, and a hook that exits with code 2 can stop the action.
Three things decide how a hook behaves: the event it listens to, the matcher that filters it, and what it returns, either an exit code or a JSON object. The hooks reference I read on 11 October 2026 lists 33 events; I counted the rows in its lifecycle table.
I also read my own setup. My ~/.claude/settings.json holds 35 hook entries across 8 events, backed by 41 shell scripts in ~/.claude/hooks. Below is how each piece works, what the docs say, and the outputs from the ones I ran on 11 October 2026.
Where hooks fire
The reference groups events into three cadences. Per session: SessionStart and SessionEnd. Per turn: UserPromptSubmit, Stop and StopFailure. On every tool call inside the agentic loop: PreToolUse and PostToolUse.
These are the ones that matter in practice, with the reference’s own wording shortened:
| Event | When it fires | Blocking or context |
|---|---|---|
UserPromptSubmit | A prompt is submitted, before Claude processes it | Yes, exit 2 rejects the prompt |
PreToolUse | Before a tool call executes | Yes |
PostToolUse | After a tool call succeeds | No, the tool already ran |
Stop | Claude finishes responding | Yes, it can keep Claude working |
SessionStart | A session begins or resumes | Plain-text stdout is added as context |
The last column comes from the reference’s exit code section and event pages. On PostToolUse the tool has already run, so the reference says to exit 2 if you want Claude to see your stderr.
Matchers
A matcher filters when a hook runs. For tool events it matches the tool name. The reference sorts matchers into three kinds:
"*", an empty string or no matcher: match everything.- Only letters, digits, underscore, hyphen, spaces, commas and pipes: an exact name, or an exact list such as
Edit|Write. - Anything else: a JavaScript regular expression, unanchored.
The unanchored part is a trap. The reference says Edit.* matches both Edit and NotebookEdit, and to use ^Edit$ when you want a whole-name match. MCP tools are matchable too: mcp__memory__.* matches every tool from a server named memory. If you have not met MCP tool names yet, start with what an MCP server is.
Exit codes and JSON
Exit code 0 means success. Exit code 2 is a blocking error on events that can block, and your stderr becomes the reason.
Any other code, with plain text or nothing on stdout, is a non-blocking error: the action goes ahead and you see a hook error notice. That last row is the one that bites people, because a hook that exits 1 on a check it meant as a block does nothing.
There is a second way to block that does not use exit 2. A PreToolUse hook can exit 0 and print JSON with permissionDecision set to deny. One more fact from the reference: Claude never sees stderr from a hook that exits 0.
A real deny hook, run
My deny-dangerous.sh uses the JSON route. I fed it two tool calls on 11 October 2026, building the first command string in code so my own session would not trip the same rule. Input for the first:
{"tool_name": "Bash", "tool_input": {"command": "git add -A"}}
Output, with exit code 0:
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"git add -A / --all is banned. CLAUDE.md: 'stage files by name, never git add -A (avoids .env).' Stage individual files."}}
For a harmless ls the same script printed nothing and exited 0. That silence is the allow path: no output, exit 0, the tool call proceeds.
My setup, by event
I counted hook commands per event in ~/.claude/settings.json on 11 October 2026:
| Event | Hook entries |
|---|---|
UserPromptSubmit | 10 |
SessionStart | 7 |
Stop | 7 |
PreToolUse | 6 |
PostToolUse | 2 |
PreCompact | 1 |
SessionEnd | 1 |
SubagentStop | 1 |
| Total | 35 |
Most of the weight sits in the two places where I want something to happen every time without remembering it: when I submit a prompt, and when a session starts. The tool-call hooks are fewer but they are the ones that block.
The PreToolUse hooks
| Matcher | Script | Timeout |
|---|---|---|
| Bash | secret-scan.sh | 5 s |
| Bash | deny-dangerous.sh | none set |
| Write|Edit|MultiEdit | anti-slop-gate.sh | none set |
| WebSearch | prefer-crawl.sh | 10 s |
| Bash | verify-package-exists.sh | none set |
| Agent | subagent-budget.sh | 5 s |
- secret-scan.sh scans Bash commands for secret patterns such as Stripe, AWS, GitHub and Anthropic key formats and exits 2 on a match. Its own header explains why it is a hook: hooks are deterministic, CLAUDE.md is advisory.
- deny-dangerous.sh blocks commands that break rules I wrote down, such as staging every file at once in git and force pushes. The output above is from this script.
- anti-slop-gate.sh runs on writes to text files such as .md and .html and denies content with patterns I have banned, including the long dash. It is the reason this post could not be written with one.
- verify-package-exists.sh blocks an install if the package name does not exist on its registry, a guard against invented package names.
That last hook blocked me while I prepared this post. On 11 October 2026 I needed to install a package into a fresh virtual environment, and the hook rejected my install command several times. When the command carried shell punctuation (a pipe or a semicolon) the hook read it as part of the package name. A bare install command with nothing around it went through.
So the hook does what its header says on a clean command and misreads a composed one. I have not fixed it. It is a good reminder that a hook is code, and code has edge cases, including the code that guards you.
The Stop hooks
Seven entries run on Stop. Three I can describe from their own headers: stop-until-green.sh blocks a “done” claim if the project has a check script that has not passed in this session, no-question-back-gate.sh catches a turn that ends by asking permission instead of continuing, and chat-slop-gate.sh checks the final message for banned patterns.
A Stop hook that blocks makes Claude continue, which means it can loop. The reference documents the guard: the input carries stop_hook_active, which is true when Claude Code is already continuing because of a stop hook, and after eight consecutive continuations Claude Code overrides the next block and ends the turn.
I grepped my scripts: chat-slop-gate.sh and evidence-gate.sh both exit 0 when that flag is true.
The header of stop-until-green.sh also records a bug I had. It looked for the project’s check script in the exact working directory only, so sprints started from a subdirectory never triggered it, for about a week before I noticed on 11 September 2026.
It now walks up to the project root. A gate that silently does not fire is worse than no gate, because you believe it ran.
Timeouts
The reference gives command hooks a default timeout of 600 seconds on most events. That is ten minutes in which a hung script holds the event. Three of the six PreToolUse hooks above have no timeout in my settings, so they sit on that default.
The other three carry 5 or 10 seconds, and the header of stop-until-green.sh warns that a hang there would stall every Stop event for exactly this reason.
What hooks do not cover
PreToolUseruns only when Claude calls a tool. Files you attach with@in a prompt are inserted without a tool call, so a hook matchingReadnever sees them. The reference says to use a Read deny rule for that.- Setting
"disableAllHooks": trueturns your hooks off, but it cannot disable hooks set in managed policy settings, per the reference. - Hooks merge across settings levels. User, project and local settings add hooks without removing managed ones.
Questions people ask
Where do I put a hook?
In ~/.claude/settings.json for all your projects, .claude/settings.json for one project you can commit, or .claude/settings.local.json for one project, uncommitted. Plugins and skills can carry hooks too. Type /hooks in Claude Code to see what is registered; the hooks guide says new hooks appear there.
Is a hook better than a CLAUDE.md rule?
For a rule that must hold every time, yes, because the model can forget an instruction but cannot skip a hook that fires on the event. For judgement calls, a rule in CLAUDE.md is still the right place. I use both, and the hooks exist because a rule was broken at least once.
Why did my hook not block anything?
Check the exit code first. Exit 2 blocks. So does valid JSON with a blocking decision, such as permissionDecision deny on PreToolUse or decision block on Stop. Exit 1 with plain text or nothing on stdout is a non-blocking error. The reference also lists an onFailure: "block" option for hooks you want to fail closed.
Do this today
Add one deny hook and test it by hand before you trust it. This one blocks a table drop. I syntax-checked it with bash -n and ran it on 11 October 2026: for DROP TABLE users it printed the deny JSON and exited 0, and for SELECT 1 it printed nothing and exited 0.
#!/usr/bin/env bash
# PreToolUse hook for Bash: deny SQL that drops a table.
cmd=$(jq -r '.tool_input.command // ""')
if printf '%s' "$cmd" | grep -qiE 'drop +table'; then
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "DROP TABLE is blocked by a hook"}}'
fi
exit 0
Save it as ~/.claude/hooks/block-sql.sh, then register it:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "bash $HOME/.claude/hooks/block-sql.sh", "timeout": 5 }
]
}
]
}
}
Test it with piped JSON first, the way I did, and only then restart the session. The first time a hook misfires, you want to see it in a terminal rather than in the middle of a task. For the wider setup it plugs into, see running Claude Code against WordPress.