CLAUDE.md rules are suggestions. Hooks are guarantees.
You can tell Claude in your CLAUDE.md never to modify .env files. It will probably listen. Set up a PreToolUse hook that blocks writes to .env files and it will always block them. That's the whole point: hooks give you deterministic control over what Claude Code does at every stage of its execution.
There are 18 lifecycle events you can hook into. Most of them you'll never use. This guide covers the 5 that are worth setting up, with the actual scripts.
How Claude Code hooks work
A hook is a shell command that runs automatically when a lifecycle event fires. Claude Code passes context to your hook via stdin as JSON, your hook does something, and the exit code tells Claude what happened:
Exit 0 — success, continue
Exit 2 — blocking error, send stderr back to Claude as an error message
Any other exit code — non-blocking warning
Exit 2 is blocking for some events and non-blocking for others. For PreToolUse, exit 2 stops the tool from running and Claude has to respond to your error. For Stop, exit 2 prevents Claude from finishing and forces it to keep working. For PostToolUse or SessionStart, exit 2 just shows your stderr (the tool already ran, or the session already started). Check the official exit code table to know which events can actually be blocked.
Your hook receives JSON on stdin with the session ID, working directory, tool name, and tool inputs. For PostToolUse hooks it also gets the tool's output. For Stop hooks it gets the conversation transcript path.
Exit codes are the simple path. There's also a JSON output path: exit 0 and print a JSON object to stdout with a hookSpecificOutput field. This gives you richer control: permissionDecision: "deny" to block with a structured reason, permissionDecision: "ask" to escalate to the user, or updatedInput to modify a tool's parameters before it runs.
The scripts below use exit codes for simplicity. If you need anything beyond binary allow/block, look at the JSON output approach in the official docs.
One thing that trips people up: stdout and stderr behave differently depending on the event and exit code. On exit 2, stderr goes back to Claude as the blocking error message; stdout is ignored. On exit 0, stdout goes to Claude's context only for SessionStart and UserPromptSubmit hooks — for everything else, it's verbose-mode only (Ctrl+O). Stderr always goes to your terminal.
The rule: send blocking messages to stderr and exit 2. Send context injection to stdout only for SessionStart hooks.
The 5 hooks worth setting up
1. Dangerous command blocker (PreToolUse)
The most important hook. Runs before every Bash command and blocks anything that matches a dangerous pattern before Claude executes it.
#!/bin/bash
# ~/.claude/hooks/block-dangerous-commands.sh
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if [ -z "$COMMAND" ]; then
exit 0
fi
# Block patterns
DANGEROUS_PATTERNS=(
"rm -rf /"
"rm -rf ~"
"rm -rf \$HOME"
"> /dev/sda"
"mkfs"
"dd if="
"curl.*\|.*bash"
"wget.*\|.*bash"
":(){ :|:& };:"
)
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
if echo "$COMMAND" | grep -qE "$pattern"; then
echo "Blocked: command matches dangerous pattern '$pattern'" >&2
exit 2
fi
done
exit 0Save this to ~/.claude/hooks/block-dangerous-commands.sh, then run chmod +x ~/.claude/hooks/block-dangerous-commands.sh. These scripts use jq to parse JSON — install it with brew install jq (macOS) or apt install jq (Linux) if you don't have it.
Config:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/block-dangerous-commands.sh"
}
]
}
]
}
}Claude receives the error message and has to work around it. It can't retry the blocked command silently.
2. Desktop notification on Stop
The least glamorous hook and the one I use most. Fires when Claude finishes so you don't have to keep checking.
One note: there's a Notification event with an idle_prompt matcher that fires specifically when Claude is waiting for your input, which is arguably more precise than Stop. Stop fires on every response completion, including intermediate steps in a long task. If you get too many notifications, try the Notification hook with matcher: "idle_prompt" instead.
#!/bin/bash
# ~/.claude/hooks/notify-done.sh
# macOS
osascript -e 'display notification "Claude Code is done" with title "Claude Code"' 2>/dev/null
# Linux (uncomment if needed)
# notify-send "Claude Code" "Done" 2>/dev/null
exit 0{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/notify-done.sh"
}
]
}
]
}
}If you run long tasks and switch windows, this means you stop checking back every 30 seconds.
3. Format on stop (not after every edit)
The obvious hook is PostToolUse formatting: run prettier after every Write or Edit. Don't do it this way. If your formatter changes files, Claude gets a system reminder about those changes injected into the conversation on every single edit. In a 50-turn session that's a lot of context noise.
Format on Stop instead. Claude has finished its work, you get clean code, and nothing gets injected mid-session. The same principle applies to MCP servers. If you're loading a lot of tools, check the MCP guide for how that overhead compounds.
#!/bin/bash
# ~/.claude/hooks/format-on-stop.sh
# Format staged and modified files only
git diff --name-only --diff-filter=AM 2>/dev/null | \
grep -E '\.(js|jsx|ts|tsx|css|json|md)$' | \
xargs -I {} npx prettier --write {} 2>/dev/null
exit 0{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/format-on-stop.sh"
}
]
}
]
}
}For Python projects, swap prettier for black and isort. For Go, gofmt -w. The pattern is the same: format only changed files, run only when Claude stops.
4. Session context injection (SessionStart)
SessionStart fires when you start, resume, clear, or compact a session. Its stdout gets injected directly into Claude's context before your first prompt. This is how you stop spending the first message of every session re-explaining what you're working on.
#!/bin/bash
# ~/.claude/hooks/session-start.sh
echo "=== Session Context ==="
echo "Branch: $(git branch --show-current 2>/dev/null || echo 'not a git repo')"
echo "Recent commits:"
git log --oneline -5 2>/dev/null || echo "none"
echo ""
echo "Modified files:"
git diff --name-only 2>/dev/null || echo "none"
echo "======================="
exit 0{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/session-start.sh"
}
]
}
]
}
}Keep the output short. Everything your hook prints to stdout becomes part of Claude's context on every session start, so a 10KB context dump defeats the purpose. Branch name, last 5 commits, and modified files is enough.
5. Block writes to protected files (PreToolUse)
For any file that should never be touched — secrets, migration files, generated config — a PreToolUse hook on Write/Edit/MultiEdit blocks Claude from writing to it. Note: this doesn't cover Bash commands that might modify the same file with echo or sed. If you need full coverage, add a second hook matching Bash that scans the command string.
#!/bin/bash
# ~/.claude/hooks/protect-files.sh
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [ -z "$FILE_PATH" ]; then
exit 0
fi
PROTECTED=(
".env"
".env.local"
".env.production"
"migrations/"
"secrets/"
)
for protected in "${PROTECTED[@]}"; do
if echo "$FILE_PATH" | grep -q "$protected"; then
echo "Blocked: $FILE_PATH is protected. Modify this file manually if needed." >&2
exit 2
fi
done
exit 0{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/protect-files.sh"
}
]
}
]
}
}Configuration and scopes
One thing to know before you start: Claude Code snapshots your hook configuration at startup. If you add or modify a hook mid-session, it has no effect until you restart. This trips up everyone the first time.
Hooks go in the hooks key of your settings file. Three places to put them:
~/.claude/settings.json— global, applies to every project.claude/settings.json— project-level, committed to git, your team gets the same hooks.claude/settings.local.json— project-level but gitignored, for hooks with credentials
The dangerous command blocker and desktop notifications belong in your global settings. The format-on-stop hook and file protection hooks belong in project settings so you can customize them per project.
You can also manage hooks interactively through the /hooks slash command inside a Claude Code session.
Common Claude Code hooks mistakes
Stdout vs stderr
On exit 2, Claude Code ignores stdout and feeds your stderr back to Claude as the blocking error message. On exit 0, stdout goes to Claude's context only for SessionStart and UserPromptSubmit hooks; for every other event type, stdout is verbose-mode only. This catches people writing echo "blocked" when they mean echo "blocked" >&2. Check with Ctrl+O (verbose mode) if your hook output isn't appearing where you expect.
The formatter context problem
Running a formatter on every PostToolUse fires a system reminder about file changes into the conversation on every edit. In a long session this adds up. Format on Stop.
Exit 2 is not exit 1
Exit 1 is a non-blocking warning. Only exit 2 blocks tool execution and sends your error message to Claude. I've seen people use exit 1 wondering why their hook isn't stopping Claude.
Hook timeouts
Command hooks default to a 600-second timeout, so they won't time out on slow scripts by default. But a hook that blocks still pauses the session until it finishes. Keep hooks fast. If you need something slow, set "async": true in the hook config to run it in the background without blocking the session.
Testing hooks
The easiest way to test a hook is to pipe sample JSON to it manually. Claude Code's stdin format for a Bash PreToolUse looks like:
{
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/test"
}
}Run echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | bash your-hook.sh and confirm it exits with the right code.
Frequently asked questions about Claude Code hooks
What are Claude Code hooks?
Hooks are shell commands that run automatically at lifecycle events during a Claude Code session: before a tool runs (PreToolUse), after it runs (PostToolUse), when Claude stops (Stop), and when a session starts (SessionStart). They give you deterministic control over Claude's behavior, unlike CLAUDE.md instructions, which Claude can override.
How do I add hooks to Claude Code?
Edit the hooks key in your settings file. For global hooks that apply to every project, use ~/.claude/settings.json. For project-specific hooks, use .claude/settings.json in your repo. Each hook needs a type ("command", "http", "prompt", or "agent"), the relevant command or URL, and optionally a matcher to filter which tools trigger it. Restart Claude Code after making changes, or use the /hooks slash command to manage them interactively without restarting.
Are Claude Code hooks global or project-specific?
Both. Global hooks go in ~/.claude/settings.json and apply everywhere. Project hooks go in .claude/settings.json (committed to git, your whole team gets them) or .claude/settings.local.json (gitignored, for hooks with credentials or machine-specific paths). You can have hooks at all three levels simultaneously.
What's the difference between PreToolUse and PostToolUse?
PreToolUse runs before the tool executes. Exit 2 in a PreToolUse hook blocks the tool entirely and sends your stderr back to Claude. PostToolUse runs after the tool finishes and receives the tool's output. You can't block with PostToolUse since the tool already ran, but exit 2 will show your stderr to Claude, which is useful for flagging when a tool returned something unexpected.
Do hooks slow down Claude Code?
Only if they're synchronous and slow. Synchronous hooks block the session while they run, so a hook doing a slow network call will pause everything. The default timeout is 600 seconds. If you need something slow, set "async": true in the hook config. Async hooks run in the background and don't block the session.
Start with the dangerous command blocker and desktop notification. Those two cover the most ground for the least setup: one protects you, one saves you time. Add the file protection hook for any project with sensitive files. The SessionStart context hook is worth it if you find yourself re-explaining context at the start of every session.
The official hooks reference has all 18 lifecycle events and the full JSON schema. You probably won't need most of them.
I write about Claude Code internals every week - context windows, hooks, MCPs, how things actually work.
