· claude-code / hooks / automation
Claude Code Hooks: The Power-User Playbook
Hooks let you attach shell commands to 29 Claude Code lifecycle events — auto-format on save, block commits when tests fail, ping Slack when a long run ends. Five working recipes and the gotchas to know before you ship them.
By Ethan
2,159 words · 11 min read
Hooks are the right answer every time you catch yourself monitoring Claude and doing something after it finishes. Auto-format changed files. Block the commit if tests are red. Log token usage per session. Ping your phone when a long run ends. All of that is a few lines of shell and one settings.json entry away.
This guide assumes you already run Claude Code daily and want to stop babysitting it. If you are still evaluating whether Claude Code is worth using, start with the Claude Code vs Codex comparison.
Why hooks exist
Before hooks, the only way to run something after Claude touched a file was to either watch Claude’s output and react manually, or wire up external filesystem watches that ran independently of Claude’s actual execution flow. Both approaches are fragile.
Hooks solve three concrete problems:
- Audit trail. You want a record of every tool call, token cost, or session boundary — and you want it to happen reliably, not only when you remember to check the transcript.
- Auto-cleanup. Claude writes correct logic but does not always format to your project’s style. A
PostToolUsehook onEdit|Writecalls your formatter on every file Claude touches, so you never review a PR with mixed indentation. - Enforced guardrails. Claude can be instructed not to do things, but instructions drift. A
PreToolUsehook that blocks destructive shell patterns and returns an error message is enforced by the runtime, not by Claude’s judgment.
The current event table covers 29 lifecycle events.
Hook anatomy
Lifecycle events
All 29 events fit into five timing buckets:
| Cadence | Events |
|---|---|
| Once per session | SessionStart, SessionEnd |
| Once per turn | UserPromptSubmit, Stop, StopFailure |
| On every tool call | PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied |
| On batch completion | PostToolBatch |
| On system events | WorktreeCreate, WorktreeRemove, PreCompact, PostCompact, ConfigChange, CwdChanged, FileChanged, InstructionsLoaded, Notification, SubagentStart, SubagentStop, TaskCreated, TaskCompleted, UserPromptExpansion, Setup, TeammateIdle, Elicitation, ElicitationResult |
The events that matter most day-to-day: PreToolUse (block before execution), PostToolUse (react after), and Stop (react when Claude finishes a turn).
PostToolUse cannot undo what the tool already did. If you need to prevent an action, use PreToolUse — that is the only point where blocking has effect.
Config file locations
| File | Scope | Commit to repo? |
|---|---|---|
~/.claude/settings.json | All your projects | No |
.claude/settings.json | This project | Yes |
.claude/settings.local.json | This project | No (gitignored) |
The canonical structure inside any of those files:
{
"hooks": {
"EventName": [
{
"matcher": "ToolName|OtherTool",
"hooks": [
{
"type": "command",
"command": "path/to/script.sh",
"timeout": 60,
"async": false
}
]
}
]
}
}
matcher is a pipe-separated list of tool names — exact match, case-sensitive. matcher: "edit" never fires; it must be "Edit".
What every hook receives on stdin
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../session.jsonl",
"cwd": "/current/working/dir",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"effort": { "level": "medium" }
}
PreToolUse and PostToolUse also include tool_name and tool_input in the payload.
Exit codes and response format
| Exit code | Meaning |
|---|---|
0 | Success; stdout is parsed as JSON |
2 | Hard block; stderr is shown to Claude as an error message |
| Other | Non-blocking warning; execution continues |
When exiting 0, you can return a JSON object from stdout to influence Claude’s behavior:
{
"continue": true,
"decision": "block",
"reason": "Why action was blocked",
"systemMessage": "Warning shown in transcript"
}
Handler types
Five types ship today:
command— shell command; the one you will use 90% of the timehttp— POST to a URL; response body parsed as JSON abovemcp_tool— call a tool on a connected MCP serverprompt— single-turn LLM eval returning{"ok": true/false, "reason": "..."}agent— subagent with tool access, 60-second timeout, 50 tool turns (experimental)
Stick with command unless you have a specific reason for the others. agent hooks are powerful on paper; in practice their 60-second limit makes them fragile on anything but fast checks.
Five working recipes
1. Auto-format on every edit
Add to .claude/settings.json (project-scoped, commit it):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
Requires jq and npx prettier. Swap in black, gofmt -w, or rustfmt for other languages. The hook runs after the file is saved; Claude does not see the reformatted content unless it reads the file again.
Source: official hooks guide — auto-format section.
2. Git-commit guard (block if tests fail)
Save as .claude/hooks/guard-commit.sh, then chmod +x:
#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
if ! echo "$COMMAND" | grep -qE '^git commit'; then
exit 0
fi
echo "Running tests before commit..." >&2
if ! npm test --silent 2>&1; then
echo "Tests failed — commit blocked. Fix failures before committing." >&2
exit 2
fi
exit 0
Register in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git commit*)",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-commit.sh"
}
]
}
]
}
}
The if field (v2.1.85+) filters the hook so it only spawns when the Bash command starts with git commit, saving overhead on every other shell call. exit 2 sends the error text to Claude, which will try to fix the failing tests before retrying.
Use $CLAUDE_PROJECT_DIR rather than relative paths — hooks run with a non-interactive shell where the working directory is not guaranteed. Swap npm test --silent for pytest -q, go test ./..., or cargo test as needed.
3. Token usage tracker
Claude Code stores each session as a JSONL file at transcript_path. Lines with message.usage contain input_tokens, output_tokens, cache_creation_input_tokens, and cache_read_input_tokens. The Stop hook can read those and append a log entry.
Save as ~/.claude/hooks/cost-tracker.sh, then chmod +x:
#!/bin/bash
INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id')
TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path')
LOG_FILE="$HOME/.claude/cost-log.jsonl"
[ -f "$TRANSCRIPT" ] || exit 0
TOTALS=$(jq -c '
select(.message.usage != null)
| select(.isSidechain != true)
| select(.isApiErrorMessage != true)
| .message.usage
' "$TRANSCRIPT" 2>/dev/null | jq -sc '
{
input_tokens: (map(.input_tokens // 0) | add),
output_tokens: (map(.output_tokens // 0) | add),
cache_write_tokens: (map(.cache_creation_input_tokens // 0) | add),
cache_read_tokens: (map(.cache_read_input_tokens // 0) | add)
}
')
echo "{\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"session_id\":\"$SESSION_ID\",\"usage\":$TOTALS}" >> "$LOG_FILE"
exit 0
Register in ~/.claude/settings.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$HOME\"/.claude/hooks/cost-tracker.sh",
"async": true
}
]
}
]
}
}
async: true means Claude stops immediately without waiting for the log write. Anthropic does not expose cost figures directly in hook payloads — no usage_data or cost fields exist in any event — so this log gives you raw token counts you can multiply by current pricing separately. The fields used above (session_id, message.usage, isSidechain, isApiErrorMessage) are observed from the JSONL transcript files Claude Code writes to disk.
4. Slack ping on stop
Save as ~/.claude/hooks/notify-stop.sh, then chmod +x:
#!/bin/bash
INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id')
CWD=$(echo "$INPUT" | jq -r '.cwd')
PROJECT=$(basename "$CWD")
[ -z "$SLACK_WEBHOOK_URL" ] && exit 0
PAYLOAD=$(jq -n \
--arg text "Claude Code finished in *$PROJECT*" \
--arg session "$SESSION_ID" \
--arg cwd "$CWD" \
'{
text: $text,
blocks: [
{ type: "section", text: { type: "mrkdwn", text: $text } },
{ type: "context", elements: [
{ type: "mrkdwn", text: ("Session: `" + $session + "`") },
{ type: "mrkdwn", text: ("Dir: `" + $cwd + "`") }
]}
]
}')
curl -s -X POST -H 'Content-type: application/json' \
--data "$PAYLOAD" "$SLACK_WEBHOOK_URL" >/dev/null 2>&1
exit 0
Same Stop hook config as recipe 3, with async: true so Claude does not block on the network call. Set SLACK_WEBHOOK_URL in your shell profile, not in settings.json. For macOS desktop notifications without Slack, the official docs show osascript -e 'display notification "Claude done" with title "Claude Code"' — no external dependencies.
If you use Warp, hooks can emit OSC 9 sequences via the terminalSequence field (v2.1.141+) to trigger Warp’s native desktop notification support without a curl call.
5. Danger-word scanner (hard block)
Adapted from the official bash validator example.
Save as .claude/hooks/danger-scanner.sh, then chmod +x:
#!/bin/bash
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
[ "$TOOL" != "Bash" ] && exit 0
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
[ -z "$COMMAND" ] && exit 0
BLOCKED_PATTERNS=(
"rm -rf /"
"rm -rf ~"
"rm -rf \$HOME"
"> /dev/sd"
"dd if=.* of=/dev/sd"
"DROP DATABASE"
"DROP TABLE"
":(){ :|:& };:"
)
for pattern in "${BLOCKED_PATTERNS[@]}"; do
if echo "$COMMAND" | grep -qiE "$pattern"; then
echo "BLOCKED: Potentially destructive pattern detected: '$pattern'" >&2
exit 2
fi
done
exit 0
Register in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/danger-scanner.sh"
}
]
}
]
}
}
Write this in pure shell + jq. A Python cold start costs 200–400ms on every Bash call; a shell script runs in under 5ms. On big refactors where Claude issues dozens of Bash calls per turn, that 200ms difference adds up.
Composing hooks
Ordering and parallelism
Multiple hooks under one event run in parallel (for async) or in definition order (for synchronous). If two PreToolUse hooks both return updatedInput to rewrite tool arguments, the last one to finish wins — order is non-deterministic. Avoid having more than one hook modify the same tool’s input.
Environment variables
Claude Code sets $CLAUDE_PROJECT_DIR (project root) before spawning hooks. Your own environment variables — including secrets — are available if set before Claude Code starts. Set webhook URLs and API keys in your shell profile, not in .claude/settings.json, so they do not land in version control.
Idempotency
PostToolUse on Edit|Write fires on every file Claude touches. Write hooks to be idempotent: a formatter run twice on the same file is fine; a per-call append-to-log is not. Use Stop for per-session aggregates.
Gotchas
Shell profile contamination
When Claude Code spawns a command hook, it runs sh -c. If your ~/.bashrc or ~/.zshrc prints anything unconditionally, that output prepends to your hook’s stdout and breaks JSON parsing:
Shell ready on arm64
{"decision": "block", "reason": "Not allowed"}
Claude Code sees “Shell ready on arm64{…” and fails to parse it. Guard your profile output with an interactive-shell check:
if [[ $- == *i* ]]; then echo "Shell ready"; fi
This is the most common production bug when hooks “mysteriously stop working.”
The Stop hook infinite loop
A Stop hook that tells Claude to keep going will re-trigger Stop, which triggers the hook again. The official docs expose a stop_hook_active field in the Stop hook payload for exactly this case:
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0 # Already been told to continue once; let it stop
fi
Without this guard, any task-completion checker spins indefinitely.
Case-sensitive matchers
matcher: "edit" will never fire. Tool names are PascalCase: Edit, Write, Bash, Read. MCP tool matchers follow mcp__<server>__<tool> naming.
Timeout defaults
| Hook type | Default timeout | UserPromptSubmit override |
|---|---|---|
command, http, mcp_tool | 10 minutes | 30 seconds |
prompt | 30 seconds | — |
agent | 60 seconds | — |
Override with "timeout": N (in seconds) per hook definition. UserPromptSubmit hooks always get 30 seconds maximum, even for command type — they block Claude’s response to the user.
Security: project hooks are an attack surface
Hooks in .claude/settings.json execute arbitrary shell commands with your user permissions. CVE-2025-59536 and CVE-2026-21852 demonstrated that malicious settings.json files in cloned repos can run arbitrary code this way. Anthropic patched the auto-trust bypass, but the underlying model is unchanged: never git clone && cd into an untrusted repo while Claude Code is running. Source: CheckPoint Research advisory.
PermissionRequest skipped in headless mode
PermissionRequest does not fire in -p (non-interactive) mode. Use PreToolUse for permission decisions in CI or scripts.
Hooks vs. CLAUDE.md
When should you use a hook vs. adding an instruction to CLAUDE.md?
Use CLAUDE.md for intent: “always write tests before implementation,” “prefer functional patterns,” “do not import lodash.” These are preferences Claude can reason about and adapt to context.
Use hooks for enforcement: formatting, commit guards, cost logging, security scanners. If the consequence of Claude ignoring the instruction is anything worse than slightly wrong style, make it a hook. Instructions in CLAUDE.md are not enforced by the runtime; hooks are.
One useful pattern: put the human-readable rationale in CLAUDE.md (“we block destructive shell commands via a PreToolUse hook”) and the actual enforcement in the hook. Claude knows why it is being blocked, not just that it is.
Sources: hooks reference · hooks guide · official bash validator example · security advisory CVE-2025-59536