Custom Hooks Cookbook: Practical Recipes for Automating Claude Code#

Executive Summary#

Hooks let you run code at specific points in Claude Code’s lifecycle – before a tool runs, after a file is edited, when a session starts, when Claude finishes responding. This cookbook provides copy-paste-ready recipes for the most common use cases: auto-formatting, command safety, test gates, notifications, logging, and context injection. Each recipe includes the hook configuration, the script, and notes on gotchas.

CategoryExample RecipesHook Events Used
Code qualityAuto-format, lint on save, type checkPostToolUse (Edit|Write)
SafetyBlock dangerous commands, protect files, branch guardPreToolUse (Bash, Edit|Write)
VerificationTest gates, build checks, stop-until-passingPostToolUse, Stop, TaskCompleted
NotificationsDesktop alerts, Slack, TTSNotification
LoggingCommand audit, session tracking, debug wrapperPostToolUse, SessionStart
ContextInject reminders, load state, persist env varsSessionStart, UserPromptSubmit
Quality gatesBlock stopping until tasks complete, auto code reviewStop, SubagentStop, TaskCompleted, PostToolUse

Table of Contents#

Hook Fundamentals#

Where Hooks Live#

Hooks are configured in JSON settings files. Three scopes are available:

LocationScopeCommittable?
~/.claude/settings.jsonAll your projectsNo
.claude/settings.jsonThis project (team)Yes
.claude/settings.local.jsonThis project (you)No

Hooks from all scopes merge together. Plugins can also register hooks via hooks/hooks.json in their package.

The Three Hook Types#

Command hooks run a shell command. The script receives JSON on stdin and communicates via exit codes and stdout.

{
  "type": "command",
  "command": "/path/to/script.sh",
  "timeout": 600,
  "async": false
}

Prompt hooks send a single-turn prompt to a Claude model (Haiku by default). The model returns {"ok": true/false, "reason": "..."}.

{
  "type": "prompt",
  "prompt": "Evaluate whether the task is complete. Context: $ARGUMENTS",
  "model": "haiku",
  "timeout": 30
}

The $ARGUMENTS placeholder is replaced with the hook’s JSON input data. Prompt hooks are supported on PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, UserPromptSubmit, Stop, SubagentStop, and TaskCompleted.

Agent hooks spawn a subagent with multi-turn tool access (Read, Grep, Glob – up to 50 turns).

{
  "type": "agent",
  "prompt": "Verify all unit tests pass. Run the test suite. $ARGUMENTS",
  "timeout": 120
}

Agent hooks are useful when verification requires inspecting files or running commands beyond evaluating the input data.

Exit Code Protocol#

Exit CodeMeaningBehavior
0SuccessStdout parsed for JSON. For SessionStart, stdout is context
2Blocking errorStderr fed to Claude. Blocks the action (if event supports it)
OtherNon-blocking errorStderr shown in verbose mode (Ctrl+O). Execution continues

Not every event supports blocking. The events that respond to exit 2:

  • Can block: PreToolUse, PermissionRequest, UserPromptSubmit, Stop, SubagentStop, TeammateIdle, TaskCompleted
  • Cannot block: PostToolUse, PostToolUseFailure, Notification, SubagentStart, SessionStart, SessionEnd, PreCompact

JSON Output Format#

When a hook exits 0 and prints JSON to stdout, these universal fields are available:

FieldDefaultDescription
continuetrueIf false, Claude stops the entire session
stopReasonMessage shown to user when continue is false
suppressOutputfalseIf true, hides stdout from verbose mode
systemMessageWarning message shown to user

For PreToolUse specifically, structured decisions go in hookSpecificOutput:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "This command is not allowed"
  }
}

The three permission decisions: allow (skip permission prompt), deny (block with reason), ask (show permission prompt with additional context).

Environment Variables#

VariableAvailable InDescription
CLAUDE_PROJECT_DIRAll hooksProject root directory
CLAUDE_PLUGIN_ROOTPlugin hooksPlugin package root
CLAUDE_CODE_REMOTEAll hooks"true" in remote web environments
CLAUDE_ENV_FILESessionStartPath to file for persisting environment variables

Event Reference#

Complete Event Table#

Session lifecycle:
  SessionStart ──> [conversation] ──> SessionEnd
Conversation flow:      │
  UserPromptSubmit ──> PreToolUse ──> [tool runs] ──> PostToolUse
                           │                              │
                           │ (if blocked)           PostToolUseFailure
                     PermissionRequest (if permission needed)
Completion events:         │
  Stop (Claude finishes) ──┘
  SubagentStart / SubagentStop
  TeammateIdle / TaskCompleted

Maintenance:
  PreCompact ──> [compaction runs]
  Notification (permission_prompt, idle_prompt, etc.)
EventFires WhenCan Block?Matcher Filters
SessionStartSession begins or resumesNostartup, resume, clear, compact
UserPromptSubmitUser submits a promptYes(none – always fires)
PreToolUseBefore a tool callYesTool name
PermissionRequestPermission dialog appearsYesTool name
PostToolUseAfter a tool call succeedsNoTool name
PostToolUseFailureAfter a tool call failsNoTool name
NotificationClaude sends a notificationNopermission_prompt, idle_prompt, auth_success
SubagentStartSubagent is spawnedNoAgent type
SubagentStopSubagent finishesYesAgent type
StopClaude finishes respondingYes(none – always fires)
TeammateIdleAgent team member going idleYes(none – always fires)
TaskCompletedTask marked as completedYes(none – always fires)
PreCompactBefore context compactionNomanual, auto
SessionEndSession terminatesNoclear, logout, other

Matcher Syntax#

Matchers filter which events trigger a hook. They use regex-style patterns:

PatternMatches
"Bash"Bash tool only
"Edit|Write"Edit or Write tools
"" or omittedEverything (wildcard)
"Notebook.*"Anything starting with Notebook
"mcp__github__.*"All tools from the GitHub MCP server
"mcp__.*__write.*"Any write tool from any MCP server

Matchers are case-sensitive: "bash" does not match the Bash tool.

Tool Input Schemas#

When writing PreToolUse or PostToolUse hooks, the tool_input field varies by tool:

ToolKey Fields in tool_input
Bashcommand, description, timeout, run_in_background
Writefile_path, content
Editfile_path, old_string, new_string, replace_all
Readfile_path, offset, limit
Globpattern, path
Greppattern, path, glob, output_mode
Taskprompt, description, subagent_type, model

Recipes: Code Quality#

Auto-Format with Prettier#

Run Prettier on any file Claude edits or creates.

Configuration (in .claude/settings.json):

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null"
          }
        ]
      }
    ]
  }
}

No separate script needed – this is a one-liner. The 2>/dev/null suppresses Prettier errors for non-supported file types.

Auto-Format Go Files#

Run gofmt only on .go files after edits.

Configuration:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | grep '\\.go$' | xargs -r gofmt -w"
          }
        ]
      }
    ]
  }
}

The grep filters to .go files only, and xargs -r skips execution when there’s no match.

Run Ruff on Python Files#

Lint and auto-fix Python files with Ruff after edits.

Script (.claude/hooks/ruff-format.sh):

#!/usr/bin/env bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

if [[ "$FILE_PATH" != *.py ]]; then
  exit 0
fi

ruff check --fix "$FILE_PATH" 2>&1
ruff format "$FILE_PATH" 2>&1
exit 0

Configuration:

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

ESLint on Save#

Run ESLint with auto-fix on TypeScript/JavaScript files.

Configuration:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | grep -E '\\.(ts|tsx|js|jsx)$' | xargs -r npx eslint --fix 2>/dev/null"
          }
        ]
      }
    ]
  }
}

Recipes: Safety#

Block Dangerous Commands#

Prevent destructive shell commands from running.

Script (.claude/hooks/block-dangerous-commands.sh):

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

# Patterns to block
BLOCKED_PATTERNS=(
  'rm -rf /'
  'rm -rf ~'
  'rm -rf \.'
  'mkfs\.'
  'dd if='
  ':(){ :|:& };:'
  'chmod -R 777 /'
  '> /dev/sda'
  'curl.*|.*sh'
  'wget.*|.*sh'
)

for pattern in "${BLOCKED_PATTERNS[@]}"; do
  if echo "$COMMAND" | grep -qE "$pattern"; then
    echo "Blocked: command matches dangerous pattern '$pattern'" >&2
    exit 2
  fi
done

exit 0

Configuration:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/block-dangerous-commands.sh"
          }
        ]
      }
    ]
  }
}

Protect Sensitive Files#

Block Claude from editing .env files, lock files, and other protected paths.

Script (.claude/hooks/protect-sensitive-files.sh):

#!/usr/bin/env bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

if [ -z "$FILE_PATH" ]; then
  exit 0
fi

PROTECTED_PATTERNS=(
  ".env"
  ".env.local"
  ".env.production"
  "package-lock.json"
  "yarn.lock"
  "bun.lockb"
  ".git/"
  "id_rsa"
  "id_ed25519"
  ".pem"
)

for pattern in "${PROTECTED_PATTERNS[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blocked: '$FILE_PATH' matches protected pattern '$pattern'" >&2
    exit 2
  fi
done

exit 0

Configuration:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/protect-sensitive-files.sh"
          }
        ]
      }
    ]
  }
}

Block Pushes to Protected Branches#

Prevent Claude from pushing directly to main or master.

Script (.claude/hooks/protect-branches.sh):

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

# Block direct pushes to main/master
if echo "$COMMAND" | grep -qE 'git push.*(main|master)'; then
  echo "Blocked: direct push to protected branch. Use a feature branch and PR instead." >&2
  exit 2
fi

# Block force pushes entirely
if echo "$COMMAND" | grep -qE 'git push.*(-f|--force)'; then
  echo "Blocked: force push is not allowed." >&2
  exit 2
fi

exit 0

Configuration:

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

Prevent Credential Leaks in Commands#

Block commands that might expose secrets in arguments.

Script (.claude/hooks/no-secrets-in-commands.sh):

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

# Patterns that suggest credentials in command arguments
SECRET_PATTERNS=(
  'ANTHROPIC_API_KEY='
  'OPENAI_API_KEY='
  'AWS_SECRET_ACCESS_KEY='
  'token=[a-zA-Z0-9]'
  'password='
  'Authorization:.*Bearer'
  'curl.*-H.*Authorization'
)

for pattern in "${SECRET_PATTERNS[@]}"; do
  if echo "$COMMAND" | grep -qiE "$pattern"; then
    echo "Blocked: command may contain credentials. Use environment variables instead." >&2
    exit 2
  fi
done

exit 0

Recipes: Verification#

Run Tests After File Changes#

Run the test suite asynchronously after Claude edits source files.

Script (.claude/hooks/run-tests.sh):

#!/usr/bin/env bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

# Only run tests for source files, not config or docs
if [[ "$FILE_PATH" != *.ts && "$FILE_PATH" != *.js && "$FILE_PATH" != *.go && "$FILE_PATH" != *.py ]]; then
  exit 0
fi

# Skip test files themselves to avoid recursive triggers
if [[ "$FILE_PATH" == *_test.* || "$FILE_PATH" == *.test.* || "$FILE_PATH" == *.spec.* ]]; then
  exit 0
fi

RESULT=$(npm test 2>&1)
EXIT_CODE=$?

if [ $EXIT_CODE -eq 0 ]; then
  echo "{\"systemMessage\": \"Tests passed after editing $FILE_PATH\"}"
else
  # Truncate output to avoid flooding context
  TRUNCATED=$(echo "$RESULT" | tail -20)
  echo "{\"systemMessage\": \"Tests FAILED after editing $FILE_PATH:\\n$TRUNCATED\"}"
fi

Configuration:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/run-tests.sh",
            "async": true,
            "timeout": 300
          }
        ]
      }
    ]
  }
}

Setting async: true lets Claude keep working while tests run. Results appear on the next conversation turn.

Stop Hook Test Gate#

Prevent Claude from stopping until the test suite passes. Uses an agent hook that can actually run commands.

Configuration:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "agent",
            "prompt": "Run the project's test suite and verify all tests pass. If tests fail, respond with {\"ok\": false, \"reason\": \"Tests are failing. Fix them before stopping.\"}. If tests pass, respond with {\"ok\": true}. $ARGUMENTS",
            "timeout": 120
          }
        ]
      }
    ]
  }
}

Critical: The Stop event includes a stop_hook_active field. If you use a command hook instead of an agent hook, you must check this field to prevent infinite loops:

#!/usr/bin/env bash
INPUT=$(cat)

# Prevent infinite loop: if we already continued due to a stop hook, allow stopping
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi

# Run tests
if ! npm test 2>&1; then
  echo "Tests are failing. Fix them before stopping." >&2
  exit 2
fi

exit 0

Task Completion Test Gate#

Block a task from being marked complete until tests pass.

Script (.claude/hooks/task-test-gate.sh):

#!/usr/bin/env bash
INPUT=$(cat)
TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // "unknown task"')

if ! npm test 2>&1; then
  echo "Cannot complete '$TASK_SUBJECT': tests are failing. Fix them first." >&2
  exit 2
fi

exit 0

Configuration:

{
  "hooks": {
    "TaskCompleted": [
      {
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/task-test-gate.sh",
            "timeout": 300
          }
        ]
      }
    ]
  }
}

Build Verification Gate#

Ensure the project builds before Claude stops.

Script (.claude/hooks/build-gate.sh):

#!/usr/bin/env bash
INPUT=$(cat)

if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi

if ! npm run build 2>&1; then
  echo "Build is broken. Fix build errors before stopping." >&2
  exit 2
fi

exit 0

Recipes: Notifications#

macOS Desktop Notification#

{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}

Linux Desktop Notification#

{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "notify-send 'Claude Code' 'Claude Code needs your attention'"
          }
        ]
      }
    ]
  }
}

Slack Webhook Notification#

Script (.claude/hooks/slack-notify.sh):

#!/usr/bin/env bash
INPUT=$(cat)
MESSAGE=$(echo "$INPUT" | jq -r '.message // "Claude Code needs attention"')
TITLE=$(echo "$INPUT" | jq -r '.title // "Notification"')

# SLACK_WEBHOOK_URL should be set in your environment
if [ -z "$SLACK_WEBHOOK_URL" ]; then
  exit 0
fi

curl -s -X POST "$SLACK_WEBHOOK_URL" \
  -H 'Content-Type: application/json' \
  -d "{\"text\": \"*$TITLE*: $MESSAGE\"}" \
  >/dev/null 2>&1

exit 0

Configuration:

{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/slack-notify.sh",
            "async": true
          }
        ]
      }
    ]
  }
}

Using async: true prevents the Slack request from blocking Claude’s flow.

Recipes: Logging and Auditing#

Log Every Bash Command#

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '\"[\" + (now | todate) + \"] \" + .tool_input.command' >> ~/.claude/command-log.txt"
          }
        ]
      }
    ]
  }
}

This logs every command with a timestamp to ~/.claude/command-log.txt before it runs. Using PreToolUse means even blocked commands are logged.

Session Start Logger#

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '\"[\" + (now | todate) + \"] session=\" + .session_id + \" cwd=\" + .cwd' >> ~/.claude/sessions.log"
          }
        ]
      }
    ]
  }
}

Debug Wrapper Script#

When a hook misbehaves, wrap it with this script to log all inputs and outputs.

Script (.claude/hooks/debug-wrapper.sh):

#!/usr/bin/env bash
LOG=~/.claude/hook-debug.log
INPUT=$(cat)
SCRIPT="$1"

TOOL=$(echo "$INPUT" | jq -r '.tool_name // "n/a"')
EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // "n/a"')

echo "=== $(date) | event=$EVENT | tool=$TOOL ===" >> "$LOG"
echo "INPUT: $INPUT" >> "$LOG"

# Run the actual hook, passing stdin
OUTPUT=$(echo "$INPUT" | "$SCRIPT" 2>&1)
CODE=$?

echo "OUTPUT: $OUTPUT" >> "$LOG"
echo "EXIT: $CODE" >> "$LOG"
echo "" >> "$LOG"

# Forward the output and exit code
echo "$OUTPUT"
exit $CODE

Usage – wrap any hook by changing its command:

{
  "type": "command",
  "command": ".claude/hooks/debug-wrapper.sh .claude/hooks/protect-branches.sh"
}

Recipes: Context Injection#

Inject Reminders After Compaction#

When context compacts, important reminders can be lost. Re-inject them.

Configuration:

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'Reminder: use Bun, not npm. Run bun test before committing. Current sprint: auth refactor.'"
          }
        ]
      }
    ]
  }
}

Stdout from a SessionStart hook is added as context to the conversation. The compact matcher ensures this only fires after compaction, not on every session start.

Load Project State on Session Start#

Inject git status and TODO context at the start of every session.

Script (.claude/hooks/load-project-state.sh):

#!/usr/bin/env bash
echo "=== Git Status ==="
git status --short 2>/dev/null

echo ""
echo "=== Recent Commits ==="
git log --oneline -5 2>/dev/null

if [ -f TODO.md ]; then
  echo ""
  echo "=== Current TODOs ==="
  cat TODO.md
fi

Configuration:

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/load-project-state.sh"
          }
        ]
      }
    ]
  }
}

Inject Sprint Context With Every Prompt#

Add project-specific context to every message Claude processes.

Configuration:

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "cat .claude/sprint-context.md 2>/dev/null || true"
          }
        ]
      }
    ]
  }
}

Warning: This fires on every single prompt. Keep the context file small to avoid adding unnecessary tokens per message. A few lines of reminders is fine; a multi-page document is not.

Persist Environment Variables#

SessionStart hooks can write to $CLAUDE_ENV_FILE to set environment variables for the session.

Script (.claude/hooks/set-env.sh):

#!/usr/bin/env bash
if [ -n "$CLAUDE_ENV_FILE" ]; then
  echo 'export NODE_ENV=development' >> "$CLAUDE_ENV_FILE"
  echo 'export DEBUG=true' >> "$CLAUDE_ENV_FILE"
  echo 'export PATH="$PATH:./node_modules/.bin"' >> "$CLAUDE_ENV_FILE"
fi
exit 0

Configuration:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/set-env.sh"
          }
        ]
      }
    ]
  }
}

CLAUDE_ENV_FILE is only available to SessionStart hooks. Other hook types do not have access to it.

Recipes: Quality Gates#

Prompt-Based Stop Gate#

Use a Haiku model to evaluate whether Claude should stop, based on conversation context.

Configuration:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Evaluate whether Claude should stop working. Context: $ARGUMENTS\n\nCheck:\n1. Are all user-requested tasks complete?\n2. Are there unaddressed errors?\n3. Is follow-up work needed?\n\nRespond with {\"ok\": true} to allow stopping, or {\"ok\": false, \"reason\": \"explanation\"} to continue.",
            "model": "haiku",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

Prompt hooks cost very little (Haiku at $1/MTok input) and add a lightweight quality check without needing a script.

Agent-Based Verification Gate#

For thorough verification that requires reading files or running commands.

Configuration:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "agent",
            "prompt": "Before allowing Claude to stop, verify:\n1. Run the test suite and confirm all tests pass\n2. Check that no TODO comments were left in modified files\n3. Verify the build succeeds\n\nIf everything passes, respond {\"ok\": true}. Otherwise, {\"ok\": false, \"reason\": \"details\"}. $ARGUMENTS",
            "timeout": 180
          }
        ]
      }
    ]
  }
}

Agent hooks are more expensive (they spawn a full subagent with tool access) but can perform multi-step verification.

Stop Hook Code Review#

Trigger a semantic code review subagent when Claude finishes, covering only files modified since the last review. This combines two hooks: a PostToolUse hook that tracks which files were modified, and a Stop hook that triggers the review.

The pattern addresses a specific problem: Claude ignores system prompt instructions as context fills up. A separate review agent with a fresh context window catches violations that the main agent missed or rationalized away.

File tracking script (.claude/hooks/review-tracker.sh):

#!/usr/bin/env bash
set -euo pipefail

LOG_DIR="/tmp"
INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty')
ACTION="${1:-}"

if [ -z "$SESSION_ID" ]; then
  exit 0
fi

LOG_FILE="${LOG_DIR}/review-log-${SESSION_ID}.jsonl"

log_file_modified() {
  local FILE_PATH
  FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
  if [ -z "$FILE_PATH" ]; then
    exit 0
  fi

  # Filter by extension -- customize for your project
  case "$FILE_PATH" in
    *.ts|*.tsx|*.js|*.jsx|*.go|*.py) ;;
    *) exit 0 ;;
  esac

  jq -nc --arg f "$FILE_PATH" --arg e "file_modified" \
    '{event: $e, file: $f}' >> "$LOG_FILE"
}

check_and_review() {
  if [ ! -f "$LOG_FILE" ]; then
    exit 0
  fi

  # Find files modified since the last review
  local LAST_REVIEW_LINE
  LAST_REVIEW_LINE=$(grep -n '"review_triggered"' "$LOG_FILE" | tail -1 | cut -d: -f1)

  local FILES
  if [ -n "$LAST_REVIEW_LINE" ]; then
    FILES=$(tail -n +"$((LAST_REVIEW_LINE + 1))" "$LOG_FILE" \
      | jq -r 'select(.event == "file_modified") | .file' \
      | sort -u)
  else
    FILES=$(jq -r 'select(.event == "file_modified") | .file' "$LOG_FILE" \
      | sort -u)
  fi

  if [ -z "$FILES" ]; then
    exit 0
  fi

  # Mark this review point
  jq -nc '{event: "review_triggered"}' >> "$LOG_FILE"

  # Build file list for output
  local FILE_LIST
  FILE_LIST=$(echo "$FILES" | sed 's/^/- /')

  cat >&2 <<REVIEW_MSG
CODE REVIEW REQUIRED

Files modified since last review:
${FILE_LIST}

INSTRUCTION: Use the Task tool with a code review subagent. Pass the file list as the prompt. The subagent should read each file and check against the project's review rules in .claude/review-rules.md (fall back to general quality checks if the file doesn't exist).

After receiving findings:
1. Show all findings to the user.
2. For each finding, either fix it or explain why you're skipping it.
   Valid skip reasons: impossible to satisfy (you tried), conflicts with explicit requirements, or genuinely makes code worse.
   Not valid: "too much time", "out of scope", "pre-existing code", "would require large refactor."
   When uncertain, ask the user.
3. Summarize: what was fixed, what was skipped and why.
REVIEW_MSG

  exit 2
}

case "$ACTION" in
  log) log_file_modified ;;
  review) check_and_review ;;
  *) exit 0 ;;
esac

Configuration:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/review-tracker.sh log"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/review-tracker.sh review"
          }
        ]
      }
    ]
  }
}

Review rules file (.claude/review-rules.md):

Define project-specific rules that the review subagent checks. The subagent reads this file and applies only what’s listed – no improvisation. Example rules for a TypeScript project:

## No Dangerous Fallback Values

Nullish coalescing (`??`) with a hardcoded default for required values
hides bugs. Required values should fail fast, not silently default.

Exceptions: feature flags defaulting to `false`, optional display
values with sensible defaults.

## No Generic Category Names

Files named utils.ts, helpers.ts, handlers.ts, or directories named
/shared are dumping grounds. Name files after what they contain.

## Domain Logic Stays in Domain Objects

Application services that query an entity's state and then make
decisions based on it are leaking domain logic. The entity should
protect its own invariants.

How it works:

  1. Every Write/Edit/MultiEdit appends the modified file path to a JSONL log keyed by session ID.
  2. When Claude stops, the Stop hook checks whether any files were modified since the last review.
  3. If yes, it emits an instruction to stderr and exits 2, which blocks Claude and forces it to read the output.
  4. Claude spawns a review subagent (Haiku is appropriate – fast and cheap for focused rule-checking) that reads each file and checks against the rules.
  5. The log records a review_triggered event so the next Stop only reviews files modified after this point.

The anti-skip instructions matter. Without them, Claude will rationalize ignoring findings (“out of scope,” “pre-existing issue,” “would require refactoring”). The Stop hook output explicitly lists what counts as a valid reason to skip a finding and what doesn’t. This is a prompt-level control – it works because the instruction arrives in a fresh context (the Stop hook output), not buried in a system prompt that’s been compressed.

Limitations: The Stop hook fires whenever Claude finishes, including when it stops to ask a question. If Claude commits before stopping, the review happens after the commit. Neither issue is fatal, but both are worth knowing about. A CodeReadyForReview hook event (which doesn’t exist yet) would be the correct abstraction.

This pattern is adapted from claude-skillz by Nick Tune.

Combining Hooks#

A Complete Safety Setup#

A set of hooks focused on preventing mistakes, suitable for ~/.claude/settings.json (applies to all projects):

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/block-dangerous-commands.sh"
          },
          {
            "type": "command",
            "command": "~/.claude/hooks/no-secrets-in-commands.sh"
          },
          {
            "type": "command",
            "command": "~/.claude/hooks/protect-branches.sh"
          },
          {
            "type": "command",
            "command": "jq -r '\"[\" + (now | todate) + \"] \" + .tool_input.command' >> ~/.claude/command-log.txt"
          }
        ]
      },
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/protect-sensitive-files.sh"
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}

Multiple hooks on the same matcher run in order. If any hook exits 2, the tool call is blocked and subsequent hooks don’t run.

A Complete CI-Style Setup#

A project-level setup focused on code quality and verification, suitable for .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null"
          },
          {
            "type": "command",
            "command": ".claude/hooks/run-tests.sh",
            "async": true,
            "timeout": 300
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "agent",
            "prompt": "Run the test suite. If all tests pass, respond {\"ok\": true}. If any fail, respond {\"ok\": false, \"reason\": \"Tests failing\"}. $ARGUMENTS",
            "timeout": 120
          }
        ]
      }
    ],
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/load-project-state.sh"
          }
        ]
      },
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'Reminder: run tests before committing. Follow TDD.'"
          }
        ]
      }
    ]
  }
}

Gotchas and Debugging#

The Stop Hook Infinite Loop#

The most common hook bug. If your Stop hook always blocks, Claude will never stop. Always check stop_hook_active:

if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0  # Let Claude stop this time
fi

Agent and prompt hooks handle this automatically – they receive the stop_hook_active context in $ARGUMENTS.

Shell Profile Pollution#

If your ~/.zshrc or ~/.bashrc prints text unconditionally (e.g., echo "Welcome!"), it prepends to your hook’s stdout and breaks JSON parsing. Fix by wrapping in interactive-shell checks:

if [[ $- == *i* ]]; then
  echo "Welcome!"
fi

Hook Snapshot at Startup#

Claude Code captures hook configuration at session start. If you edit hooks during a session, changes don’t take effect until you run /hooks to review them or restart the session.

Async Hooks Cannot Block#

Async hooks run in the background. By the time they finish, the triggering action has already proceeded. Use async for notifications and logging, not for safety gates.

Exit 2 vs JSON#

If a hook exits with code 2, stdout is ignored. If you want structured control (allow/deny/ask with reasons), exit 0 and output JSON. Don’t mix the approaches.

PermissionRequest Hooks Don’t Fire in Headless Mode#

In headless mode (claude -p), PermissionRequest events don’t fire. Use PreToolUse hooks for automated permission decisions in CI/CD pipelines.

PostToolUse Cannot Undo#

PostToolUse fires after the tool has already run. You can log, notify, or inject a system message, but you can’t undo the action.

The hookEventName Bug#

When returning JSON from a PreToolUse hook, the hookSpecificOutput object must include "hookEventName": "PreToolUse". Omitting it causes a parse error. This is a common bug in custom hooks:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow"
  }
}

Debugging Commands#

  • claude --debug – full hook execution details in output
  • Ctrl+O – toggle verbose mode to see hook output in the transcript
  • Manual testing – pipe JSON to your script directly:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | .claude/hooks/block-dangerous-commands.sh
echo "Exit code: $?"

Best Practices#

  1. Start simple. One or two safety hooks are better than a complex multi-hook pipeline. Add hooks as you find specific needs, not speculatively.

  2. Use async for non-blocking work. Formatting, logging, notifications, and test runs that don’t need to block should use async: true.

  3. Keep hooks fast. Synchronous hooks block Claude’s workflow. If a hook takes more than a few seconds, consider making it async or increasing the timeout.

  4. Filter by file type in the script. Rather than trying to encode file type logic in matchers, check the file extension inside the script. Matchers filter by tool name, not file path.

  5. Use PreToolUse for safety, PostToolUse for quality. Safety hooks (blocking dangerous actions) should fire before the tool runs. Quality hooks (formatting, testing) should fire after.

  6. Put personal hooks in ~/.claude/settings.json. Team hooks go in .claude/settings.json (committed). Personal preferences (notifications, editor integrations) go in user settings.

  7. Test hooks manually before deploying. Pipe sample JSON to your scripts and verify the exit codes and output before adding them to settings.

  8. Log during development. Use the debug wrapper script or write to a log file while developing hooks. Remove verbose logging once the hook is stable.

  9. Truncate large output. If a hook outputs test results or build logs, truncate to the last 20-30 lines. Long outputs consume context tokens.

  10. Use jq -r for field extraction. It handles missing fields gracefully (returns “null” or empty string) and avoids fragile grep/sed parsing.

Anti-Patterns#

  1. Stop hooks without stop_hook_active check. Causes infinite loops where Claude can never finish responding.

  2. Synchronous test suites on every edit. Running a full test suite synchronously after every Edit/Write blocks Claude for minutes. Use async: true or limit to specific file patterns.

  3. Overly broad matchers with expensive hooks. A PostToolUse hook with no matcher that runs a test suite fires on every single tool call – Read, Glob, Grep, everything.

  4. Secrets in hook scripts. Don’t hardcode API keys or tokens in hook scripts that get committed to the repo. Use environment variables.

  5. Complex JSON construction in bash. Building nested JSON in bash is fragile. Use jq -n for constructing JSON output:

    # Bad
    echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny"}}'
    
    # Good
    jq -n '{
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "deny",
        permissionDecisionReason: "Not allowed"
      }
    }'
  6. UserPromptSubmit hooks that inject large context. This fires on every single user message. A 500-line context file injected per message burns thousands of tokens per turn.

  7. Ignoring exit codes from tools. If your hook calls npm test but doesn’t check $?, it silently passes even when tests fail.

  8. Multiple blocking hooks that duplicate checks. If you have both a PreToolUse hook and a PermissionRequest hook checking the same thing, you’ll get duplicate prompts or conflicting decisions.

References#