Claude Code Hooks (2026): Every Hook Event, 6 Examples and the Gotchas
by Sandlabs Team, Founder, Sandlabs
Every team that adopts Claude Code hits the same wall around week three. Claude is fast — too fast. It writes code, edits files, runs commands, and you find yourself trailing behind it: re-running the formatter, scrolling back to check what got committed, asking "did it run the tests?" five times a day.
The fix is hooks. Claude Code hooks let you run shell commands automatically when specific events happen — before a tool runs, after a file is written, when Claude finishes responding. Used well, they replace the running mental checklist with deterministic guarantees: every edit gets formatted, every Bash command gets logged, every push to main gets blocked unless you approve it.
This guide is the practical version. What hooks actually are, the events you'll use, the JSON syntax, a full reference table of every hook event, and six hooks we run on real consulting projects (including a Stop hook). Current as of October 2026, checked against Anthropic's hooks reference.
What Are Claude Code Hooks?
Claude Code hooks are user-defined shell commands that fire at specific points in Claude's execution lifecycle — before/after tool calls, on session start, when Claude stops, when a notification appears, and others. They're configured in settings.json and run as ordinary shell processes with structured JSON input on stdin.
The mental model: hooks are deterministic rules. Slash commands and prompts ask Claude to do something. Hooks just happen. If you write a PostToolUse hook for Edit|Write that runs Prettier, every Edit and Write — across every conversation, every project, every developer on the team — runs Prettier afterward. There is no "Claude forgot."
This is the feature that turns Claude Code from a clever pair-programmer into something you can deploy across a team without losing sleep over consistency.
The Hook Events You'll Actually Use
Claude Code currently documents 33 hook events (the full list is below). You'll only need a handful in practice. The most useful for day-to-day team workflows:
- PreToolUse — fires before any tool runs. Use to block, ask, or transform tool input. The most powerful event; this is where guardrails live.
- PostToolUse — fires after a tool succeeds. Use for auto-formatting, audit logging, or running tests after writes.
- UserPromptSubmit — fires when the user submits a prompt, before Claude processes it. Use to inject context or validate input.
- SessionStart — fires when a session opens or resumes. Use to load env vars, inject project state, or warm up context.
- Stop — fires when Claude finishes responding. Use to validate output or force a continuation if a check fails.
- Notification — fires when Claude needs attention (permission prompt, idle). Use for desktop notifications.
- PreCompact / PostCompact — fires around context compaction. Use to snapshot or re-inject critical context.
- SubagentStop — fires when a subagent finishes. Use to capture results or trigger follow-up work.
Newer events like PostToolBatch, Elicitation, TaskCompleted, and WorktreeCreate cover more advanced workflows (parallel tool calls, MCP input interception, multi-agent coordination). Start with PreToolUse and PostToolUse — they cover 80% of the value.
Every Claude Code Hook Event
The complete list as of October 2026, from Anthropic's hooks reference.
"Matcher" is what the matcher field is compared against; "Exit 2" is what happens when your hook exits with code 2.
| Event | Fires when | Matcher matches | Exit 2 does |
|---|---|---|---|
SessionStart | A session starts or resumes | startup, resume, clear, compact, fork | Shows stderr to the user only |
Setup | Claude Code runs an init or maintenance pass | init, maintenance | Ignored |
UserPromptSubmit | You submit a prompt, before Claude sees it | No matcher | Blocks the prompt |
UserPromptExpansion | A typed command expands into a prompt | Command name | Blocks the expansion |
PreToolUse | Before any tool call runs | Tool name | Blocks the tool call |
PermissionRequest | A tool call needs a permission decision | Tool name | Not honoured; use the JSON decision instead |
PermissionDenied | Auto mode denies a tool call | Tool name | Ignored |
PostToolUse | A tool call succeeds | Tool name | Shows stderr to Claude |
PostToolUseFailure | A tool call fails | Tool name | Shows stderr to Claude |
PostToolBatch | A batch of parallel tool calls resolves | No matcher | Stops the agentic loop |
Notification | Claude Code sends a notification | Notification type | Ignored |
MessageDisplay | Claude's text is shown to you | No matcher | Only changes what is displayed |
SubagentStart | A subagent is spawned | Agent type | Can add context |
SubagentStop | A subagent finishes | Agent type | Keeps the subagent working |
TaskCreated | A task is created | No matcher | Rolls back the task |
TaskCompleted | A task is marked complete | No matcher | Prevents completion |
Stop | Claude finishes responding | No matcher | Keeps Claude working |
StopFailure | A turn ends on an API error | Error type | Ignored |
TeammateIdle | An agent-team teammate is about to go idle | No matcher | Keeps it working |
InstructionsLoaded | CLAUDE.md or a rules file is loaded | Load reason | Ignored |
ConfigChange | A config file changes mid-session | Settings source | Blocks the change (except managed policy) |
CwdChanged | The working directory changes | No matcher | Shows stderr to the user |
DirectoryAdded | A directory is added with /add-dir | How it was added | Logged only |
FileChanged | A watched file changes | File names | Shows stderr to the user |
WorktreeCreate | A worktree is being created | No matcher | Any non-zero exit fails creation |
WorktreeRemove | A hook-created worktree is removed | No matcher | Can fail removal |
PreCompact | Before context compaction | manual, auto | Blocks compaction |
PostCompact | After context compaction | manual, auto | Shows stderr to the user |
PreModelSwitch | Before the model changes | Model name | Blocks the switch |
PostModelSwitch | After the model changes | Model name | Shows stderr to the user |
Elicitation | An MCP server asks you for input | MCP server name | Declines the request |
ElicitationResult | You answer an MCP elicitation | MCP server name | Blocks the answer |
SessionEnd | The session ends | End reason | Shows stderr to the user |
Two things the table makes obvious: only a few events can actually stop something (PreToolUse, UserPromptSubmit, Stop, SubagentStop, PreCompact, ConfigChange), and the rest are for logging, notifying or injecting context.
Anatomy of a Hook
Hooks live in ~/.claude/settings.json (global), .claude/settings.json (project, committed to the repo), or .claude/settings.local.json (project, gitignored). The structure:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "npx prettier --write \"$CLAUDE_PROJECT_DIR\"",
"timeout": 60
}
]
}
]
}
}
Three things to understand:
1. The matcher. A pattern that decides which tool calls trigger the hook. "" or omitted matches every occurrence. Edit|Write matches both. Bash matches only Bash. mcp__github__.* matches all GitHub MCP tools. A matcher made only of letters, digits, _, -, spaces, commas and | is an exact match; any other character turns it into a regular expression. Matchers are case-sensitive.
2. Hook input. Your command receives a JSON payload on stdin: session_id, cwd, hook_event_name, tool_name, tool_input, and event-specific fields. Parse it with jq or your language of choice.
3. Hook output. Exit codes control behaviour:
exit 0— proceed (and stdout becomes additional context for some events)exit 2— block the action (stderr is shown to Claude or the user)- other codes — non-blocking error, logged
For richer control, exit 0 with a structured JSON object on stdout. Set hookSpecificOutput.permissionDecision to "allow", "deny", or "ask" to override the default permission flow.
Handler types. "type": "command" (a shell command) is the one you'll use most, but hooks can also be http (POST the event to a URL), mcp_tool (call an MCP tool), prompt (ask a model to decide) or agent (experimental). Type /hooks inside Claude Code to see every hook that is active and which settings file it came from.
Six Claude Code Hook Examples That Earn Their Keep
These are pulled from real Sandlabs client projects. Drop them into .claude/settings.json and they work immediately.
1. Auto-format every file Claude touches
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs -I {} sh -c 'npx prettier --write {} 2>/dev/null || true'"
}
]
}
]
}
}
Saves you from PRs that mix Claude's output with manual formatter passes. Works for TypeScript, JSON, YAML, Markdown — anything Prettier handles. The || true keeps Claude moving if Prettier doesn't recognise a file type.
2. Block writes outside the project root
Save this as .claude/hooks/project-boundary.sh:
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ -n "$FILE" && "$FILE" != "$CLAUDE_PROJECT_DIR"* ]]; then
echo "Blocked: writes outside $CLAUDE_PROJECT_DIR are not allowed" >&2
exit 2
fi
exit 0
Wire it up:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/project-boundary.sh"
}
]
}
]
}
}
Critical when Claude is working in a client repo that lives next to your home dotfiles. One stray edit to ~/.zshrc ruins the day.
3. Audit log every Bash command
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -c '{ts: (now | todate), cwd, cmd: .tool_input.command}' >> \"$HOME\"/.claude/audit.jsonl"
}
]
}
]
}
}
For regulated environments, this is non-negotiable. JSONL is grep-friendly and works with jq for analysis. Pair with a weekly cron that ships the file to S3, BigQuery, or your SIEM of choice.
4. Desktop notification when Claude is waiting
{
"hooks": {
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude needs your input\" with title \"Claude Code\" sound name \"Glass\"'"
}
]
}
]
}
}
macOS version. Linux: replace with notify-send "Claude Code" "Claude needs your input". Stops you from staring at the terminal between answers — useful when Claude is running long agentic tasks and only occasionally needs a permission decision.
5. Force approval on destructive git commands
.claude/hooks/git-safety.sh:
#!/bin/bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -qE 'git (push.*--force|reset --hard|clean -[fd]+|branch -D)'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "ask",
permissionDecisionReason: "Destructive git command — confirm before running."
}
}'
fi
exit 0
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/git-safety.sh"
}
]
}
]
}
}
We added this after a session force-pushed over three days of in-progress work on a client repo. Cheap insurance for shared codebases.
6. Stop hook — don't let Claude stop until the types compile
A Stop hook fires when Claude finishes responding. If it exits 2, Claude is forced to keep working — which makes the Stop hook perfect for "don't stop until the build is green." The catch: you must check stop_hook_active and exit cleanly when it is already set, or you will loop forever.
Save this as .claude/hooks/stop-until-green.sh:
#!/bin/bash
INPUT=$(cat)
# Already re-prompted once this turn — let Claude stop to avoid an infinite loop
if [[ "$(echo "$INPUT" | jq -r '.stop_hook_active')" == "true" ]]; then
exit 0
fi
if ! npm run -s typecheck >/dev/null 2>&1; then
echo "Typecheck is failing — fix the errors before you stop." >&2
exit 2
fi
exit 0
Wire it up:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stop-until-green.sh"
}
]
}
]
}
}
This turns "Claude said it's done" into "Claude is done and the types compile." The stop_hook_active guard is the entire difference between a useful gate and an infinite loop — the most common mistake people make with the Claude Code Stop hook.
How to Stop Claude Reading .env Files
This one comes up in Anthropic's course material as a quiz question: a developer wants to prevent Claude from reading sensitive .env files. Which type of hook should they set up, and what tool names would they likely match?
The answer is a PreToolUse hook matching Read and Grep.
It has to be PreToolUse because only a pre-tool hook can stop the call before the file is opened; a PostToolUse hook fires after Claude has already seen the contents.
And it matches the tools that read (Read, Grep), not the ones that write or delete.
In a real project, go further than the quiz answer:
{
"permissions": {
"deny": ["Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)"]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Grep|Glob|Bash",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-env.sh" }
]
}
]
}
}
Your block-env.sh reads the tool input from stdin, checks whether the path or command mentions .env, and exits 2 with a reason on stderr if it does.
Three caveats from Anthropic's docs that most guides miss:
@file mentions skip tools entirely. If you type@.env, no tool call happens, so noPreToolUsehook fires. TheRead(...)deny rule is what catches that case.- Deny rules are the primary control; the hook is the extra layer. Claude applies
Readdeny rules to Grep, Glob and@mentions, and to common shell readers likecat,headandtail. - Neither catches everything in Bash. A script that opens the file itself, or a recursive
grep, can get past both. For real secrets, turn on Claude Code's sandbox, and keep production secrets out of the repo altogether.
Common Gotchas
A few things that will trip you up the first week:
Exit codes vs JSON output, not both. If you exit 2, Claude reads stderr and ignores stdout. If you exit 0 with JSON, Claude parses stdout. Mixing them leaves you debugging silent failures.
The cwd in hook input is not your script's working directory. Hook input gives you cwd as Claude saw it. Your script's actual cwd may differ. Always use $CLAUDE_PROJECT_DIR for project paths.
Matchers are case-sensitive. bash does not match Bash. Use exact tool names.
Stop hooks can loop forever. A Stop hook that exits 2 forces Claude to keep going. If your script doesn't check stop_hook_active from the input JSON and early-exit when it's true, you'll spin until the timeout.
Make scripts executable. chmod +x .claude/hooks/*.sh on macOS and Linux. Windows ignores this.
Don't store secrets in hooks. Hooks run in your shell with full environment access. They are powerful — and dangerous. Review every hook a teammate adds, especially in shared .claude/settings.json files. Treat hooks like CI scripts, not config.
Hooks vs Slash Commands vs MCP Servers
Teams reach for the wrong tool here all the time:
- Hooks — deterministic, always-run, event-driven. Use when you need a rule that fires every time without asking.
- Slash commands — Claude-invoked, contextual, judgment-dependent. Use when you want Claude to decide whether and how to run something.
- MCP servers — entire tool ecosystems Claude can call into. Use when you need a rich, reusable integration (GitHub, Slack, your database). See our Notion MCP server guide and best MCP servers 2026 for examples.
Rule of thumb: if it must always happen, it's a hook. If Claude should decide, it's a slash command or an MCP tool.
Frequently Asked Questions
Can Claude Code hooks run on Windows?
Yes. The hook command runs in your default shell. On Windows, that's PowerShell or cmd; use platform-appropriate commands (notify-send won't work, New-BurntToastNotification will). Most teams settle on cross-platform tooling like Node.js or Python scripts inside their hooks to avoid maintaining three versions.
Do hooks slow Claude down?
A simple hook (formatter, log append) adds 50–500ms per tool call. Heavy hooks (running a test suite) can add seconds. Use the timeout field to cap them. For very expensive checks, run them asynchronously or move them to CI rather than blocking Claude's session.
Can I use hooks to enforce security policy across a team?
Yes — that is the strongest use case. Commit .claude/settings.json to the repo with hooks that block dangerous tool calls: writes outside the repo, force pushes, edits to .env files. Every developer who clones the repo inherits the policy automatically. Pair this with code review on changes to the hook scripts themselves.
What happens if a hook errors?
Exit codes other than 0 or 2 are logged but non-blocking — Claude continues. If you need an error to halt the action, exit 2 explicitly. Use set -e and set -u in shell hook scripts to surface bugs early during development.
Are Claude Code hooks the same as Git hooks?
No. Git hooks fire on Git operations (commit, push). Claude Code hooks fire on Claude's tool calls and lifecycle events. They are complementary — Git hooks for repo policy, Claude Code hooks for AI agent behaviour.
Which hook stops Claude from reading .env files?
A PreToolUse hook matching Read and Grep (add Glob and Bash in practice), exiting 2 when the target is a .env file. Pair it with Read(./.env) deny rules in your permissions, because @ file mentions skip tool calls and therefore skip hooks.
What hook events does Claude Code support?
33 as of October 2026, including PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, Stop, SubagentStop, Notification, PreCompact and SessionEnd. See the full table above for when each fires, what its matcher matches, and whether it can block.
What is a Claude Code Stop hook?
A Stop hook is a hook that fires on the Stop event — when Claude finishes responding. If your Stop hook exits 2, Claude is forced to keep working, which is how you enforce "don't stop until tests pass" or "don't stop until the types compile." The critical detail: the hook's JSON input includes a stop_hook_active field, and your script must exit 0 when it is true, otherwise the Stop hook loops forever. See example 6 above for a working script.
What Comes Next
Hooks are how you take a single-developer tool and turn it into a team-grade platform. Once your team has a .claude/settings.json with the right guardrails — formatters running, audit logs flowing, dangerous commands gated behind approvals — Claude Code stops being "the thing one engineer uses" and starts being infrastructure.
The next layer is connecting Claude to the rest of your stack: GitHub, Slack, your database, your CRM. That is what MCP servers do. Hooks plus MCP plus a thoughtful Claude Code setup is the full stack — deterministic guardrails, rich integrations, and a repeatable team workflow.
Want hooks, MCP servers, and a custom Claude Code setup designed for your team? At Sandlabs, we design and ship Claude-native systems in 2–6 weeks with fixed pricing. Talk to us.