Hooks are shell commands that Claude Code runs at fixed points: before a tool runs, after it runs, when Claude stops, and so on. Unlike instructions in a prompt or CLAUDE.md, hooks run every time, so they're the right tool for anything that must always happen.
Hooks live in a settings.json file: ~/.claude/settings.json for you, or .claude/settings.json in a repository to share with your team. Each hook subscribes to an event and, optionally, a matcher that filters by tool name:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard.py" }
]
}
]
}
}
tool_name, tool_input (for Bash, tool_input.command; for edits, tool_input.file_path), cwd and hook_event_name.PreToolUse), and whatever you print to stderr is fed back to Claude as the reason."Edit|Write". Leave the matcher out to match everything.$CLAUDE_PROJECT_DIR points at the project root, so hook paths work from any subdirectory.Useful events include PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop and SessionStart. Run /hooks inside Claude Code to see what's active.
The single most valuable hook. This minimal version blocks recursive deletes of your home or root directory, and force-pushes to main:
#!/usr/bin/env python3
# .claude/hooks/guard.py: PreToolUse, matcher "Bash"
import json, re, sys
data = json.load(sys.stdin)
cmd = data.get("tool_input", {}).get("command", "")
RULES = [
(r"\brm\s+-[a-zA-Z]*[rR][a-zA-Z]*\s+(~/?|/|\$HOME/?)(\s|$)", "recursive delete of home or root"),
(r"\bgit\s+push\b.*(--force(?!-with-lease)|\s-f\b).*[\s:+](main|master)(\s|$)", "force-push to main"),
]
for pattern, reason in RULES:
if re.search(pattern, cmd):
print(f"Blocked: {reason}. Ask the user to run it themselves.", file=sys.stderr)
sys.exit(2)
sys.exit(0)
Test it without Claude by piping JSON in:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf ~/"}}' | python3 .claude/hooks/guard.py; echo "exit=$?"
# Blocked: recursive delete of home or root. ...
# exit=2
Regexes like these are harder than they look. rm -rf ./build must pass while rm -rf ~/ is blocked, and --force-with-lease should be allowed. Always test both what should be blocked and what should pass. See the full guide to blocking dangerous commands, or build and test your own rule set with the free hook builder.
#!/usr/bin/env python3
# PreToolUse, matcher "Edit|Write|MultiEdit"
import fnmatch, json, os, sys
path = json.load(sys.stdin).get("tool_input", {}).get("file_path", "")
name = os.path.basename(path)
if name != ".env.example" and any(fnmatch.fnmatch(name, p) for p in
[".env", ".env.*", "*.pem", "package-lock.json", "pnpm-lock.yaml", "yarn.lock"]):
print(f"Blocked: {path} is protected. Lockfiles change via the package manager.", file=sys.stderr)
sys.exit(2)
A PostToolUse hook with matcher "Edit|Write|MultiEdit" runs after the file is written, which is the right moment to format. Keep it non-blocking by always exiting 0:
#!/usr/bin/env bash
f=$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("tool_input",{}).get("file_path",""))')
case "$f" in
*.ts|*.tsx|*.js|*.jsx|*.css|*.json) npx --no-install prettier --write "$f" >/dev/null 2>&1 ;;
*.py) ruff format -q "$f" 2>/dev/null ;;
*.go) gofmt -w "$f" ;;
esac
exit 0
npx --no-install matters: without it, a project that doesn't use Prettier would download it on every edit.
Match Bash in PreToolUse. If the command is a git commit, run git diff --cached and search the added lines for key patterns: AKIA[0-9A-Z]{16} (AWS), ghp_… (GitHub), sk_live_… (Stripe), -----BEGIN … PRIVATE KEY-----. Exit 2 with the file names (never the full secret) so Claude unstages them and moves them to environment variables.
"Notification": [
{ "hooks": [ { "type": "command",
"command": "osascript -e 'display notification \"Claude needs your input\" with title \"Claude Code\"'" } ] }
]
On Linux, use notify-send "Claude Code" "Needs your input". Add the same thing under Stop to be told when a long task finishes.
For SessionStart and UserPromptSubmit, text a hook prints to stdout is added to Claude's context. That makes it useful for injecting the current branch, open ticket or today's on-call notes:
"SessionStart": [
{ "hooks": [ { "type": "command", "command": "echo \"Branch: $(git branch --show-current)\"" } ] }
]
"$CLAUDE_PROJECT_DIR", because Claude may cd into subdirectories.Keelwork bundles 10 workflow skills, 5 tested safety hooks (including a full guard-bash and a secret scanner), 3 subagents and 5 CLAUDE.md templates, with a one-command installer that safely merges into your settings.
Get Keelwork — $24 →