TL;DR
Claude Code hooks let you attach shell commands, HTTP endpoints, or even AI prompts to lifecycle events: file edits, tool calls, session starts. They run deterministically, every time, unlike instructions Claude might interpret loosely. This tutorial walks through five hooks I actually use in production: an auto-formatter, a destructive-command blocker, an AI-powered lint gate, an automatic test runner, and a session-start context injector. Every hook includes the full configuration JSON and the backing script.
Why Hooks Changed How I Use Claude Code
I spent my first month with Claude Code adding the same reminders to every prompt. “Run prettier after editing.” “Don’t delete the build directory.” “Check the tests before you say you’re done.” Claude followed these instructions maybe 80% of the time. Good enough for casual use, maddening for a codebase where one missed format check means a failed CI pipeline.
Hooks fixed that problem entirely. Instead of hoping Claude remembers to format a file, a PostToolUse hook fires prettier --write on every edit. Instead of trusting that Claude won’t run rm -rf /, a PreToolUse hook intercepts the command and blocks it before execution. The LLM doesn’t get a vote. The hook runs every time, the same way, with zero prompt engineering required.
The shift from “Claude, please remember to do X” to “X happens automatically, always” was the biggest reliability improvement I made to my Claude Code workflow all year. These are the five hooks I rely on every day.
How Claude Code Hooks Work
A hook is a handler (a shell command, HTTP call, or AI prompt) that fires at a specific point in Claude Code’s lifecycle. If you’ve been comparing terminal coding agents, hooks are one of the features that set Claude Code apart from the competition. You configure hooks in your settings file (project-scoped, user-scoped, or local), and Claude Code runs them deterministically whenever the matching event occurs.
Lifecycle Events
Claude Code exposes 30 lifecycle events. The ones you’ll use most:
| Event | When It Fires | Can Block? |
|---|---|---|
PreToolUse | Before Claude calls a tool (Bash, Edit, Write, etc.) | Yes |
PostToolUse | After a tool call completes | Yes |
UserPromptSubmit | When you press Enter on a prompt | Yes |
Stop | When Claude finishes a response | Yes |
SessionStart | When a session begins or resumes | No |
The “Can Block?” column is what makes hooks useful. A PreToolUse hook that exits with code 2 blocks the tool call entirely and feeds the error message back to Claude so it can adjust.
Handler Types
Five handler types exist, each suited to different complexity levels:
Command hooks run a shell command. They receive the event’s JSON payload on stdin and communicate results through exit codes and stdout. You’ll use these 90% of the time.
HTTP hooks POST the event JSON to a URL, which works well for integrating with external services or shared team tooling.
MCP tool hooks call a tool on a connected MCP server, bridging hooks into your existing MCP integrations without custom scripts.
For validation that needs judgment rather than pattern matching, prompt hooks send a text prompt to a fast Claude model (Haiku by default) for single-turn evaluation. The model returns an ok: true or ok: false decision as JSON.
Agent hooks are the heaviest option: they spawn a sub-agent with access to tools like Read and Grep for multi-turn codebase verification. If you’ve used Claude Code subagents before, agent hooks follow the same principle but triggered automatically instead of on demand.
Configuration Structure
Hooks live in your settings file under the hooks key. The basic shape:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/my-script.sh"
}
]
}
]
}
}
The matcher field filters which tool calls trigger the hook. "Bash" matches only Bash tool calls. "Edit|Write" matches both. Omitting it (or using "*") matches everything. Matchers also support regex when the pattern contains characters beyond letters, digits, underscores, and pipes.
Put this in .claude/settings.json for project-scoped hooks (shared via git), ~/.claude/settings.json for user-scoped, or .claude/settings.local.json for local-only hooks that stay out of version control.
Hook 1: Auto-Format Every File Edit
Every time Claude edits or creates a file, this hook runs your formatter on it automatically. No more reviewing diffs full of inconsistent indentation, and no more manually running prettier after every session.
Configuration
Add this to .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|NotebookEdit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/auto-format.sh",
"timeout": 15
}
]
}
]
}
}
Script
Create .claude/hooks/auto-format.sh:
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat /dev/stdin)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ -z "$FILE_PATH" || ! -f "$FILE_PATH" ]]; then
exit 0
fi
EXT="${FILE_PATH##*.}"
case "$EXT" in
js|jsx|ts|tsx|json|css|md|html|yaml|yml)
npx prettier --write "$FILE_PATH" 2>/dev/null
;;
py)
python3 -m black --quiet "$FILE_PATH" 2>/dev/null
python3 -m isort --quiet "$FILE_PATH" 2>/dev/null
;;
go)
gofmt -w "$FILE_PATH" 2>/dev/null
;;
rs)
rustfmt "$FILE_PATH" 2>/dev/null
;;
esac
exit 0
chmod +x .claude/hooks/auto-format.sh
How It Works
The PostToolUse event fires after any Edit, Write, or NotebookEdit call. The hook reads the event JSON from stdin, extracts the file_path from tool_input, checks the extension, and runs the appropriate formatter. Exit code 0 means success, so Claude continues normally. If the formatter isn’t installed for a given language, the 2>/dev/null silently swallows the error.
The 15-second timeout prevents a pathological formatter from blocking the entire session. In practice, formatting a single file takes under a second.
Lesson learned: I originally set the matcher to "*". Bad idea. The hook fired on every tool call, including Bash, Read, and Grep. Reading a file doesn’t produce a file_path in the same schema position, so the hook would parse nothing and exit cleanly, but the overhead of spawning a shell process on every single tool call added visible latency. Narrowing the matcher to "Edit|Write|NotebookEdit" cut the noise completely.
Hook 2: Block Destructive Commands
Claude occasionally generates destructive commands — rm -rf, git push --force, DROP TABLE — especially when it’s debugging a stubborn test failure and gets creative. This PreToolUse hook intercepts those commands before they execute.
Add this to .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/block-destructive.sh"
}
]
}
]
}
}
Then create .claude/hooks/block-destructive.sh:
#!/usr/bin/env bash
set -euo pipefail
COMMAND=$(jq -r '.tool_input.command // ""' < /dev/stdin)
BLOCKED_PATTERNS=(
'rm -rf /'
'rm -rf ~'
'rm -rf \.'
'git push.*--force'
'git reset --hard'
'git clean -fd'
'DROP TABLE'
'DROP DATABASE'
'truncate '
'chmod -R 777'
'mkfs\.'
'dd if='
'> /dev/sd'
)
for pattern in "${BLOCKED_PATTERNS[@]}"; do
if echo "$COMMAND" | grep -qiE "$pattern"; then
jq -n --arg reason "Blocked: command matches destructive pattern '$pattern'" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: $reason
}
}'
exit 0
fi
done
exit 0
Under the Hood
Before Claude runs any Bash command, the PreToolUse event fires. The script extracts the command string, loops through a blocklist of regex patterns, and if any match, returns a JSON response with permissionDecision: "deny". Claude receives the denial reason as feedback and adjusts its approach, usually by asking you what to do instead.
The patterns are intentionally conservative. rm -rf . (with the escaped dot) catches attempts to wipe the current directory, but rm -rf ./build (a common and safe cleanup) doesn’t match because the dot is followed by a slash, not a line end. Tune the patterns to your project’s risk profile.
Why This Beats Permission Mode
Claude Code’s built-in permission system already prompts before destructive commands. But if you’re running in auto or acceptEdits mode for speed, those prompts are suppressed. This hook fires regardless of permission mode. It’s a hard gate that can’t be bypassed by configuration, only by editing the hook itself.
I run Claude in auto mode for most of my work because the constant approval prompts kill my flow (I covered the broader problem of AI agent guardrails in an earlier post). The command blocker is what makes auto mode viable. I’ve watched Claude attempt git push --force three times in one session while trying to fix a rebase gone wrong. Each time, the hook caught it, Claude saw the denial reason, and it switched to a safer approach without me lifting a finger. Auto mode is fast but reckless without the hook catching the worst commands.
Hook 3: AI-Powered Code Quality Gate
Some validations can’t be captured in a regex. “Does this code follow our error handling conventions?” or “Is this SQL query safe from injection?” require judgment. Prompt hooks delegate that judgment to a fast Claude model.
Add this to your settings:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "prompt",
"prompt": "Review the code change in the JSON input below. Check for: 1) SQL injection via string concatenation, 2) hardcoded secrets or API keys, 3) unvalidated user input passed to shell commands, 4) debug/console.log statements left in production code. If you find any issue, respond with {\"ok\": false, \"reason\": \"<specific issue>\"}. If the code is clean, respond with {\"ok\": true}.",
"timeout": 15
}
]
}
]
}
}
The Execution Flow
After every Edit or Write, Claude Code sends the tool call details to a separate Haiku model instance. That model reads the code change, checks for the four categories listed in the prompt, and returns a structured JSON decision. If it finds a hardcoded API key or a SQL injection pattern, the ok: false response stops Claude and surfaces the reason.
Fifteen seconds sounds generous, but prompt hooks hit an LLM endpoint, so they’re inherently slower than a shell script. Haiku typically responds in 1-3 seconds, but network hiccups happen. Fifteen seconds is generous enough to survive a slow response without blocking the session indefinitely.
The tradeoff: prompt hooks add latency to every matching tool call. On a session where Claude edits 30 files, that’s 30 additional LLM calls at 1-3 seconds each, up to 90 seconds of cumulative overhead. I run this hook only on projects with strict security requirements (anything touching user data or payment flows). For a personal blog or a side project, the auto-formatter and command blocker are enough.
You can reduce the blast radius by scoping the matcher. The if field accepts a single permission rule, so to cover multiple tools you define separate hook handlers:
{
"hooks": [
{
"type": "prompt",
"prompt": "...",
"if": "Write(src/api/**)"
},
{
"type": "prompt",
"prompt": "...",
"if": "Edit(src/api/**)"
}
]
}
This restricts the AI quality gate to files under src/api/, leaving everything else untouched.
Hook 4: Auto-Run Tests After Changes
The “verify your work” hook. After Claude edits a source file, this hook runs the related tests automatically and feeds the results back as context. Configuration:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/auto-test.sh",
"timeout": 120
}
]
}
]
}
}
Create .claude/hooks/auto-test.sh:
#!/usr/bin/env bash
set -uo pipefail
INPUT=$(cat /dev/stdin)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ -z "$FILE_PATH" || ! -f "$FILE_PATH" ]]; then
exit 0
fi
# Skip non-source files
case "$FILE_PATH" in
*.md|*.json|*.yaml|*.yml|*.toml|*.txt|*.css|*.html)
exit 0
;;
esac
# Detect test framework and run
if [[ -f "pyproject.toml" || -f "pytest.ini" || -f "setup.cfg" ]]; then
# Python: run pytest on the changed file's test
DIR=$(dirname "$FILE_PATH")
BASE=$(basename "$FILE_PATH" .py)
TEST_FILE="$DIR/test_${BASE}.py"
if [[ -f "$TEST_FILE" ]]; then
OUTPUT=$(python3 -m pytest "$TEST_FILE" --tb=short -q 2>&1) || true
elif [[ -d "tests" ]]; then
OUTPUT=$(python3 -m pytest tests/ --tb=short -q 2>&1) || true
else
exit 0
fi
elif [[ -f "package.json" ]]; then
# JS/TS: run related tests
OUTPUT=$(npx jest --findRelatedTests "$FILE_PATH" --no-coverage 2>&1) || true
elif [[ -f "go.mod" ]]; then
# Go: run package tests
PKG_DIR=$(dirname "$FILE_PATH")
OUTPUT=$(go test "./$PKG_DIR/..." -count=1 -short 2>&1) || true
else
exit 0
fi
# Feed results back to Claude as context
if [[ -n "$OUTPUT" ]]; then
TRIMMED=$(echo "$OUTPUT" | tail -20)
jq -n --arg ctx "Test results for $FILE_PATH:\n$TRIMMED" \
'{ additionalContext: $ctx }'
fi
exit 0
How It Works
After every file edit, the hook checks whether the changed file is a source file (skipping markdown, config, etc.), detects which test framework the project uses, and runs the relevant tests. The test output gets fed back to Claude via the additionalContext field in the JSON response. Claude sees the results in its context and can react to failures immediately, without you having to ask “did the tests pass?”
The || true after each test command prevents the hook from failing when tests fail. We want test failures passed to Claude as context rather than crashing the hook. Exit code 0 with additionalContext is the right pattern: “the hook succeeded, and here’s what it found.”
I set the timeout to 120 seconds because test suites vary wildly. A Go package’s unit tests finish in under a second; a Python integration suite might take 30 seconds. If your tests are slower than two minutes per run, you probably want to narrow the scope (run only the single test file, not the entire suite).
Hook 5: Session-Start Context Injector
Every time you start or resume a Claude Code session, this hook loads project context (the current git branch, recent commits, open issues, running services) so Claude starts with situational awareness instead of a blank slate. Configuration:
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/session-context.sh",
"timeout": 10
}
]
}
]
}
}
Create .claude/hooks/session-context.sh:
#!/usr/bin/env bash
set -uo pipefail
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "not a git repo")
LAST_COMMITS=$(git log --oneline -5 2>/dev/null || echo "no commits")
DIRTY_FILES=$(git diff --name-only 2>/dev/null | head -10)
STAGED=$(git diff --cached --name-only 2>/dev/null | head -10)
# Check for running dev servers
RUNNING_PORTS=""
if command -v lsof &>/dev/null; then
RUNNING_PORTS=$(lsof -iTCP -sTCP:LISTEN -P 2>/dev/null | grep -E ':(3000|5173|8000|8080|4321)' | awk '{print $9}' | sort -u || true)
fi
# Build context
CONTEXT="Project state at session start:
Branch: $BRANCH
Last 5 commits:
$LAST_COMMITS"
if [[ -n "$DIRTY_FILES" ]]; then
CONTEXT="$CONTEXT
Uncommitted changes:
$DIRTY_FILES"
fi
if [[ -n "$STAGED" ]]; then
CONTEXT="$CONTEXT
Staged for commit:
$STAGED"
fi
if [[ -n "$RUNNING_PORTS" ]]; then
CONTEXT="$CONTEXT
Dev servers listening:
$RUNNING_PORTS"
fi
jq -n --arg ctx "$CONTEXT" '{ additionalContext: $ctx }'
What Gets Injected
Both fresh starts (startup) and resumed sessions (resume) trigger the SessionStart event. The script gathers git state, uncommitted files, and running dev servers, then returns everything as additionalContext. Claude receives this context at the very beginning of the conversation, before your first prompt.
The startup|resume matcher ensures the hook fires whether you’re starting fresh or picking up where you left off. The clear and compact subtypes are intentionally excluded. When you clear a session, you probably don’t want the old context polluting the new one.
The Payoff
Without this hook, my first message in every Claude Code session used to be “run git status and git log –oneline -5.” That’s two tool calls, two permission prompts (in default mode), and 10 seconds of overhead before I could ask my actual question. The hook handles all of that before I type a single character.
Debugging Hooks
When a hook misbehaves, start with the /hooks command. It opens an interactive browser showing every configured hook, its source file, matcher, and handler details. If your hook isn’t listed, the problem is in the configuration file path, not the script.
Next, check exit codes. Exit 0 means success (parse stdout for JSON). Exit 2 means a blocking error (stderr gets shown to the user). Exit 1 or 3-255 means a non-blocking error (first line of stderr appears in the transcript, but execution continues).
The fastest debugging cycle is to test hooks manually. Pipe a fake event JSON into your script:
echo '{"tool_input":{"command":"rm -rf /"}}' | .claude/hooks/block-destructive.sh
echo $?
If the exit code and stdout match what you expect, the hook logic is correct and any remaining issue is in the configuration (wrong matcher, wrong event, wrong file path).
Hooks vs Skills vs CLAUDE.md
Claude Code has three extension mechanisms, and picking the wrong one wastes effort:
| Mechanism | When to Use | Runs How |
|---|---|---|
| CLAUDE.md | Project conventions, coding style, preferred libraries | Read by Claude at session start; advisory |
| Skills | Reusable multi-step workflows (deploy, review, publish) | Invoked on demand via the Skill tool |
| Hooks | Enforcement, automation, validation that must always happen | Fires automatically on lifecycle events |
CLAUDE.md is guidance that Claude might follow or might not. Skills are procedures Claude invokes when you ask for them (I used a spec-driven approach with Claude Code in the spec-driven development tutorial). Hooks are the only mechanism that’s guaranteed to fire every time, regardless of what Claude decides to do.
If you’d be upset when Claude skips something, make it a hook. Everything else goes in CLAUDE.md or a skill.
FAQ
What are Claude Code hooks?
Hooks are user-defined handlers (shell commands, HTTP endpoints, or AI prompts) that fire automatically at specific points in Claude Code’s lifecycle. Unlike instructions in CLAUDE.md that Claude interprets flexibly, hooks execute deterministically every time the matching event occurs.
What lifecycle events do Claude Code hooks support?
30 events across nine categories: per-session (SessionStart, SessionEnd, Setup), per-turn (UserPromptSubmit, Stop), tool loop (PreToolUse, PostToolUse, PermissionRequest, PostToolBatch), agent/team (SubagentStart, SubagentStop, TeammateIdle), file/config (FileChanged, CwdChanged, ConfigChange, InstructionsLoaded), compaction (PreCompact, PostCompact), worktree (WorktreeCreate, WorktreeRemove), MCP, and display events. PreToolUse and PostToolUse are the most commonly used.
How are hooks different from skills in Claude Code?
Skills are reusable AI workflows that Claude invokes when asked (via the Skill tool). Hooks fire automatically on lifecycle events without Claude choosing to run them. Use skills for multi-step procedures; use hooks for enforcement and automation that must always happen.
Can Claude Code hooks block destructive commands?
Yes. A PreToolUse hook that exits with code 2 or returns permissionDecision: "deny" blocks the tool call entirely. Claude receives the denial reason as feedback and adjusts its approach. This works regardless of the session’s permission mode.
What is a prompt hook in Claude Code?
A prompt hook sends a text prompt to a fast Claude model (Haiku by default) for single-turn evaluation. The model returns a structured JSON decision. Prompt hooks are slower than command hooks (1-3 seconds vs milliseconds) but can make judgment calls that regex can’t, like detecting SQL injection patterns or evaluating code quality.
How do I debug Claude Code hooks?
Three approaches: use the /hooks command to verify your hook is loaded, test scripts manually by piping fake JSON to stdin (echo '{"tool_input":{...}}' | ./script.sh), and check exit codes (0 = success, 2 = blocking error, 1/3-255 = non-blocking error with stderr shown in transcript).
Sources
- Claude Code hooks reference — official documentation covering all lifecycle events, handler types, JSON schemas, and exit code semantics
- Automate workflows with hooks — Claude Code guide — Anthropic’s step-by-step tutorial with setup instructions and examples
- Claude Code Hooks: A Practical Guide to Workflow Automation — DataCamp tutorial covering common use cases
Bottom Line
Five hooks cover 90% of what I need from Claude Code automation. The auto-formatter alone saves me from reviewing formatting diffs on every PR. The destructive-command blocker has caught three rm -rf attempts in the past month (all during aggressive debugging loops). The session-context injector shaved a “git status → git log” ritual off every session start.
Start with Hook 1 (auto-format) and Hook 2 (command blocker). They take five minutes to set up and pay for themselves immediately. Add the others as your workflow demands. The full configuration files and scripts from this tutorial are ready to copy into your .claude/ directory.