← All guides

Claude Code Hooks Deep Dive: 8 Real Patterns Beyond the Default Examples

Claude Code hook system explained — 8 production-tested patterns: pre-commit blocking, post-tool-use logging, prompt rewriting, security gates, and how to debug them.

Claude Code hooks intercept tool calls and lifecycle events so you can run shell scripts before or after the agent acts. Six event types fire at predictable moments — PreToolUse, PostToolUse, SessionStart, Stop, UserPromptSubmit, and Notification — each configured in settings.json and each respecting exit-code semantics (exit 0 = continue, exit 2 = block with stderr fed back to Claude). Below are eight production-tested patterns that go well beyond the bash-formatter examples in the official docs: destructive-command blocking, auto-formatting, prompt rewriting, cost guardrails, test triggers, git stash safety nets, and richer session bootstrap.

The 6 Hook Event Types

Hooks live in ~/.claude/settings.json (user) or .claude/settings.json (project). Each event accepts a matcher (tool name pattern) and a list of commands to run.

Event Fires when Exit-code 2 effect
PreToolUse Before any tool call Cancels the tool call; stderr returned to Claude
PostToolUse After tool call succeeds Output appended as context (cannot undo)
UserPromptSubmit After you press enter, before Claude sees prompt Replaces or augments the prompt
SessionStart New session or claude --resume Output injected as initial context
Stop Claude finishes responding Output shown but does not block
Notification Claude is idle, awaiting input Side-effect only (sound, banner, etc.)

The hook receives JSON on stdin (tool name, args, working directory, transcript path). Stdout becomes additional context for Pre/Post/SessionStart. Anything you echo to stderr with exit code 2 in PreToolUse halts the call — that is your security gate.

Two subtleties trip people up. First, exit code 1 is not a block — it is treated as a soft failure and Claude proceeds anyway, which is rarely what you want. Always use exit 2 for hard blocks. Second, PostToolUse cannot undo the action that already happened; if you need to prevent damage, your gate must live in PreToolUse. The Post event is for observation, formatting, logging, and feedback. Treat the two halves as different tools with different powers.

Pattern 1 — Block Destructive Bash

The cheapest insurance you will buy. Match Bash, scan the command for ruinous patterns, exit 2 if matched.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/block-destructive.sh" }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
# ~/.claude/hooks/block-destructive.sh
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // ""')

if echo "$cmd" | grep -qE '(rm -rf /|rm -rf ~|dropdb|DROP DATABASE|git push.*--force.*(main|master)|:>.*\.env)'; then
  echo "Blocked: destructive pattern detected in: $cmd" >&2
  exit 2
fi
exit 0

Force-push to main, rm -rf /, dropping a database, truncating a .env — all stopped before the shell ever sees them. The agent receives the stderr message as feedback and will usually rephrase or ask you what to do, instead of silently corrupting state.

Extend the regex over time. Whenever you have a near-miss, add the pattern: chmod 777 -R, git checkout -- ., find ... -delete, psql -c TRUNCATE, aws s3 rm --recursive. The list grows, but each entry is a paper-cut you will never repeat. Treat it like a personal .gitignore for shell history.

Pattern 2 — Auto-Format on File Write

Run prettier, black, or gofmt right after Claude writes a file. Non-blocking; just keeps the tree clean.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/format-on-write.sh" }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path // empty')
[[ -z "$file" ]] && exit 0

case "$file" in
  *.ts|*.tsx|*.js|*.jsx|*.json|*.md) prettier --write "$file" 2>/dev/null ;;
  *.py) black -q "$file" 2>/dev/null ;;
  *.go) gofmt -w "$file" 2>/dev/null ;;
  *.rs) rustfmt --edition 2021 "$file" 2>/dev/null ;;
  *.sh) shfmt -w -i 2 "$file" 2>/dev/null ;;
esac
exit 0

Two reasons to prefer this over a pre-commit hook. You see the formatted result immediately, so formatter conflicts surface in the same turn. And Claude sees its own code in canonical style on subsequent reads, so it mimics that style on later edits — style drift quietly disappears.

Pattern 3 — Append Context to User Prompts

UserPromptSubmit is underused. You can inject reminders Claude will treat as part of your message — useful for enforcing project conventions like "always answer in Korean" or "respect the 80/15/5 cost rule."

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          { "type": "command", "command": "echo '\\n\\n[reminder] Use Haiku for batch tasks, Sonnet for code, Opus only for strategy. Honor the prompt cache by keeping system prompt static.'" }
        ]
      }
    ]
  }
}

The string is appended every turn — keep it short, every token ships on every request. We use this for forcing language, budget discipline, and reminders too dynamic for CLAUDE.md. An advanced version reads from a file so you can edit it without restarting: command: "cat ~/.claude/prompt-suffix.txt 2>/dev/null".

Pattern 4 — Notify on Long-Running Commands

Notification fires when Claude is idle. Pair it with osascript and say so you can step away from the terminal.

{
  "hooks": {
    "Notification": [
      {
        "hooks": [
          { "type": "command", "command": "osascript -e 'display notification \"Claude is waiting\" with title \"Claude Code\"' && say -v Samantha 'ready'" }
        ]
      }
    ]
  }
}

For Linux, swap in notify-send and spd-say. On Windows WSL, powershell.exe -c plus New-BurntToastNotification. The voice clue is the killer feature on macOS — your laptop literally calls you back to the keyboard when Claude needs input, which makes long autonomous runs practical without screen-staring.


Mid-article tangent: if hooks are saving you tool calls and you want the same discipline applied to your API spend, the Cost Optimization Masterclass walks through prompt-cache budgeting, model routing, and the 80/15/5 rule we use in our own automation stack. It pairs naturally with hook-based guardrails.


Pattern 5 — Cost Guardrail in PostToolUse

Claude Code writes a transcript JSONL containing token usage per turn. A Stop hook can tally cost and warn when a session crosses a budget.

#!/usr/bin/env bash
# ~/.claude/hooks/cost-guard.sh
input=$(cat)
transcript=$(echo "$input" | jq -r '.transcript_path')
[[ -f "$transcript" ]] || exit 0

# Sonnet 4.5 pricing as a rough proxy: $3/M input, $15/M output
total=$(jq -s '
  map(select(.message.usage)) |
  map(.message.usage.input_tokens * 0.000003 + .message.usage.output_tokens * 0.000015) |
  add // 0
' "$transcript")

awk -v t="$total" 'BEGIN { if (t+0 > 0.50) exit 1 }' \
  && exit 0 \
  || { echo "[cost-guard] session crossed \$0.50: \$$total" >&2; exit 0; }

This does not block — it just prints to stderr so you see the warning. Tighten the threshold to taste. A stricter version exits 2 inside PreToolUse to refuse new tool calls once the budget is blown, but expect to argue with Claude about why it cannot continue. We prefer the soft warning + manual stop.

Pair this with prompt caching to amortize the cost across turns: a static system prompt re-used over a long session pays for itself once the cache hits compound. We covered the break-even math separately, but the short version is "1.28 reuses of a cached block beats one fresh write."

Pattern 6 — Test Runner After Every Edit

Tighter than CI: run the affected test file the moment Claude edits source.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/test-on-edit.sh" }
        ]
      }
    ]
  }
}

Inside the script, map src/foo.ts to src/foo.test.ts (or tests/test_foo.py) and run only that file. Pipe the output back via stdout — Claude reads it as context and self-corrects on red tests.

The trick is scoping. Running the whole test suite on every edit is too slow and the feedback gets lost. Running the single related file is fast (sub-second for unit tests) and gives Claude a tight loop: edit, observe failure, edit again. We have seen Claude burn down a list of failing tests in three turns because each turn ended with the failing assertion already in its context window.

Pattern 7 — Git Stash Before Risky Operations

When Claude is about to run git rebase, git reset, or a migration, stash uncommitted work first.

#!/usr/bin/env bash
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // ""')

if echo "$cmd" | grep -qE '(git rebase|git reset --hard|alembic upgrade|prisma migrate deploy)'; then
  if ! git diff --quiet || ! git diff --cached --quiet; then
    git stash push -u -m "auto-stash before risky op @ $(date -Iseconds)"
    echo "[hook] stashed uncommitted changes before: $cmd" >&2
  fi
fi
exit 0

Recover with git stash list afterwards. Cheap insurance against an over-eager rebase. The pattern generalizes: any time a destructive command can be preceded by a checkpoint, do the checkpoint in a hook instead of asking Claude to remember. Database migrations? Snapshot the schema. Production deploy? Tag the previous commit. Hooks are the right place for "always do X before Y" because Claude will forget, eventually.

Pattern 8 — SessionStart Context Loading

The SessionStart event injects whatever you echo as initial context. Load the recent commits, branch state, and any TODO file so Claude does not have to ask.

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          { "type": "command", "command": "git status -sb && echo --- && git log --oneline -5 && echo --- && [ -f TODOS.md ] && head -30 TODOS.md" }
        ]
      }
    ]
  }
}

Now every fresh session opens with current branch, dirty files, last five commits, and your top open items already in context. This is the single highest-ROI hook in the list. Without it, the first three or four turns of a fresh session are Claude asking "what was I working on?" and you re-explaining state that the filesystem already knows.

You can layer more: open PRs (gh pr list --json title,number), today's agenda from a calendar CLI, the output of npm outdated to flag stale deps. Each line is a token, so be selective — a 200-token bootstrap is great, a 4,000-token bootstrap means every session starts with cold cache pressure.

Hook Debugging

Performance: Keep Hooks Under 500ms

Every hook blocks the agent loop. A 2-second formatter on every edit will halve your effective speed. Rules of thumb:

Profile with time wrapped around your hook script. If a formatter takes too long, run it in the background and let the next edit pick up the result.

The other failure mode is too many hooks. Five PostToolUse hooks for Edit — format, test, lint, type-check, log — will feel sluggish. Combine them into a single dispatcher script that runs each step with a timeout. One place to trace, one place to short-circuit.

Frequently Asked Questions

Can hooks call APIs?

Yes — they are arbitrary shell. We have hooks that POST to a Supabase table for an audit log, call OpenAI for a "is this commit message clear?" lint, and ping a webhook on Stop. Watch latency, mind secrets (read from ~/.claude/.env, not hard-coded), and never block on a flaky third party in PreToolUse.

How do I share hooks across machines?

Keep ~/.claude/settings.json and ~/.claude/hooks/ in a git repo and symlink. We have a dotfiles/claude/ directory; a fresh machine gets fully wired in one stow command. For team-wide hooks, commit .claude/settings.json at the project root — it is loaded automatically and merges with the user file.

Hooks vs slash commands — which to use?

Slash commands are explicit (you type /review). Hooks are automatic (they fire on events). Use hooks for anything that should always happen — formatting, security gates, notifications. Use slash commands for opt-in workflows. If you find yourself typing the same slash command after every edit, that is a hook.

Can I block specific Claude tools entirely?

Yes — match the tool name and exit 2 unconditionally. To disable web fetching, for example: { "matcher": "WebFetch", "hooks": [{ "type": "command", "command": "echo 'WebFetch disabled by policy' >&2; exit 2" }] }. The cleaner alternative is the permissions.deny array in settings.json, but a hook lets you allow conditionally (e.g., domain-allowlist).

Why is my hook not firing?

Three usual suspects, in order: (1) JSON syntax error in settings.json — run jq . ~/.claude/settings.json to validate; (2) matcher pattern wrong — Claude tools are Bash, Edit, Write, MultiEdit, Read, Glob, Grep, WebFetch, Task, etc., case-sensitive; (3) script not executable — chmod +x ~/.claude/hooks/*.sh. Run claude --debug and look for the hook: lines.

Where to Go Next

Hooks are leverage. They turn one-time prompt instructions ("don't force-push, please?") into permanent system behavior. Pair them with a curated CLAUDE.md and a tight permissions list and you have a workspace that defends itself.

For the broader picture, see the Claude Code complete guide for the full feature surface, and how to build a custom Claude Code skill for packaging multi-step workflows that hooks alone cannot express.

AI Disclosure: Drafted with Claude Code; hooks tested against Claude Code v2.1 settings.json on macOS.

Tools and references