0%
#claude-code#hooks#automation#tutorial#PreToolUse#PostToolUse#shell

Claude Code Hooks: Complete Practical Guide with Real Examples (2026)

Published 2026-05-0513 min read

[01]Why Hooks Matter

If CLAUDE.md tells Claude what your project is, and settings.json tells it what tools it can use, Claude Code hooks tell it what must happen around every tool call. They turn the AI assistant from a helpful collaborator into a disciplined teammate.

The trick is that hooks fire deterministically — they are shell commands, not LLM prompts. When a PostToolUse hook says "run Prettier on the edited file," Prettier runs every time. The model cannot forget. The model cannot decide to skip it because the conversation is getting long. This is the only way to enforce a rule with 100% reliability in an LLM-driven workflow.

This guide is the practical companion to our .claude/ folder complete guide — that one explains the layout; this one explains the hooks subsystem in depth, with copy-pasteable shell scripts for the most common use cases.

[02]The Six Lifecycle Events

Every hook attaches to one of six events. Pick the right event and the rest is easy; pick the wrong event and you fight the system.

EventFiresBest For
SessionStartOnce when Claude Code session beginsCache warmup, env validation, telemetry start
UserPromptSubmitEach time user submits a promptAuto-inject context (current branch, env, time)
PreToolUseBefore any tool call (Bash, Edit, Write...)Guards: block dangerous commands, scan for secrets
PostToolUseAfter any tool callCleanup: auto-format, lint, run quick tests
StopWhen Claude finishes a responseNotifications (mac say, desktop notif)
SessionEndOnce when session endsTelemetry flush, log archival

The two you will actually use most: PreToolUse (guards) and PostToolUse (cleanup). Sections 4 and 5 cover both with real examples.

[03]Hook Configuration Anatomy

Hooks live in .claude/settings.json (team-shared) or .claude/settings.local.json (personal) under the hooks key. The structure:

{
  "hooks": {
    "<EventName>": [
      {
        "matcher": "<tool-name-regex>",
        "hooks": [
          { "type": "command", "command": "<shell-command-or-script>" }
        ]
      }
    ]
  }
}

Three knobs:

  1. Event name: one of the six from the table above
  2. Matcher: a regex against tool names. "Edit|Write|MultiEdit" matches any file-write tool. "Bash" matches only Bash. Empty string or omitted = match everything for that event.
  3. Command: any executable shell command. Can be inline ("echo hello") or a path to a script (".claude/hooks/format.sh").

The JSON Protocol

Every hook receives a JSON event payload on stdin. The shape varies by event but always includes:

{
  "session_id": "abc-123",
  "transcript_path": "/path/to/conversation.jsonl",
  "cwd": "/path/to/project",
  "hook_event_name": "PostToolUse",
  "tool_name": "Edit",
  "tool_input": {
    "file_path": "/abs/path/to/edited/file.ts",
    "old_string": "...",
    "new_string": "..."
  },
  "tool_response": {
    "success": true
  }
}

Read it with jq or any JSON parser. The exit code matters:

  • Exit 0 = success, allow tool call to proceed (PreToolUse) or move on (PostToolUse)
  • Exit 2 (PreToolUse) = block the tool call. stderr output is shown to Claude as the reason.
  • Exit non-zero (PostToolUse) = warning logged, but tool call already happened — too late to undo.

[04]PostToolUse Recipes

Three drop-in scripts for the most common cleanup needs. Save each one in .claude/hooks/, chmod +x, and reference from settings.json.

Recipe 1: Auto-format edited files

#!/usr/bin/env bash
# .claude/hooks/format.sh — runs Prettier on edited TS/JS/JSON/MD files

INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

[[ -z "$FILE" || ! -f "$FILE" ]] && exit 0

case "$FILE" in
  *.ts|*.tsx|*.js|*.jsx|*.json|*.md|*.css|*.html)
    npx --no-install prettier --write "$FILE" 2>/dev/null
    ;;
esac
exit 0

Wire it up in settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [{ "type": "command", "command": ".claude/hooks/format.sh" }]
      }
    ]
  }
}

Recipe 2: Run typecheck after every edit

#!/usr/bin/env bash
# .claude/hooks/typecheck.sh — surface TS errors immediately

INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

# Only check TS files
[[ "$FILE" != *.ts && "$FILE" != *.tsx ]] && exit 0

# Run incremental tsc; non-zero exit shows errors but doesn't undo the edit
if ! npx --no-install tsc --noEmit --incremental 2>&1 | head -20; then
  echo "[typecheck hook] errors above" >&2
fi
exit 0

Trade-off: this adds 2-5 seconds per edit. Worth it if your team values fast feedback over speed of iteration.

Recipe 3: Quick test for the changed module

#!/usr/bin/env bash
# .claude/hooks/quick-test.sh — run tests for the changed file's module only

INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

[[ -z "$FILE" ]] && exit 0

# Find related test file by convention: src/foo.ts → src/foo.test.ts
TEST_FILE=$(echo "$FILE" | sed 's/\.\(ts\|tsx\|js\|jsx\)$/.test.\1/')
[[ ! -f "$TEST_FILE" ]] && exit 0

npx --no-install vitest run "$TEST_FILE" --reporter=basic 2>&1 | tail -10
exit 0

[05]PreToolUse Recipes (Guards)

PreToolUse is your safety net. Three guards every team should have.

Recipe 4: Block dangerous commands

#!/usr/bin/env bash
# .claude/hooks/danger-guard.sh — block irreversible Bash invocations

INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

# Patterns we never want to see
DANGER='(rm -rf /|rm -rf ~|rm -rf \$HOME|git reset --hard origin|git push --force.*main|git push --force.*master|DROP TABLE|TRUNCATE TABLE)'

if echo "$CMD" | grep -qE "$DANGER"; then
  echo "[danger-guard] BLOCKED: command matches danger pattern: $CMD" >&2
  echo "If you really need this, run it manually outside Claude Code." >&2
  exit 2  # exit 2 = block the tool call
fi
exit 0

Wire up to Bash only:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": ".claude/hooks/danger-guard.sh" }]
      }
    ]
  }
}

Recipe 5: Block edits that contain secrets

#!/usr/bin/env bash
# .claude/hooks/secret-scan.sh — block writes that look like they contain secrets

INPUT=$(cat)
NEW=$(echo "$INPUT" | jq -r '.tool_input.new_string // .tool_input.content // empty')

# Common secret patterns
SECRET_RE='(sk-[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]{36}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]+PRIVATE KEY-----)'

if echo "$NEW" | grep -qE "$SECRET_RE"; then
  echo "[secret-scan] BLOCKED: edit appears to contain a secret (API key, GitHub PAT, AWS key, or private key)" >&2
  echo "Move the secret to .env.local (gitignored) and reference it via process.env." >&2
  exit 2
fi
exit 0

Recipe 6: Block commits to protected branches

#!/usr/bin/env bash
# .claude/hooks/branch-guard.sh — block git commits on main/master

INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

# Only care about git commit invocations
if ! echo "$CMD" | grep -qE '^git commit'; then
  exit 0
fi

BRANCH=$(git branch --show-current 2>/dev/null)
if [[ "$BRANCH" == "main" || "$BRANCH" == "master" ]]; then
  echo "[branch-guard] BLOCKED: cannot commit directly to $BRANCH. Create a feature branch first: git checkout -b feature/..." >&2
  exit 2
fi
exit 0

This one alone has prevented countless "oops, I committed to main" incidents. Pair it with the same regex matcher as Recipe 4.

[06]UserPromptSubmit Recipe — Auto-inject Context

Some context is too noisy to put in CLAUDE.md but valuable per-prompt: current git branch, recent commits, current time. The UserPromptSubmit hook lets you inject it into every prompt.

#!/usr/bin/env bash
# .claude/hooks/inject-context.sh — prepend useful context to user prompts

# Use stdout to add context (it gets prepended to the prompt)
echo "<context>"
echo "Current branch: $(git branch --show-current 2>/dev/null)"
echo "Recent commits:"
git log --oneline -3 2>/dev/null
echo "Current time: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "</context>"
exit 0

Wire up:

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [{ "type": "command", "command": ".claude/hooks/inject-context.sh" }]
      }
    ]
  }
}

Now every prompt arrives with branch + recent history + timestamp prepended. Claude is no longer confused about whether you're on a feature branch, no longer makes up dates, no longer asks "what was the last commit?"

Use sparingly. Each line of injected context burns tokens. Inject only what genuinely helps; resist the urge to dump everything.

[07]Debugging Hooks (and Common Pitfalls)

Hooks fail silently. The model does not see your stderr unless you exit non-zero on a PreToolUse. Here's how to debug.

Step 1: Verify the hook fires at all

Add a debug log line as the first thing the script does:

#!/usr/bin/env bash
echo "[$(date)] hook fired: $0" >> /tmp/claude-hooks.log
INPUT=$(cat)
echo "$INPUT" >> /tmp/claude-hooks.log
# ... rest of the hook ...

Then tail -f /tmp/claude-hooks.log in another terminal while you trigger the event. If nothing appears, the hook is not firing — check matcher, file path, and executable bit.

Common Pitfalls

  1. Forgot chmod +x. The script needs to be executable. Add to your team's onboarding doc.
  2. Matcher doesn't match. The matcher is a regex against tool name. "edit" (lowercase) does not match Edit. Use "Edit|Write|MultiEdit" exactly.
  3. Path is relative to wrong directory. The hook runs with cwd = your project root, not .claude/hooks/. Use absolute paths or paths from project root.
  4. Hook silently rewrites Claude's output. Don't. Hooks should observe and gate, not modify Claude's text. If you need to enforce a style, use PostToolUse + format-on-edit, not output rewriting.
  5. Hook is slow on the hot path. A 5-second hook on PostToolUse turns Claude's session into molasses. Run heavy checks (full test suite, full lint) only at SessionEnd or in CI, not on every edit.
  6. Hook depends on a tool that's not installed. npx prettier assumes prettier is in node_modules. Add a command -v prettier >/dev/null || exit 0 guard so the hook degrades gracefully on machines without the tool.

[08]Frequently Asked Questions

What's the difference between PreToolUse and PostToolUse?

PreToolUse fires before the tool runs and can block it (exit 2). PostToolUse fires after the tool completes and cannot undo it. Use Pre for guards, Post for cleanup.

Can hooks read the file Claude is about to edit?

Yes — the tool_input.file_path field on PreToolUse for Edit/Write tools tells you the path. Read the file content from disk; the hook runs with full file system access.

How do I disable a hook for one session?

Move the hook config from settings.json to settings.local.json with the matcher set to something that won't match (e.g. "DISABLED"). Or comment out the entry. There's no per-session disable flag.

Do hooks run in CI / on Vercel builds?

No — hooks fire only inside Claude Code sessions. Your build pipeline never sees them. If you need the same logic in CI, write the script as a standalone command and call it from both the hook and your CI YAML.

Can a hook modify Claude's prompt before it's sent to the model?

The UserPromptSubmit hook can prepend stdout content to the prompt. Other events cannot modify the prompt. This is a deliberate design — hooks should be transparent observers, not silent rewriters.

Why doesn't my hook fire on the very first prompt of a session?

It does, but SessionStart fires before UserPromptSubmit. If you're seeing your hook miss the first prompt, check whether you accidentally attached it to SessionStart (which only fires once).

How do I test a hook without running Claude Code?

Pipe a fake JSON event to it from the command line:

echo '{"tool_name":"Edit","tool_input":{"file_path":"/tmp/test.ts"}}' | .claude/hooks/format.sh

Inspect the exit code (echo $?) and stderr to verify behavior.

Related: .claude/ folder complete guide · Codex /pet command cheat sheet · Claude Buddy Checker

// COMMENTS

github_discussions.sh

Sign in with GitHub to leave a comment.

Ready to find your buddy?

CHECK YOUR BUDDY

Built by the community. Not affiliated with Anthropic.

All computation is local. No data is collected or transmitted.

> EOF