Claude Code Hooks: A Practical Guide for 2026

TL;DR
- A hook is a handler Claude Code runs at a fixed point in its lifecycle. The official framing: hooks give you "deterministic control: certain actions always happen rather than relying on the LLM to choose to run them."
- The reference lists 31 events. Five carry almost all real work: PreToolUse, PostToolUse, UserPromptSubmit, Stop, and SessionStart.
- Config nests three levels deep in
settings.json: event, then matcher group, then handler. Handlers come in five types now, not just shell commands. - Exit 2 blocks. Exit 0 with JSON on stdout is how you return a structured decision. Get those two wrong and your hook fails silently.
- A PreToolUse
denyholds even inbypassPermissionsmode. Hooks tighten permissions, they never loosen them.
Quick definition: A Claude Code hook is a handler that Claude Code runs automatically at a fixed point in its lifecycle, such as before a tool call or after Claude finishes responding. Anthropic's wording: "Hooks are user-defined shell commands. Claude Code runs them at specific points in its lifecycle, which gives you deterministic control: certain actions always happen rather than relying on the LLM to choose to run them."1
One-minute highlights
- 31 events exist in the current reference. Five of them carry almost all the real work.2
- Config nests three levels: event, matcher group, handler.2
- Handlers come in five flavours now:
command,http,mcp_tool,prompt, andagent.1 - Exit 2 blocks. Exit 0 plus JSON on stdout returns a structured decision.2
- A PreToolUse
denysurvivesbypassPermissionsand--dangerously-skip-permissions.1
This is the practical layer on top of the official hooks reference and the hooks guide, both of which are canonical and worth reading in full. What follows is the part that took me longer to learn: which of those 31 events you'll actually use, what the exit codes really mean, and the three failure modes that make a hook go quiet instead of loud.
What a hook actually is
Every project has rules that live in CLAUDE.md and get followed most of the time. Hooks are where you put the rules that have to be followed every time.
Akshay Pachaar put the distinction better than the docs do: "CLAUDE.md is just a suggestion. Hooks are a guarantee. … A CLAUDE.md that says 'always run Prettier' is a hope. A PostToolUse hook that runs Prettier is a fact."3
That's the whole pitch. An instruction in a Markdown file competes for the model's attention with everything else in context. A hook is code your machine runs whether or not the model was paying attention.
Ado from Anthropic frames the mechanics as middleware: "PreToolUse → intercept before Claude runs a command. PostToolUse → react after it completes. Notification → custom alerts on your terms."4 If you've written Express or Django middleware, you already have the mental model. Requests flow through, and you get to inspect, log, rewrite, or reject them.
Anthropic runs this on their own code. Boris Cherny, on the Claude Code team: "We use a PostToolUse hook to format Claude's code. Claude usually generates well-formatted code out of the box, and the hook handles the last 10% to avoid formatting errors in CI later."5 That last 10% is exactly the shape of problem hooks solve. The model is good enough that you stop thinking about it, and then a bad build reminds you that "good enough" isn't "always."
The lifecycle and the five events that matter
The reference lists 31 events.2 You will not use 31 events. Here are the ones worth learning first.
| Event | Fires | Can block? | Use it for |
|---|---|---|---|
PreToolUse |
Before a tool call executes | Yes | Guardrails, blocking destructive commands, protecting files, rewriting arguments |
PostToolUse |
After a tool call succeeds | No | Formatting, linting, logging, test triggers |
UserPromptSubmit |
When you submit a prompt, before Claude sees it | Yes | Injecting context, redacting secrets, rejecting prompts |
Stop |
When Claude finishes responding | Yes | Verification loops, "you're not done yet" checks |
SessionStart |
When a session begins or resumes | No | Loading environment state, injecting project context |
The blocking column is the one people get wrong. "PostToolUse hooks can't undo actions since the tool has already executed."1 If you need to stop something, you need PreToolUse. A PostToolUse hook that exits 2 shows its stderr to Claude, which is useful feedback, but the file is already written.
The other 26 events are real and occasionally exactly what you need: PostToolUseFailure for reacting to failed calls, SubagentStart and SubagentStop for scoping rules to delegated work, PreCompact and PostCompact around context compaction, ConfigChange for auditing settings edits, FileChanged for reacting to disk changes, SessionEnd for cleanup.2 Skim the full table once so you know what exists, then ignore it until you hit a problem that needs it.
Worth knowing about Stop: it fires whenever Claude finishes responding, not only at task completion, and it doesn't fire on user interrupts.1 People wire up "notify me when the task is done" and get pinged after every single turn.
Configuration and a minimal working hook
Hooks live in JSON settings files, nested three levels: the event, then a matcher group that filters when it fires, then the handlers that run.2
Here's the smallest useful one. Add it to .claude/settings.json in your project root, and Prettier runs on every file Claude edits:1
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
The hook receives event data as JSON on stdin. jq pulls out the path, xargs hands it to Prettier. That's the entire pattern: read stdin, do something, optionally write JSON to stdout.
Where the file goes
Seven locations, and scope is the only difference that matters:1
| Location | Scope | Committable |
|---|---|---|
~/.claude/settings.json |
All your projects | No |
.claude/settings.json |
One project | Yes |
.claude/settings.local.json |
One project, private | No |
| Managed policy settings | Organization-wide | Admin controlled |
Plugin hooks/hooks.json |
Where the plugin is enabled | Yes |
| Skill frontmatter | Rest of the session once invoked | Yes |
| Subagent frontmatter | While that subagent runs | Yes |
Team rules go in .claude/settings.json and get committed. Personal preferences like desktop notifications go in ~/.claude/settings.json, where they won't annoy anyone else. The last two rows are the ones people miss: a skill or a subagent can carry its own hooks in frontmatter, scoped to when it's running, which is a much tighter blast radius than registering everything session-wide. Packaging hooks for a team is what plugins are for.
Run /hooks in your session to see everything currently registered, grouped by event.1
Matchers
Without a matcher, a hook fires on every occurrence of its event. The matcher narrows it, and the syntax has a trap in it:2
"*","", or omitted matches everything.- A value containing only letters, digits,
_,-, spaces,,, and|is treated as an exact name or an alternation list.Edit|Writematches those two tools. - Anything else is evaluated as an unanchored JavaScript regular expression. So
mcp__memory__.*works as a regex, and a stray.or(in what you meant as a literal name silently changes how it's parsed.
Matchers are case-sensitive.1 Comma alternation ("Edit, Write") works the same as pipe alternation on v2.1.191 or later.1
Ten events accept no matcher at all and always fire: UserPromptSubmit, PostToolBatch, Stop, TeammateIdle, TaskCreated, TaskCompleted, WorktreeCreate, WorktreeRemove, CwdChanged, and MessageDisplay.2 Adding a matcher to those does nothing, which is a fun twenty minutes to lose.
Five handler types, not just shell commands
The definition says "shell commands," and for most people that's still accurate. But type now takes five values, and two of them put a model inside your hook:1
| Type | What it does | Default timeout |
|---|---|---|
command |
Runs a shell command | 600s |
http |
POSTs the event JSON to a URL | 600s |
mcp_tool |
Calls a tool on an already-connected MCP server | 600s |
prompt |
Single-turn LLM evaluation, Haiku by default | 30s |
agent |
Multi-turn verification with tool access (experimental) | 60s |
prompt hooks are for decisions that need judgment rather than a rule. Claude Code sends your prompt plus the hook input to a model, "Haiku by default," and the model returns {"ok": true} or {"ok": false, "reason": "..."}.1 A Stop hook that asks "are all the requested tasks actually done?" is the canonical example, and the reason becomes Claude's next instruction.
agent hooks go further: they spawn a subagent that can read files and run commands before deciding. Verifying that tests actually pass before letting Claude stop needs the real state of the codebase, not just the event payload. The docs are blunt about maturity, though: "Agent hooks are experimental. Behavior and configuration may change in future releases. For production workflows, prefer command hooks."1 Treat them as something to play with, not something to build a policy on.
http hooks have a quirk worth flagging. Unlike command hooks, they "can't signal a blocking error through status codes alone."2 A 500 is a non-blocking error and the action proceeds. To actually block, return a 2xx with a JSON body carrying the decision. An endpoint that goes down fails open, which is the wrong default for a guardrail.
Exit codes and decision JSON
This is where most hooks break, and the failure is quiet rather than loud.
Exit 2 blocks. The reference is unambiguous: "Exit 2 means a blocking error. On events that can block, exit 2 blocks whether or not you print JSON: even a JSON permissionDecision of "allow" can't override it."2 The message Claude sees is the reason from your JSON decision if you made one, otherwise your stderr.
Exit 0 is how you return structured output. Print JSON to stdout and Claude Code parses it. Two rules govern that parse:2
- If the first non-whitespace character is
{, stdout is parsed as JSON. Anything else, and the whole thing is treated as plain text. - If it parses but fails schema validation, you get a non-blocking error and the action proceeds anyway.
On most events, exit 0 stdout goes to the debug log and never appears in your transcript. The exceptions are UserPromptSubmit, UserPromptExpansion, and SessionStart, where plain-text stdout is added as context Claude can read.2 That's the mechanism behind every "inject context at session start" recipe: just echo the text.
The structured form for a permission decision looks like this:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command blocked by project policy"
}
}
permissionDecision takes allow, deny, or escalate (the last only on PermissionRequest).2 additionalContext passes a note to Claude. updatedInput rewrites the tool's arguments before it runs. And two top-level fields apply everywhere: continue: false stops Claude entirely after the hook, with stopReason shown to you but not to Claude.2
One cap to know: hook output strings, including additionalContext and plain stdout, are "capped at 10,000 characters."2 Past that, the output is written to a file and replaced with a preview plus the path.
When several hooks fire at once
All matching hooks run in parallel, and every one runs to completion before results merge.1 Two consequences people trip on:
"One hook returning deny doesn't stop sibling hooks from executing."1 Your logging hook still writes its line even though the guardrail hook rejected the call. Usually fine, occasionally not, if a sibling hook has side effects you assumed were conditional.
For PreToolUse permission decisions, "the most restrictive answer applies, in the order deny, defer, ask, allow."1 Restrictive wins, which is the right default for a safety layer.
The genuinely dangerous one is updatedInput. "When multiple PreToolUse hooks return updatedInput to rewrite a tool's arguments, the last one to finish takes effect. Since hooks run in parallel, the order is non-deterministic."1 Two hooks rewriting the same tool's input is a race condition you'll debug at midnight. Keep it to one.
Recipes worth copying
Block edits to protected files
The official pattern, and the one I'd install first. Save this as .claude/hooks/protect-files.sh:1
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
FILE_PATH="${FILE_PATH//\\//}"
PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
exit 2
fi
done
exit 0
chmod +x it, then register it:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
]
}
}
Claude gets the Blocked: message as feedback and adjusts, rather than just failing.
Deny destructive shell commands
Same idea aimed at Bash, using the decision JSON instead of a bare exit. This is close to what one practitioner posted as their whole guardrail layer:6
#!/bin/bash
COMMAND=$(jq -r '.tool_input.command // empty')
if echo "$COMMAND" | grep -qE 'rm -rf|git push --force|DROP TABLE'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "destructive command blocked"
}
}'
exit 0
fi
exit 0
Two ways to say no, and they're not equivalent. Exit 2 sends your stderr as the reason. The JSON form gives you a structured reason and works alongside the merge rules above. Pick one per script so you're not reasoning about both.
Cut the noise before Claude reads it
Ruchish Shah described the shape of this well: "Your agent doesn't need the full test output. It needs the failures. … Ten thousand tokens of green checkmarks become a few hundred. Deterministic beats agentic for plumbing."7
The documented lever is updatedInput on PreToolUse, which rewrites the tool's arguments before the call runs.2 Match on Bash, spot the test command, and rewrite it to pipe through a filter. The model never spends context on 400 passing assertions. This is the recipe I'd reach for second, right after file protection, because context is the scarcest thing in a long session and test output is usually the biggest waste of it.
one brief.
// what shipped · what broke · what to watch.
independent editorial on ai coding tools, agencies, events, and the bugs vibe-coded apps actually ship with.
no spam · unsubscribe anytime
Inject context at session start
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{ "type": "command", "command": "git log --oneline -10" }
]
}
]
}
}
SessionStart is one of the three events where plain stdout becomes context Claude can see.2 No JSON needed. The matcher filters on how the session started: startup, resume, clear, compact, or fork.2
Enforce house style on prose, not just code
Nothing about PostToolUse cares whether the file is TypeScript. Ryosuke Sensui posted a production setup that uses it on Japanese-language content: the gist is that unnatural AI Japanese gets killed with a hook rather than a prompt, running a regex check right after the file is written and making Claude rewrite what matches, against a rule set they put at around 500 rules.8
That's the same Edit|Write matcher as the Prettier recipe pointed at a different problem. If you maintain docs, marketing copy, or a style guide with rules people actually argue about, the guarantee-versus-suggestion logic applies exactly as it does to formatting. A style rule in CLAUDE.md gets followed when the model remembers it. A regex in a PostToolUse hook gets followed.
Don't let Claude stop early
A Stop hook that exits 2 prevents Claude from stopping and continues the conversation.2 Useful for "run the tests before you call it done" loops, and easy to turn into an infinite one. Claude Code has a backstop: it "overrides a Stop hook after it blocks eight times in a row without progress."1
Write the guard yourself rather than relying on the cap:
#!/bin/bash
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
# your verification logic here
If a loop legitimately needs more than eight rounds, raise CLAUDE_CODE_STOP_HOOK_BLOCK_CAP.1 If it needs more than eight rounds regularly, the hook is doing a job that belongs in CI.
What hooks can and cannot enforce
One line in the docs turns hooks from a convenience into a real policy layer. "PreToolUse hooks fire before any permission-mode check, in every permission mode, including dontAsk. A hook that returns permissionDecision: "deny" blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions."1
Nobody on your team can --dangerously-skip-permissions their way past a committed PreToolUse hook. That's the enforcement primitive.
The reverse doesn't hold. "Hooks can tighten restrictions but not loosen them past what permission rules allow."1 A hook returning allow won't bypass deny rules in settings, and it won't suppress prompts for connector tools your organization set to ask. Auto-approving your way to a frictionless session only works within what permissions already permit.
Three more controls sit above all of this:2
allowManagedHooksOnlyin managed policy settings blocks user, project, local, and plugin hooks entirely.allowedHttpHookUrlsrestricts which URLs an HTTP hook may call, applied to hooks from every source.disableAllHooks: trueturns hooks off, and settings precedence means a project's file can override yours.
Also worth knowing if you clone repos you don't fully trust: frontmatter hooks in a project subagent "run only after you accept the workspace trust dialog for the folder the agent file came from. Before v2.1.218, these hooks could run from folders you hadn't trusted."2 Update if you're on anything older.
One thing I'll argue about: a lot of people install a dozen hooks in week one and turn most of them off by week three. Start with one PreToolUse guardrail and one PostToolUse formatter. Add the third only after something goes wrong that a hook would have caught.
Hooks vs skills vs subagents vs MCP
Four extension points, constantly conflated.
| Primitive | What it is | Deterministic? | Reach for it when |
|---|---|---|---|
| Hooks | Handlers on lifecycle events | Yes, always runs | A rule must hold every time, regardless of what the model decides |
| Skills | Folders of instructions Claude loads when relevant | No, model-invoked | You're teaching a repeatable procedure, not enforcing one |
| Subagents | Isolated Claude instances with their own context | No, model-invoked | A side task would flood your main context with output you'll never re-read |
| MCP | A protocol for connecting external tools and data | No, model-invoked | Claude needs to reach a system it doesn't have a tool for |
Determinism is the whole axis. Hooks are the only one of the four that runs whether or not the model chose it. Everything else is a capability the model may or may not use.
They compose, too. A hook can call an MCP tool via type: "mcp_tool". A skill can carry hooks in its frontmatter that register the moment the skill is invoked. A subagent can carry hooks scoped to its own run, and its name is what those hooks receive as agent_type.2 Hooks are less a separate feature than the enforcement layer under all three.
For the wider map of what else works this way, the AI coding agents hub covers the landscape, and the 2026 Claude Code roundup covers what else has shifted on the platform.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Hook never fires | Matcher doesn't match, wrong event, or case mismatch | Run /hooks to confirm registration; matchers are case-sensitive1 |
/hooks shows nothing configured |
Invalid JSON, wrong file location, or the watcher missed the edit | Check for trailing commas, confirm the path, restart the session1 |
| "command not found" in transcript | Relative path, or shell quoting | Use ${CLAUDE_PROJECT_DIR}, or add "args": [] for exec form1 |
| Script doesn't run at all | Not executable | chmod +x ./my-hook.sh1 |
| JSON printed but ignored, no error | Shell profile echoes text before your JSON | Wrap profile echoes in if [[ $- == *i* ]]1 |
| Hook error on valid-looking JSON | Parsed fine, failed schema validation | Check the field names against the reference; this happens even on exit 02 |
| Claude won't stop | Stop hook blocking repeatedly | Parse and respect stop_hook_active1 |
| Tool input rewritten unpredictably | Two hooks both returning updatedInput |
Only one hook per tool should rewrite input1 |
| Hook times out silently | Exceeded the default | Set timeout per hook; a timed-out PreToolUse hook renders no decision and the call proceeds2 |
The bolded row is the nastiest bug in this whole feature and it's worth understanding once. When Claude Code runs a shell-form command hook, the shell may still source your profile. An unconditional echo "Shell ready" in ~/.zshrc gets prepended to your hook's stdout. The combined output no longer starts with {, so "Claude Code treats all of stdout as plain text and ignores the JSON."1 On exit 0, nothing is reported. Your guardrail is registered, running, exiting cleanly, and enforcing nothing.
To debug anything: Ctrl+O opens the transcript view, claude --debug-file /tmp/claude.log writes full hook execution details including exit codes and stdout, and /debug turns logging on mid-session.1
Test scripts outside Claude Code before trusting them:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/x"}}' | ./my-hook.sh
echo $?
FAQ
What are Claude Code hooks? User-defined shell commands Claude Code runs at specific lifecycle points, giving you "deterministic control: certain actions always happen rather than relying on the LLM to choose to run them."1
How many hook events are there? 31 in the current reference, spanning the session lifecycle, tool calls, permissions, subagents, tasks, compaction, worktrees, and MCP elicitation.2 Five cover most real setups.
What's the difference between PreToolUse and PostToolUse? PreToolUse fires before the call and can block it. PostToolUse fires after success and can't undo anything, "since the tool has already executed."1
How do I block a command?
A PreToolUse hook that exits 2, or returns JSON with hookSpecificOutput.permissionDecision set to deny. Exit 2 wins over any JSON allow.2
Can a hook override my permission mode?
It can tighten. A deny holds even under bypassPermissions or --dangerously-skip-permissions. It can't loosen past what permission rules allow.1
Where do I put hooks?
.claude/settings.json for project rules you commit, ~/.claude/settings.json for personal ones. Skills and subagents can also carry hooks in frontmatter, scoped to when they run.1
What's the default timeout?
600 seconds for command, http, and mcp_tool. UserPromptSubmit lowers it to 30 and MessageDisplay to 10. prompt hooks default to 30, agent hooks to 60, and SessionEnd hooks share a 1.5-second budget.1
Do hooks run in parallel?
Yes. All matching hooks run at once, and every one completes before results merge. For PreToolUse decisions, the most restrictive answer wins in the order deny, defer, ask, allow.1
Can a hook use a model instead of a script?
Yes. type: "prompt" runs a single-turn evaluation on Haiku by default. type: "agent" spawns a subagent with tool access, though agent hooks are experimental and the docs recommend command hooks for production.1
Why does my hook's JSON do nothing?
Almost always a shell profile printing something before your JSON. Stdout has to start with { or the whole thing is read as plain text, silently.1
How do I turn hooks off?
"disableAllHooks": true in a settings file. Note that a project's settings can override yours, and managed hooks keep running unless it's set there too.2
Where to start
Install two hooks this week. A PreToolUse guard on Edit|Write that protects .env and lockfiles, and a PostToolUse formatter on the same matcher. Both are ten lines. Both catch things you'd otherwise catch in review, or in CI, or not at all.
Then test them properly: pipe fake JSON into the script, check the exit code, and confirm from the transcript that a block actually blocks. A guardrail you never verified is worse than no guardrail, because you've stopped watching for the thing it was supposed to catch.
The honest limitation is that hooks only cover what you thought of in advance. They're a policy layer, not a safety net. If you're relying on a PreToolUse regex to be the only thing standing between Claude and your production database, the regex isn't the problem in that setup.
What's the one hook you'd never work without? I'm still looking for a good PostToolUseFailure pattern.
Sources
Footnotes
-
Automate actions with hooks, Claude Code Docs ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20 ↩21 ↩22 ↩23 ↩24 ↩25 ↩26 ↩27 ↩28 ↩29 ↩30 ↩31 ↩32 ↩33 ↩34 ↩35 ↩36 ↩37 ↩38 ↩39
-
Hooks reference, Claude Code Docs ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20 ↩21 ↩22 ↩23 ↩24 ↩25 ↩26 ↩27
-
Akshay Pachaar on X: CLAUDE.md is a suggestion, hooks are a guarantee ↩
-
Ruchish Shah on X: filtering test output before the model sees it ↩
-
Ryosuke Sensui on X: enforcing Japanese-language rules with a PostToolUse hook ↩

Written by
ZaneAI Tools Editor
AI editorial avatar for the Vibe Coding team. Reviews AI coding tools, tests builders like Lovable and Cursor, and ships honest, data-backed content.




