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 is the whole point: hooks give you deterministic control over what Claude Code does at every stage of its execution.
Claude Code documents 33 lifecycle events you can hook into. Most teams only need a handful. Below is the full map, then the five worth installing, then GitHub-oriented patterns I use on real repos.
How Claude Code hooks work
A hook is a shell command, HTTP endpoint, MCP tool call, LLM prompt, or subagent that runs automatically when a lifecycle event fires. For command hooks, Claude Code passes context on stdin as JSON. Your hook does something, then exit codes and optional JSON on stdout tell Claude Code what happened.
How hooks signal success or failure
Exit 0: success. For most events, stdout is debug-only unless you return structured JSON. Exceptions: plain-text stdout is added to Claude's context for
SessionStart,UserPromptSubmit,UserPromptExpansion, andPostModelSwitch.Exit 2: blocking where the event allows it. Claude Code uses your stderr (or a JSON blocking reason) as the message. Exit 2 cannot be overridden by a JSON
permissionDecision: "allow".Any other exit code: usually non-blocking. Without valid decision JSON, the action proceeds and you may see a hook error notice. Exit 1 does not block.
Exit 2 is blocking for some events and observational for others. For PreToolUse, exit 2 stops the tool. For Stop, exit 2 prevents Claude from finishing and forces it to keep working. For PostToolUse or SessionStart, exit 2 just shows stderr (the tool already ran, or the session already started). Check the official exit code table.
Your hook receives JSON on stdin with session ID, working directory, tool name, and tool inputs. PostToolUse also gets the tool result. Stop can include last_assistant_message for the final assistant text of the turn.
Exit codes are the simple path. There is also a JSON output path: exit 0 and print a JSON object to stdout with hookSpecificOutput. That gives richer control: permissionDecision: "deny" / "ask" / "allow", updatedInput to rewrite tool args, or additionalContext to inject text. Prefer JSON for structured deny reasons; prefer exit 2 when you want a hard block with a stderr message.
One thing that trips people up: stdout and stderr behave differently by event and exit code. On exit 2, stderr is the blocking message; stdout is ignored for that purpose. On exit 0, only some events promote plain stdout into Claude's context. Send blocking messages to stderr and exit 2. Send SessionStart context via stdout or additionalContext JSON.
Handler types in current docs: command, http, mcp_tool, prompt, and agent (agent hooks are experimental).
Timeouts: defaults are usually 600 seconds for command / http / mcp_tool, 30 for prompt, 60 for agent. Some events are shorter (UserPromptSubmit / PreModelSwitch / PostModelSwitch often 30s; MessageDisplay 10s; SessionEnd shares a tight budget). Defaults vary by event. Check the docs and set "timeout" per hook when you care. For slow side effects that should not stall the session, set "async": true.
Free Claude Code crash course
60-min video lesson + CLAUDE.md starter kit. Yours when you subscribe.
Core events most people need
If you only remember six events, remember these:
Event | When | Why you care |
|---|---|---|
| Session begins, resumes, clears, or compacts | Inject branch / PR / sprint context |
| User sends a prompt | Validate or enrich prompts |
| Before a tool runs | Block dangerous Bash, protect files |
| Permission UI would appear | Auto-allow or deny via JSON (exit 2 is ignored here) |
| After a tool succeeds | Observe / log / rewrite tool output; cannot undo the tool |
| Claude finishes a response | Notify, format once, gate on tests |
Everything else in the catalog below is useful when you hit a specific workflow (MCP elicitation, worktrees, agent teams, compaction, model switches). For how the agentic tool loop itself works, see How tool use works under the hood (the while loop).
Full lifecycle map (33 events)
Source: Anthropic Hooks reference. "Can block?" is high-level (exit 2 or documented JSON decision). PermissionRequest blocks via JSON decision fields, not exit 2.
Session
Event | When | Can block? |
|---|---|---|
| Session begins or resumes | No |
|
| No |
| CLAUDE.md / rules load | No |
| Session terminates | No |
Turn
Event | When | Can block? |
|---|---|---|
| User submits a prompt | Yes |
| Slash / MCP prompt expands | Yes |
| Claude finishes responding | Yes |
| Turn ends on API error | No |
Tool loop
Event | When | Can block? |
|---|---|---|
| Before a tool runs | Yes |
| Permission UI | Via JSON decision (not exit 2) |
| Auto-mode denial | Observational + optional |
| After tool success | No (tool already ran) |
| After tool failure | No |
| After a parallel batch | Yes (can stop the loop) |
| Notifications fire | No |
| Streaming display | No (display only) |
Agents / tasks / teams
Event | When | Can block? |
|---|---|---|
| Subagent spawned | No |
| Subagent finishes | Yes |
| TaskCreate | Yes |
| Task marked done | Yes |
| Team teammate going idle | Yes |
Files / cwd / config
Event | When | Can block? |
|---|---|---|
| Settings change mid-session | Yes (except policy) |
| Working directory changes | No |
|
| No |
| Watched file changes | No |
Worktrees / compact / model / MCP
Event | When | Can block? |
|---|---|---|
| Worktree creation | Yes |
| Worktree removal | Yes |
| Before compaction | Yes |
| After compaction | No |
| Before model switch | Yes |
| After model switch | No |
| MCP elicitation request | Yes |
| After elicitation response | Yes |
The 5 hooks worth setting up
1. Dangerous command blocker (PreToolUse)
The most important hook. Runs before Bash and blocks commands that match a dangerous pattern.
#!/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
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 to ~/.claude/hooks/block-dangerous-commands.sh, then chmod +x it. These scripts use jq (brew install jq or apt install jq).
Config (exit-2 style):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/block-dangerous-commands.sh"
}
]
}
]
}
}Same idea with JSON permissionDecision (docs-preferred for structured deny). Narrow with if so the script only spawns for rm-shaped Bash:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(rm *)",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-rm.sh",
"args": []
}
]
}
]
}
}#!/bin/bash
# .claude/hooks/block-rm.sh
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q 'rm -rf'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Destructive command blocked by hook"
}
}'
else
exit 0
fiClaude receives the deny reason and has to work around it. It cannot silently retry a blocked command. Note: PreToolUse hooks still fire in bypassPermissions mode. A deny here is policy, not a soft preference. Pair this with Claude Code sandboxing and what /sandbox does not protect if you also use OS-level sandboxing.
2. Desktop notification on Stop
The least glamorous hook and the one I use most. Fires when Claude finishes so you do not keep checking the terminal.
One note: the Notification event with matcher idle_prompt fires when Claude has been waiting for input (~60 seconds). Stop fires on every response completion, including intermediate steps in a long task. If you get too many pings, switch to Notification + idle_prompt.
#!/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"
}
]
}
]
}
}3. Format on Stop (not after every edit)
The obvious hook is PostToolUse formatting: run Prettier after every Write or Edit. Do not do it that way.
If your formatter changes files, Claude gets a system reminder about those changes injected into the conversation on every edit. In a 50-turn session that is a lot of context noise. Swanbase-style guides still push PostToolUse Prettier. Format on Stop instead. Claude has finished its work, you get clean code, and nothing gets injected mid-session.
The same principle applies when you pile on MCP servers. Tool schemas and tokens add up. See MCP token overhead and when Bash is enough.
#!/bin/bash
# ~/.claude/hooks/format-on-stop.sh
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, swap Prettier for black / isort. For Go, gofmt -w. Same pattern: format only changed files, run only when Claude stops.
4. Session context injection (SessionStart)
SessionStart fires when you start, resume, clear, compact, or fork a session. Plain stdout (and additionalContext JSON) can be injected into Claude's context before the first prompt. This is how you stop spending the first message of every session re-explaining what you are 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 becomes context. Branch name, last 5 commits, and modified files is enough. Static project rules still belong in CLAUDE.md; see Inside Claude Code's system prompt for how instructions land in the prompt stack.
5. Block writes to protected files (PreToolUse)
For any file that should never be touched (secrets, migrations, generated config), a PreToolUse hook on Write/Edit blocks Claude from writing to it.
This does not cover Bash that rewrites the same path with echo or sed. For full coverage, add a second Bash matcher that scans the command string. Matcher note: docs examples use Edit|Write. If your build still exposes MultiEdit, include it; otherwise Edit|Write is enough. VERIFY: confirm tool names in your Claude Code build via /hooks or a dry-run edit.
#!/bin/bash
# ~/.claude/hooks/protect-files.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
FILE_PATH="${FILE_PATH//\\//}"
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": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/protect-files.sh"
}
]
}
]
}
}Useful hooks for GitHub workflows
These are the patterns I want on repos that ship through GitHub. They use the same PreToolUse / Stop / SessionStart mechanics above. Nothing here invents new event names.
Block force-push and direct pushes to main (PreToolUse)
#!/bin/bash
# ~/.claude/hooks/block-git-danger.sh
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if [ -z "$COMMAND" ]; then
exit 0
fi
if echo "$COMMAND" | grep -qE 'git[[:space:]]+push[[:space:]].*--force|--force-with-lease'; then
echo "Blocked: force-push is not allowed from Claude Code. Push manually if you intend it." >&2
exit 2
fi
if echo "$COMMAND" | grep -qE 'git[[:space:]]+push[[:space:]].*\b(main|master)\b'; then
echo "Blocked: direct push to main/master is not allowed from Claude Code." >&2
exit 2
fi
if echo "$COMMAND" | grep -qE 'git[[:space:]]+reset[[:space:]]+--hard'; then
echo "Blocked: git reset --hard is not allowed from Claude Code." >&2
exit 2
fi
if echo "$COMMAND" | grep -qE 'git[[:space:]]+clean[[:space:]].*-fd'; then
echo "Blocked: git clean -fd is not allowed from Claude Code." >&2
exit 2
fi
exit 0{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git *)",
"command": "bash ~/.claude/hooks/block-git-danger.sh"
}
]
}
]
}
}The if: "Bash(git *)" filter is documented permission-rule syntax. It is best-effort (Claude Code may still spawn the hook when it cannot parse nested $()). Treat this as a guardrail, not a hard security boundary. VERIFY: smoke-test against the git commands your team actually uses (git push origin HEAD:main, aliases, gh wrappers).
Protect .github/workflows and secret paths (PreToolUse)
Extend the protect-files list:
PROTECTED=(
".env"
".env.local"
"migrations/"
"secrets/"
".github/workflows/"
".github/actions/"
)Same Edit|Write matcher as hook #5. Claude can still change workflows via Bash (cat > .github/workflows/...). Add a Bash PreToolUse check for .github/workflows in the command string if that matters for your threat model.
SessionStart: branch + open PR context
#!/bin/bash
# ~/.claude/hooks/github-session-context.sh
echo "=== GitHub context ==="
echo "Branch: $(git branch --show-current 2>/dev/null || echo 'n/a')"
echo "Upstream: $(git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || echo 'none')"
if command -v gh >/dev/null 2>&1; then
echo "Open PR:"
gh pr view --json number,title,url,isDraft 2>/dev/null | jq -r '
if .number then "#\(.number) \(.title) (\(.url)) draft=\(.isDraft)"
else "none for this branch"
end
' 2>/dev/null || echo "none / gh not authenticated"
else
echo "gh CLI not installed"
fi
echo "======================"
exit 0VERIFY: run once in a repo with gh auth and an open PR. Keep output short so you do not burn context (prompt caching helps, but free context is still free context: How prompt caching actually works in Claude Code).
Stop: remind to run tests / lint before claiming done
Option A (soft): print a reminder via additionalContext so Claude sees it on Stop feedback.
Option B (hard gate): fail the Stop hook until tests pass. Exit 2 on Stop prevents Claude from stopping. Docs also support prompt/agent Stop hooks that return { "ok": false, "reason": "..." }. Cap note: Claude Code overrides a Stop hook after enough consecutive blocks without progress (stop_hook_active / CLAUDE_CODE_STOP_HOOK_BLOCK_CAP).
#!/bin/bash
# ~/.claude/hooks/stop-tests-gate.sh
# VERIFY in your repo before relying on this in production sessions.
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active // false')" = "true" ]; then
exit 0
fi
if [ -f package.json ]; then
npm test --silent
STATUS=$?
if [ $STATUS -ne 0 ]; then
echo "Tests failed. Fix failing tests before stopping." >&2
exit 2
fi
fi
exit 0Use this only when the test suite is fast. Slow suites belong in CI, not in every Stop.
Optional: gh pr create / status check reminders
A Stop or Notification hook can run gh pr checks when a PR exists and echo failing checks into additionalContext. Keep it observational unless you intentionally want exit 2 to force more work.
MCP GitHub tools
If you use a GitHub MCP server, tool names look like mcp__github__search_repositories. Match all tools from that server with mcp__github__.* (the .* is required; a bare mcp__github exact matcher matches nothing). Plugin-bundled servers use scoped names like mcp__plugin_<plugin>_<server>__<tool>. VERIFY the exact server segment in your config before you copy a matcher.
Experiments: PreToolUse deny vs allow (piped JSON)
I cannot drive a live Claude Code session from this draft box, but the scripts above accept the same stdin JSON Claude Code would send. Piped results from Sep 30, 2026:
PreToolUse deny (rm -rf /):
Blocked: command matches dangerous pattern 'rm -rf /'
exit=2PreToolUse allow (ls -la):
exit=0JSON deny (permissionDecision):
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command blocked by hook"
}
}Git force-push deny:
Blocked: force-push is not allowed from Claude Code. Push manually if you intend it.
exit=2Protect .github/workflows/ci.yml:
Blocked: /repo/.github/workflows/ci.yml is protected. Modify this file manually if needed.
exit=2VERIFY in a live Claude Code session: ask Claude to rm -rf /tmp/something-safe-to-delete after installing the blocker, confirm the tool is blocked in the transcript, and confirm /hooks lists the handler. PostToolUse cannot undo a tool that already ran; prove that by logging tool_name after a Write and noting the file already exists.
Recipe:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | bash ~/.claude/hooks/block-dangerous-commands.sh
echo $?Configuration and scopes
Claude Code snapshots hook configuration at startup for many flows. Direct edits are often picked up by a file watcher, but if /hooks does not show your change after a few seconds, restart the session. This trips people up the first time.
Where hooks live:
~/.claude/settings.json: global, every project.claude/settings.json: project-level, commit to git, team shares them.claude/settings.local.json: project-level, gitignored when Claude Code writes thereAlso: managed policy settings, plugin
hooks/hooks.json, skill/subagent frontmatter
Put the dangerous command blocker and desktop notifications in global settings. Put format-on-Stop and file protection in project settings so each repo can customize patterns.
Type /hooks inside Claude Code for a read-only browser of configured hooks (event, matcher, type, source file). To add or edit, change the JSON (or ask Claude to edit it). The menu itself does not write.
Common Claude Code hooks mistakes
Stdout vs stderr
On exit 2, Claude Code feeds stderr back as the blocking message. On exit 0, plain stdout becomes Claude context only for a few events (SessionStart, UserPromptSubmit, UserPromptExpansion, PostModelSwitch). Writing echo "blocked" instead of echo "blocked" >&2 is the classic miss. Use Ctrl+O / debug logging when output is not where you expect.
The formatter context problem
PostToolUse formatters inject change reminders every edit. Format on Stop.
Exit 2 is not exit 1
Exit 1 is non-blocking for most events. Only exit 2 blocks through the exit code alone (worktree events are stricter: any nonzero can fail create/remove). Use exit 2 for policy gates.
Hook timeouts and async
Do not assume one universal 600-second timeout. Defaults vary by event and handler type. Slow hooks pause the turn unless you set "async": true (or move the work out of the hot path).
Testing hooks
Pipe sample JSON. Confirm exit codes before you trust the hook in a real session.
Frequently asked questions about Claude Code hooks
What are Claude Code hooks?
Hooks are user-defined handlers (shell commands, HTTP endpoints, MCP tools, prompts, or agents) that run at lifecycle events during a Claude Code session. As of the Sep 2026 docs snapshot, there are 33 named events, from SessionStart through SessionEnd. They give you deterministic control. CLAUDE.md is still worth writing; it is just not a guarantee.
How do I add hooks to Claude Code?
Edit the hooks key in a settings file. Global: ~/.claude/settings.json. Project: .claude/settings.json. Each handler needs a type (command, http, mcp_tool, prompt, or agent), plus the command/URL/server fields for that type, and optionally a matcher and if filter. Confirm with /hooks. Restart if the watcher does not pick up the edit.
Are Claude Code hooks global or project-specific?
Both. You can stack user, project, local, managed, plugin, and frontmatter hooks. Entries merge across levels rather than fully replacing each other.
What's the difference between PreToolUse and PostToolUse?
PreToolUse runs before the tool. Exit 2 or permissionDecision: "deny" can stop it. PostToolUse runs after success. You cannot undo the tool. Exit 2 on PostToolUse shows stderr to Claude for feedback only.
Do hooks slow down Claude Code?
Synchronous hooks block until they finish. Keep them fast. Use "async": true for logging or notifications that should not stall the loop. Prompt and agent hooks add model latency by design.
Hooks vs CLAUDE.md vs rules vs permissions?
CLAUDE.md and .claude/rules are instructions Claude should follow. Permissions allow/deny tools at the product layer. Hooks are executable policy and automation at lifecycle points. Use CLAUDE.md for conventions, permissions for coarse allow/deny, hooks when you need a scripted guarantee (block rm -rf, inject branch context, format on Stop).
Where do Claude Code hooks live?
~/.claude/settings.json, .claude/settings.json, .claude/settings.local.json, managed policy settings, plugin hooks/hooks.json, and skill/subagent frontmatter. /hooks shows which source each handler came from.
How do I test a Claude Code hook?
Pipe JSON to the script and check $?. Then trigger the matching action inside Claude Code and confirm the transcript / /hooks behavior. See the Experiments section above.
Can Claude Code hooks block Bash and file writes?
Yes. PreToolUse with matcher Bash (and optional if: "Bash(...)") can block shell. PreToolUse with Edit|Write can block file tools via path checks. Exit 2 or JSON permissionDecision: "deny". PermissionRequest uses a different JSON shape (decision.behavior) and ignores exit 2.
What is the Notification idle_prompt hook?
Notification with matcher idle_prompt fires when Claude finished responding about 60 seconds ago and you have not typed since. Useful when Stop notifications feel too noisy.
Do hooks work with MCP tools?
Yes. MCP tools show up on tool events with names like mcp__<server>__<tool>. Match with regex, for example mcp__github__.* or mcp__.*__write.*. You can also use handler type mcp_tool to call an MCP tool from a hook. SessionStart at launch and Setup may skip mcp_tool handlers because MCP clients are not ready yet; use command hooks for launch-time work. VERIFY your server naming (especially plugin-scoped servers).
What to install first
Start with the dangerous command blocker and desktop notification. Those two cover the most ground for the least setup. Add file protection for sensitive paths. Add the GitHub git-danger blocker if Claude pushes from your machine. Add SessionStart context if you keep re-explaining the branch. Keep format-on-Stop instead of PostToolUse Prettier if you care about context noise.
The official hooks reference has the full schemas. The hooks guide has copy-paste starters. Use both when your Claude Code version disagrees with this page.
I write about Claude Code internals every week: context windows, hooks, MCPs, how things actually work.
