Guides

Claude Code hooks: 6 practical examples you can copy

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.

How hooks work in 60 seconds

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" }
        ]
      }
    ]
  }
}

Useful events include PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop and SessionStart. Run /hooks inside Claude Code to see what's active.

1. Block dangerous shell commands

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.

2. Protect .env files and lockfiles

#!/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)

3. Auto-format after every edit

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.

4. Scan for secrets before commits

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.

5. Desktop notification when Claude needs you

"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.

6. Add context at session start

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)\"" } ] }
]

Common mistakes

Skip the setup: get the tested versions

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 →