Claude Code Hooks: Complete Practical Guide with Real Examples (2026)
[01]Why Hooks Matter
If CLAUDE.md tells Claude what your project is, and settings.json tells it what tools it can use, Claude Code hooks tell it what must happen around every tool call. They turn the AI assistant from a helpful collaborator into a disciplined teammate.
The trick is that hooks fire deterministically — they are shell commands, not LLM prompts. When a PostToolUse hook says "run Prettier on the edited file," Prettier runs every time. The model cannot forget. The model cannot decide to skip it because the conversation is getting long. This is the only way to enforce a rule with 100% reliability in an LLM-driven workflow.
This guide is the practical companion to our .claude/ folder complete guide — that one explains the layout; this one explains the hooks subsystem in depth, with copy-pasteable shell scripts for the most common use cases.
[02]The Six Lifecycle Events
Every hook attaches to one of six events. Pick the right event and the rest is easy; pick the wrong event and you fight the system.
| Event | Fires | Best For |
|---|---|---|
SessionStart | Once when Claude Code session begins | Cache warmup, env validation, telemetry start |
UserPromptSubmit | Each time user submits a prompt | Auto-inject context (current branch, env, time) |
PreToolUse | Before any tool call (Bash, Edit, Write...) | Guards: block dangerous commands, scan for secrets |
PostToolUse | After any tool call | Cleanup: auto-format, lint, run quick tests |
Stop | When Claude finishes a response | Notifications (mac say, desktop notif) |
SessionEnd | Once when session ends | Telemetry flush, log archival |
The two you will actually use most: PreToolUse (guards) and PostToolUse (cleanup). Sections 4 and 5 cover both with real examples.
[03]Hook Configuration Anatomy
Hooks live in .claude/settings.json (team-shared) or .claude/settings.local.json (personal) under the hooks key. The structure:
{
"hooks": {
"<EventName>": [
{
"matcher": "<tool-name-regex>",
"hooks": [
{ "type": "command", "command": "<shell-command-or-script>" }
]
}
]
}
}
Three knobs:
- Event name: one of the six from the table above
- Matcher: a regex against tool names.
"Edit|Write|MultiEdit"matches any file-write tool."Bash"matches only Bash. Empty string or omitted = match everything for that event. - Command: any executable shell command. Can be inline (
"echo hello") or a path to a script (".claude/hooks/format.sh").
The JSON Protocol
Every hook receives a JSON event payload on stdin. The shape varies by event but always includes:
{
"session_id": "abc-123",
"transcript_path": "/path/to/conversation.jsonl",
"cwd": "/path/to/project",
"hook_event_name": "PostToolUse",
"tool_name": "Edit",
"tool_input": {
"file_path": "/abs/path/to/edited/file.ts",
"old_string": "...",
"new_string": "..."
},
"tool_response": {
"success": true
}
}
Read it with jq or any JSON parser. The exit code matters:
- Exit 0 = success, allow tool call to proceed (PreToolUse) or move on (PostToolUse)
- Exit 2 (PreToolUse) = block the tool call.
stderroutput is shown to Claude as the reason. - Exit non-zero (PostToolUse) = warning logged, but tool call already happened — too late to undo.
[04]PostToolUse Recipes
Three drop-in scripts for the most common cleanup needs. Save each one in .claude/hooks/, chmod +x, and reference from settings.json.
Recipe 1: Auto-format edited files
#!/usr/bin/env bash
# .claude/hooks/format.sh — runs Prettier on edited TS/JS/JSON/MD files
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
[[ -z "$FILE" || ! -f "$FILE" ]] && exit 0
case "$FILE" in
*.ts|*.tsx|*.js|*.jsx|*.json|*.md|*.css|*.html)
npx --no-install prettier --write "$FILE" 2>/dev/null
;;
esac
exit 0
Wire it up in settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [{ "type": "command", "command": ".claude/hooks/format.sh" }]
}
]
}
}
Recipe 2: Run typecheck after every edit
#!/usr/bin/env bash
# .claude/hooks/typecheck.sh — surface TS errors immediately
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Only check TS files
[[ "$FILE" != *.ts && "$FILE" != *.tsx ]] && exit 0
# Run incremental tsc; non-zero exit shows errors but doesn't undo the edit
if ! npx --no-install tsc --noEmit --incremental 2>&1 | head -20; then
echo "[typecheck hook] errors above" >&2
fi
exit 0
Trade-off: this adds 2-5 seconds per edit. Worth it if your team values fast feedback over speed of iteration.
Recipe 3: Quick test for the changed module
#!/usr/bin/env bash
# .claude/hooks/quick-test.sh — run tests for the changed file's module only
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
[[ -z "$FILE" ]] && exit 0
# Find related test file by convention: src/foo.ts → src/foo.test.ts
TEST_FILE=$(echo "$FILE" | sed 's/\.\(ts\|tsx\|js\|jsx\)$/.test.\1/')
[[ ! -f "$TEST_FILE" ]] && exit 0
npx --no-install vitest run "$TEST_FILE" --reporter=basic 2>&1 | tail -10
exit 0[05]PreToolUse Recipes (Guards)
PreToolUse is your safety net. Three guards every team should have.
Recipe 4: Block dangerous commands
#!/usr/bin/env bash
# .claude/hooks/danger-guard.sh — block irreversible Bash invocations
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# Patterns we never want to see
DANGER='(rm -rf /|rm -rf ~|rm -rf \$HOME|git reset --hard origin|git push --force.*main|git push --force.*master|DROP TABLE|TRUNCATE TABLE)'
if echo "$CMD" | grep -qE "$DANGER"; then
echo "[danger-guard] BLOCKED: command matches danger pattern: $CMD" >&2
echo "If you really need this, run it manually outside Claude Code." >&2
exit 2 # exit 2 = block the tool call
fi
exit 0
Wire up to Bash only:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": ".claude/hooks/danger-guard.sh" }]
}
]
}
}
Recipe 5: Block edits that contain secrets
#!/usr/bin/env bash
# .claude/hooks/secret-scan.sh — block writes that look like they contain secrets
INPUT=$(cat)
NEW=$(echo "$INPUT" | jq -r '.tool_input.new_string // .tool_input.content // empty')
# Common secret patterns
SECRET_RE='(sk-[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]{36}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]+PRIVATE KEY-----)'
if echo "$NEW" | grep -qE "$SECRET_RE"; then
echo "[secret-scan] BLOCKED: edit appears to contain a secret (API key, GitHub PAT, AWS key, or private key)" >&2
echo "Move the secret to .env.local (gitignored) and reference it via process.env." >&2
exit 2
fi
exit 0
Recipe 6: Block commits to protected branches
#!/usr/bin/env bash
# .claude/hooks/branch-guard.sh — block git commits on main/master
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# Only care about git commit invocations
if ! echo "$CMD" | grep -qE '^git commit'; then
exit 0
fi
BRANCH=$(git branch --show-current 2>/dev/null)
if [[ "$BRANCH" == "main" || "$BRANCH" == "master" ]]; then
echo "[branch-guard] BLOCKED: cannot commit directly to $BRANCH. Create a feature branch first: git checkout -b feature/..." >&2
exit 2
fi
exit 0
This one alone has prevented countless "oops, I committed to main" incidents. Pair it with the same regex matcher as Recipe 4.
[06]UserPromptSubmit Recipe — Auto-inject Context
Some context is too noisy to put in CLAUDE.md but valuable per-prompt: current git branch, recent commits, current time. The UserPromptSubmit hook lets you inject it into every prompt.
#!/usr/bin/env bash
# .claude/hooks/inject-context.sh — prepend useful context to user prompts
# Use stdout to add context (it gets prepended to the prompt)
echo "<context>"
echo "Current branch: $(git branch --show-current 2>/dev/null)"
echo "Recent commits:"
git log --oneline -3 2>/dev/null
echo "Current time: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "</context>"
exit 0
Wire up:
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [{ "type": "command", "command": ".claude/hooks/inject-context.sh" }]
}
]
}
}
Now every prompt arrives with branch + recent history + timestamp prepended. Claude is no longer confused about whether you're on a feature branch, no longer makes up dates, no longer asks "what was the last commit?"
Use sparingly. Each line of injected context burns tokens. Inject only what genuinely helps; resist the urge to dump everything.
[07]Debugging Hooks (and Common Pitfalls)
Hooks fail silently. The model does not see your stderr unless you exit non-zero on a PreToolUse. Here's how to debug.
Step 1: Verify the hook fires at all
Add a debug log line as the first thing the script does:
#!/usr/bin/env bash
echo "[$(date)] hook fired: $0" >> /tmp/claude-hooks.log
INPUT=$(cat)
echo "$INPUT" >> /tmp/claude-hooks.log
# ... rest of the hook ...
Then tail -f /tmp/claude-hooks.log in another terminal while you trigger the event. If nothing appears, the hook is not firing — check matcher, file path, and executable bit.
Common Pitfalls
- Forgot
chmod +x. The script needs to be executable. Add to your team's onboarding doc. - Matcher doesn't match. The matcher is a regex against tool name.
"edit"(lowercase) does not matchEdit. Use"Edit|Write|MultiEdit"exactly. - Path is relative to wrong directory. The hook runs with cwd = your project root, not
.claude/hooks/. Use absolute paths or paths from project root. - Hook silently rewrites Claude's output. Don't. Hooks should observe and gate, not modify Claude's text. If you need to enforce a style, use
PostToolUse+ format-on-edit, not output rewriting. - Hook is slow on the hot path. A 5-second hook on PostToolUse turns Claude's session into molasses. Run heavy checks (full test suite, full lint) only at SessionEnd or in CI, not on every edit.
- Hook depends on a tool that's not installed.
npx prettierassumes prettier is in node_modules. Add acommand -v prettier >/dev/null || exit 0guard so the hook degrades gracefully on machines without the tool.
[08]Frequently Asked Questions
What's the difference between PreToolUse and PostToolUse?
PreToolUse fires before the tool runs and can block it (exit 2). PostToolUse fires after the tool completes and cannot undo it. Use Pre for guards, Post for cleanup.
Can hooks read the file Claude is about to edit?
Yes — the tool_input.file_path field on PreToolUse for Edit/Write tools tells you the path. Read the file content from disk; the hook runs with full file system access.
How do I disable a hook for one session?
Move the hook config from settings.json to settings.local.json with the matcher set to something that won't match (e.g. "DISABLED"). Or comment out the entry. There's no per-session disable flag.
Do hooks run in CI / on Vercel builds?
No — hooks fire only inside Claude Code sessions. Your build pipeline never sees them. If you need the same logic in CI, write the script as a standalone command and call it from both the hook and your CI YAML.
Can a hook modify Claude's prompt before it's sent to the model?
The UserPromptSubmit hook can prepend stdout content to the prompt. Other events cannot modify the prompt. This is a deliberate design — hooks should be transparent observers, not silent rewriters.
Why doesn't my hook fire on the very first prompt of a session?
It does, but SessionStart fires before UserPromptSubmit. If you're seeing your hook miss the first prompt, check whether you accidentally attached it to SessionStart (which only fires once).
How do I test a hook without running Claude Code?
Pipe a fake JSON event to it from the command line:
echo '{"tool_name":"Edit","tool_input":{"file_path":"/tmp/test.ts"}}' | .claude/hooks/format.sh
Inspect the exit code (echo $?) and stderr to verify behavior.
Related: .claude/ folder complete guide · Codex /pet command cheat sheet · Claude Buddy Checker