Claude Code Hooks 101
CLAUDE.md is a suggestion. Claude reads it and follows it about 80% of the time. Hooks are different. They’re shell commands that fire automatically every time Claude does something, regardless of what the prompt says.
Two hook types cover most workflows:
PreToolUseruns before the tool executes. Exit code2blocks the action and sends your error message back to Claude so it can try a safer approach. Think of it as a bouncer.PostToolUseruns after the tool completes. Good for formatting, linting, logging. Think of it as quality control on the assembly line.
Hooks live in one of 3 places:
.claude/settings.json # project-level, committed to git
~/.claude/settings.json # user-level, all projects
.claude/settings.local.json # local only, not committedBlock dangerous commands
Claude is powerful enough to run rm -rf, git reset --hard, or curl … | bash. It probably won’t, but “probably” isn’t good enough.
#!/usr/bin/env bash
set -euo pipefail
cmd=$(jq -r '.tool_input.command // ""')
dangerous_patterns=(
"rm -rf"
"git reset --hard"
"git push.*--force"
"DROP TABLE"
"DROP DATABASE"
"curl.*|.*sh"
"wget.*|.*bash"
)
for pattern in "${dangerous_patterns[@]}"; do
if echo "$cmd" | grep -qiE "$pattern"; then
echo "Blocked: '$cmd' matches dangerous pattern '$pattern'. Propose a safer alternative." >&2
exit 2
fi
done
exit 0Exit code 2 is the key. It cancels the action and the error message goes back to Claude so it knows to try something safer. Exit 0 means go ahead. Anything else logs a warning but doesn’t block.
Log every command
Appends every Bash command Claude runs to a timestamped audit trail.
#!/usr/bin/env bash
set -euo pipefail
cmd=$(jq -r '.tool_input.command // ""')
printf '%s %s\n' "$(date -Is)" "$cmd" >> .claude/command-log.txt
exit 0Add .claude/command-log.txt to .gitignore. When something breaks, you have an exact record of what ran and when.
Auto-format and lint after every edit
Rather than telling Claude “always run Prettier” in CLAUDE.md and hoping it remembers, a PostToolUse hook makes it unconditional.
The hook receives the tool call as JSON on stdin, so jq can pull out the file path:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": ".claude/hooks/block-dangerous.sh" },
{ "type": "command", "command": ".claude/hooks/log-commands.sh" }
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null; exit 0"
},
{
"type": "command",
"command": "npx eslint --fix $(jq -r '.tool_input.file_path') 2>&1 | tail -10; exit 0"
},
{
"type": "command",
"command": "npm run test --silent 2>&1 | tail -5; exit 0"
}
]
}
]
}
}Prettier runs first, then ESLint with --fix, then the test suite. The tail -5 keeps test output short so Claude sees “3 tests failed” rather than 200 lines. The ; exit 0 on all three means failures are reported but never block the session. By the time you look at the code, it’s formatted, lint-clean, and you already know if tests are passing.