Skip to main content

Playbook

Every hook configuration here has been tested in production workflows. Copy them directly into your .claude/settings.json or adapt them to your stack. Each recipe includes the rationale behind design choices — not just the config.

Auto-Formatting on File Save

The most common hook. Run your formatter after every Edit or Write operation so Claude never commits unformatted code. All formatters use the same structure — a PostToolUse hook on Edit|Write:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write",
            "statusMessage": "Formatting..."
          }
        ]
      }
    ]
  }
}

Swap the command for your language's formatter:

LanguageCommand
JS/TS (Prettier)jq -r '.tool_input.file_path' | xargs npx prettier --write
Python (Black)FILE=$(jq -r '.tool_input.file_path'); [[ "$FILE" == *.py ]] && black "$FILE" || true
Go (gofmt)FILE=$(jq -r '.tool_input.file_path'); [[ "$FILE" == *.go ]] && gofmt -w "$FILE" || true
Rust (rustfmt)FILE=$(jq -r '.tool_input.file_path'); [[ "$FILE" == *.rs ]] && rustfmt "$FILE" || true

The || true at the end of language-filtered variants matters. Without it, a non-matching file extension causes the [[ test to return exit code 1, which shows a warning in the transcript.

Security Gates

Security hooks belong in PreToolUse because it fires before permissions and can hard-block operations. These are the safety-net pattern implemented as code.

Block Dangerous Git Operations

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git *)",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/git-policy.sh"
          }
        ]
      }
    ]
  }
}

The if field (available since v2.1.85) uses permission rule syntax to narrow which Bash calls trigger the hook. Without it, every Bash command spawns a process — wasteful when you only care about git.

.claude/hooks/git-policy.sh:

#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
 
# Force pushes to protected branches
if echo "$COMMAND" | grep -qE 'git push.*(--force|-f).*(main|master|release)'; then
  echo "BLOCKED: Force push to protected branch" >&2
  exit 2
fi
 
# Commits that skip pre-commit hooks
if echo "$COMMAND" | grep -qE 'git commit.*--no-verify'; then
  echo "BLOCKED: --no-verify bypasses pre-commit hooks" >&2
  exit 2
fi
 
# Hard resets that destroy uncommitted work
if echo "$COMMAND" | grep -qE 'git reset --hard'; then
  echo "BLOCKED: git reset --hard destroys uncommitted changes" >&2
  exit 2
fi
 
# Branch deletion of protected branches
if echo "$COMMAND" | grep -qE 'git branch\s+-[dD]\s+(main|master|release)'; then
  echo "BLOCKED: Cannot delete protected branch" >&2
  exit 2
fi
 
exit 0

Block Destructive Shell Commands

#!/bin/bash
# .claude/hooks/bash-safety.sh
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
 
# Recursive delete from root
if echo "$COMMAND" | grep -qE 'rm\s+-rf\s+/'; then
  echo "BLOCKED: Recursive delete from root" >&2
  exit 2
fi
 
# Pipe-to-shell (supply chain attack vector)
if echo "$COMMAND" | grep -qE 'curl.*\|\s*(bash|sh|zsh)'; then
  echo "BLOCKED: Pipe-to-shell is a supply chain risk" >&2
  exit 2
fi
 
# Credential exfiltration patterns
if echo "$COMMAND" | grep -qE 'curl.*(AWS_SECRET|API_KEY|TOKEN|PASSWORD)'; then
  echo "BLOCKED: Potential credential exfiltration" >&2
  exit 2
fi
 
# Database destruction
if echo "$COMMAND" | grep -qiE '(DROP\s+(TABLE|DATABASE)|TRUNCATE|DELETE\s+FROM\s+\w+\s*$)'; then
  echo "BLOCKED: Destructive database operation" >&2
  exit 2
fi
 
exit 0

Register it:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/bash-safety.sh"
          }
        ]
      }
    ]
  }
}

Protect Sensitive Files

Block edits to files that should never be modified by the agent:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
          }
        ]
      }
    ]
  }
}
#!/bin/bash
# .claude/hooks/protect-files.sh — register with PreToolUse, matcher: Edit|Write
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
 
PROTECTED=(".env" "package-lock.json" "yarn.lock" ".git/" "credentials" "secrets" ".ssh/")
 
for pattern in "${PROTECTED[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "BLOCKED: $FILE_PATH matches protected pattern '$pattern'" >&2
    exit 2
  fi
done
exit 0

Validation Pipelines

Post-edit validation catches type errors and lint violations before they compound across multiple files. These hooks implement the checkpoint-loop pattern — Claude gets immediate feedback and self-corrects.

Lint + Type-Check After TypeScript Edits

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "FILE=$(jq -r '.tool_input.file_path'); if [[ \"$FILE\" == *.ts || \"$FILE\" == *.tsx ]]; then npx eslint --fix \"$FILE\" 2>&1; npx tsc --noEmit 2>&1 | head -20; fi",
            "timeout": 30,
            "statusMessage": "Linting and type-checking..."
          }
        ]
      }
    ]
  }
}

The timeout: 30 is deliberate. Type-checking a large project can take 10+ seconds. Without a timeout, a broken tsconfig.json hangs your session for 600 seconds (the default). The head -20 caps output — Claude doesn't need 200 lines of type errors to understand the problem.

Auto-Approve Specific Permissions

Skip the approval dialog for operations you always want to allow:

{
  "hooks": {
    "PermissionRequest": [
      {
        "matcher": "ExitPlanMode",
        "hooks": [
          {
            "type": "command",
            "command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PermissionRequest\", \"decision\": {\"behavior\": \"allow\"}}}'"
          }
        ]
      }
    ]
  }
}

Keep auto-approve hooks narrowly scoped. An empty matcher here would auto-approve every permission prompt — effectively --dangerously-skip-permissions without the flag. Limit it to specific, safe operations like ExitPlanMode.

Notification Systems

Desktop notifications use the Notification event with platform-specific commands:

PlatformCommand
macOSosascript -e 'display notification "Claude Code needs attention" with title "Claude Code"'
Linuxnotify-send 'Claude Code' 'Needs attention'
Windowspowershell -c "[System.Windows.Forms.MessageBox]::Show('Needs attention')"
{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code needs attention\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}

HTTP Webhooks and Audit Logging

HTTP hooks send the full stdin JSON as the request body. Use Stop events for "task complete" notifications to Slack or Discord:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "http",
            "url": "https://hooks.slack.com/services/T00/B00/xxx",
            "headers": { "Content-Type": "application/json" }
          }
        ]
      }
    ]
  }
}

For audit logging, log every Bash command asynchronously so it never blocks the agent:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.command' >> ~/.claude/command-log.txt",
            "async": true
          }
        ]
      }
    ]
  }
}

Context Re-Injection After Compaction

When Claude compacts its context window, instructions can get lost. SessionStart hooks with the compact matcher re-inject critical context. The stdin JSON for SessionStart includes the trigger type:

{
  "session_id": "abc123",
  "hook_event_name": "SessionStart",
  "cwd": "/home/user/project",
  "permission_mode": "default",
  "trigger": "compact"
}

The dynamic approach pulls live project state rather than maintaining a static string:

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "echo '--- Post-compaction context ---'; echo 'Package manager: bun (not npm)'; git log --oneline -10; echo '--- Branch: '$(git branch --show-current)"
          }
        ]
      }
    ]
  }
}

Hook Field Reference

Every hook handler accepts these fields:

FieldTypeDefaultPurpose
typestringrequired"command", "http", "prompt", or "agent"
commandstringShell command to execute
ifstringPermission rule syntax filter (v2.1.85+)
shellstring"bash"Shell to use. "powershell" on Windows.
timeoutnumber600/30/60Seconds before kill. Varies by handler type.
statusMessagestringUI message shown while hook runs
asyncbooleanfalseNon-blocking execution
asyncRewakebooleanfalseWake Claude from idle on exit 2
oncebooleanfalseFire only once per session
modelstringHaikuModel override for prompt-type hooks

Set "disableAllHooks": true at the top level of settings.json to disable all hooks without removing configurations — the emergency kill switch for debugging hook issues:

{
  "disableAllHooks": true,
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/git-policy.sh"
          }
        ]
      }
    ]
  }
}

All hooks remain defined but none execute. Remove the flag to re-enable them.