What if Claude Code could automatically format your files after every edit, block dangerous shell commands before they execute, and run your test suite whenever code changes — all without you lifting a finger?
That's exactly what Claude Code hooks do. Hooks are lifecycle event listeners that let you attach custom logic to specific moments in Claude Code's execution pipeline. They intercept actions at precisely the right time — before a tool runs, after it succeeds, when a session starts, or when Claude finishes responding.
Think of hooks as middleware for your AI coding assistant. They give you programmatic control over Claude's behavior without modifying Claude itself.
This guide is the definitive resource on Claude Code hooks in 2026. We'll cover all 18 hook events, four hook types, configuration locations, and five production-ready recipes.
What You'll Learn
By the end of this guide, you'll understand the complete hook system. You'll know every lifecycle event, how to configure each hook type, and how data flows through the hook pipeline — from stdin JSON input to stdout decisions.
Prerequisites
-
Claude Code installed and working — Run
claudefrom your terminal -
jq installed —
sudo apt install jqorbrew install jq - Basic terminal/shell knowledge
-
A project directory —
mkdir ~/hooks-playground && cd ~/hooks-playground
How Hooks Work: The stdin JSON Pattern
Command hooks receive event data as JSON on stdin. There is no $CLAUDE_TOOL_INPUT environment variable — that doesn't exist.
Here's what the JSON input looks like for a PreToolUse event:
{
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "ls -la /home/user/project"
},
"session_id": "abc123...",
"transcript_path": "/path/to/transcript",
"cwd": "/home/user/project",
"permission_mode": "default"
}
Reading it in bash:
#!/bin/bash
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
Output: Exit Codes and stdout JSON
| Exit Code | Meaning | Behavior |
|---|---|---|
| 0 | Allow | Proceeds normally |
| 2 | Block | For PreToolUse: blocks the call |
| Other | Error | Hook failed |
For granular control, output JSON to stdout:
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Destructive command blocked"
}
}'
All 18 Hook Events
| Event | When It Fires | Matchers? |
|---|---|---|
| SessionStart | Session begins/resumes/clears | Yes |
| InstructionsLoaded | CLAUDE.md or rules loaded | No |
| UserPromptSubmit | Before Claude processes prompt | No |
| PreToolUse | Before tool executes | Yes |
| PermissionRequest | Permission dialog appears | Yes |
| PostToolUse | After tool succeeds | Yes |
| PostToolUseFailure | After tool fails | Yes |
| Notification | Claude sends notification | Yes |
| SubagentStart | Subagent spawned | Yes |
| SubagentStop | Subagent terminates | Yes |
| Stop | Claude finishes responding | No |
| TeammateIdle | Teammate going idle | No |
| TaskCompleted | Task marked complete | No |
| ConfigChange | Config file changes | Yes |
| WorktreeCreate | Git worktree created | No |
| WorktreeRemove | Git worktree removed | No |
| PreCompact | Before compaction | Yes |
| SessionEnd | Session terminates | Yes |
Hook Configuration Structure
Three-level JSON: event → matcher group → hook handler.
{
"hooks": {
"EVENT_NAME": [
{
"matcher": "REGEX_PATTERN",
"hooks": [
{
"type": "command",
"command": "your-script.sh"
}
]
}
]
}
}
Four Hook Types
| Type | Required | Optional | Timeout |
|---|---|---|---|
| command | type, command | timeout, async, statusMessage, once | 600s |
| http | type, url | headers, allowedEnvVars, timeout | 30s |
| prompt | type, prompt | model, timeout | — |
| agent | type, prompt | model, timeout | 60s |
Your First Hook: Blocking rm -rf /
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "COMMAND=$(cat | jq -r '.tool_input.command'); if echo \"$COMMAND\" | grep -qE 'rm\\s+-rf\\s+/'; then jq -n '{hookSpecificOutput: {hookEventName: \"PreToolUse\", permissionDecision: \"deny\", permissionDecisionReason: \"Blocked: rm -rf / detected\"}}'; else exit 0; fi"
}
]
}
]
}
}
5 Production-Ready Recipes
Recipe 1: Auto-Format on File Save
#!/bin/bash
# .claude/hooks/auto-format.sh
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')
if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then exit 0; fi
EXT="${FILE##*.}"
case "$EXT" in
js|jsx|ts|tsx|json|css|md) npx prettier --write "$FILE" 2>/dev/null ;;
py) black --quiet "$FILE" 2>/dev/null ;;
go) gofmt -w "$FILE" 2>/dev/null ;;
rs) rustfmt "$FILE" 2>/dev/null ;;
esac
exit 0
Config:
{
"hooks": {
"PostToolUse": [{
"matcher": "Edit|Write|MultiEdit",
"hooks": [{
"type": "command",
"command": ".claude/hooks/auto-format.sh",
"statusMessage": "Formatting..."
}]
}]
}
}
Recipe 2: Block Dangerous Commands
#!/bin/bash
COMMAND=$(cat | jq -r '.tool_input.command // empty')
if [ -z "$COMMAND" ]; then exit 0; fi
PATTERNS=('rm\s+-rf\s+/' 'mkfs\.' 'dd\s+if=' 'chmod\s+-R\s+777\s+/' 'curl.*\|\s*bash' '>\s*/dev/sd[a-z]')
for p in "${PATTERNS[@]}"; do
if echo "$COMMAND" | grep -qE "$p"; then
jq -n --arg r "Blocked: $p detected" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
exit 0
fi
done
exit 0
Recipe 3: Auto-Run Tests (Async)
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')
if [ -z "$FILE" ]; then exit 0; fi
if ! echo "$FILE" | grep -qE '\.(js|ts|py|go|rs)$'; then exit 0; fi
if [ -f "package.json" ]; then
npx jest --findRelatedTests "$FILE" --no-coverage >> /tmp/claude-tests.txt 2>&1
elif [ -f "pyproject.toml" ]; then
python -m pytest --tb=short >> /tmp/claude-tests.txt 2>&1
fi
exit 0
Set "async": true so Claude continues working.
Recipe 4: Log All Tool Usage
#!/bin/bash
INPUT=$(cat)
mkdir -p "$HOME/.claude/logs"
TOOL=$(echo "$INPUT" | jq -r '.tool_name // "unknown"')
SESSION=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
TINPUT=$(echo "$INPUT" | jq -c '.tool_input // {}')
jq -n --arg ts "$(date -Iseconds)" --arg t "$TOOL" --arg s "$SESSION" --argjson i "$TINPUT" \
'{timestamp:$ts,tool:$t,session:$s,input:$i}' >> "$HOME/.claude/logs/tool-usage.jsonl"
exit 0
Recipe 5: Custom Notifications
#!/bin/bash
INPUT=$(cat)
EVENT=$(echo "$INPUT" | jq -r '.hook_event_name')
case "$EVENT" in
Notification) MSG=$(echo "$INPUT" | jq -r '.message // "Notification"') ;;
Stop) MSG="Claude finished responding." ;;
esac
# Desktop
command -v notify-send &>/dev/null && notify-send "Claude Code" "$MSG"
# Slack
[ -n "$SLACK_WEBHOOK_URL" ] && curl -s -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg t "$MSG" '{text:$t}')" || true
exit 0
Hook Locations
| Location | Path | Scope |
|---|---|---|
| Global | ~/.claude/settings.json |
All projects |
| Project shared | .claude/settings.json |
Team-wide |
| Project local | .claude/settings.local.json |
Your machine |
| Managed policy | Org-wide | Enterprise |
Advanced Patterns
MCP Tool Matching
{
"matcher": "mcp__database__execute_query",
"hooks": [{
"type": "command",
"command": ".claude/hooks/check-sql-safety.sh"
}]
}
The "once" Flag
{
"type": "command",
"command": "npm ls --depth=0",
"once": true,
"statusMessage": "Verifying dependencies..."
}
Security Best Practices
- Never store secrets in configs — use
allowedEnvVars - Use script files over inline commands
- Set appropriate timeouts
- Log hook actions for audit trails
- Test hooks locally first
- Use
/hooksto verify hooks loaded
FAQ
Do hooks work with MCP? Yes. MCP tools follow mcp__<server>__<tool> naming.
Can hooks modify Claude's behavior? Yes. PreToolUse blocks actions, PostToolUse triggers automation.
Exit code 0 vs 2? 0 = allow, 2 = block (PreToolUse).
How to debug? Use /hooks command. Validate JSON with python3 -c "import json; json.load(open('.claude/settings.json'))".
Originally published at serenitiesai.com
Top comments (0)