Claude Code Hooks Automation developer tools

Claude Code Hooks: 12 Real-World Automation Patterns 2026

The Prompt Shelf ·

Most Claude Code hook examples stop at desktop notifications. That’s fine for demos, but production teams need hooks that actually gate dangerous operations, enforce code quality, alert on-call engineers, and maintain audit trails.

This guide covers 12 patterns drawn from real usage — organized by category: security gates, quality enforcement, observability, and collaboration. Each pattern includes a copy-paste settings.json snippet and the hook script itself. Every pattern relies on published Claude Code hook behavior from the official docs (2026 release).

If you want the exhaustive reference covering all 25+ lifecycle events, exit codes, and handler types, see the Claude Code Hooks Complete Reference. This article is about what to build with hooks, not the mechanics.


How Hooks Fit Into the Agent Loop

A quick orientation before the patterns. Claude Code hooks are synchronous callbacks registered in settings.json. They fire at specific moments in the agent loop — before a tool runs, after a file write, when a session starts — and can block actions, enrich Claude’s context, or trigger side effects.

The configuration lives in hooks inside settings.json at user, project, or org scope:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/hook-script.sh"
          }
        ]
      }
    ]
  }
}

The critical property that makes policy enforcement possible: exit code 2 from a PreToolUse hook blocks the tool from running. Not a warning, not a suggestion — a hard block. Claude Code reads the hook’s stderr and shows it to Claude as the reason, allowing Claude to adjust its approach.

Exit code 0 continues normally. Any other non-zero code logs to debug output without blocking.


Category 1: Security Gates

Pattern 1 — Block Destructive Shell Commands

The most common security hook. Block rm -rf, DROP TABLE, git push --force, and similar high-stakes commands before they run.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/security-gate.sh"
          }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
# .claude/hooks/security-gate.sh

set -euo pipefail

COMMAND=$(jq -r '.tool_input.command // ""')

# Patterns to block outright
BLOCKED_PATTERNS=(
  "rm -rf /"
  "rm -rf \*"
  "dd if=/dev/zero"
  "mkfs\."
  "> /dev/sd"
  "chmod -R 777"
  "DROP TABLE"
  "DROP DATABASE"
  "TRUNCATE"
  "git push --force"
  "git push -f"
  "git reset --hard HEAD"
)

for pattern in "${BLOCKED_PATTERNS[@]}"; do
  if echo "$COMMAND" | grep -qiE "$pattern"; then
    echo "BLOCKED: Destructive command detected: $pattern" >&2
    echo "Command: $COMMAND" >&2
    echo "If this is intentional, run it manually in a terminal." >&2
    exit 2
  fi
done

# Production environment guard
if echo "$COMMAND" | grep -q "NODE_ENV=production"; then
  echo "BLOCKED: Direct production execution detected." >&2
  echo "Use the deployment pipeline, not claude code." >&2
  exit 2
fi

exit 0

Pattern 2 — Allowlist-Only File Write Paths

Prevent Claude from writing outside permitted directories. Useful in monorepos where Claude should only touch specific packages:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/path-allowlist.sh"
          }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
# .claude/hooks/path-allowlist.sh

set -euo pipefail

FILE_PATH=$(jq -r '.tool_input.file_path // ""')
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"

# Define allowed paths relative to project root
ALLOWED_PREFIXES=(
  "$PROJECT_DIR/src/"
  "$PROJECT_DIR/tests/"
  "$PROJECT_DIR/docs/"
  "$PROJECT_DIR/.claude/"
)

for prefix in "${ALLOWED_PREFIXES[@]}"; do
  if [[ "$FILE_PATH" == "$prefix"* ]]; then
    exit 0
  fi
done

echo "BLOCKED: Write outside allowed paths." >&2
echo "Attempted path: $FILE_PATH" >&2
echo "Allowed: src/, tests/, docs/, .claude/" >&2
exit 2

Pattern 3 — Secret Detection Gate

Scan files before Claude writes them. Catches hardcoded API keys, tokens, and credentials before they land in the codebase:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/secret-scan.sh",
            "timeout": 15
          }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
# .claude/hooks/secret-scan.sh

set -euo pipefail

CONTENT=$(jq -r '.tool_input.content // ""')

# Secret patterns — add your own
SECRET_PATTERNS=(
  "sk-[a-zA-Z0-9]{40,}"          # OpenAI API key
  "AKIA[0-9A-Z]{16}"             # AWS Access Key ID
  "ghp_[a-zA-Z0-9]{36}"         # GitHub Personal Access Token
  "eyJhbGciO"                     # JWT (base64 header)
  "-----BEGIN (RSA|EC|DSA) PRIVATE KEY-----"
  "xoxb-[0-9]{11}-[0-9]{11}"    # Slack Bot Token
  "AIza[0-9A-Za-z\\-_]{35}"     # Google API Key
)

for pattern in "${SECRET_PATTERNS[@]}"; do
  if echo "$CONTENT" | grep -qE "$pattern"; then
    echo "BLOCKED: Potential secret detected in file content." >&2
    echo "Pattern matched: $pattern" >&2
    echo "Use environment variables or a secrets manager instead." >&2
    exit 2
  fi
done

exit 0

Pattern 4 — MCP Tool Scope Restriction

Block Claude from using high-risk MCP tools without explicit approval. Useful when an MCP server has both read and write capabilities and you want to gate the write side:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "mcp__database__.*",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/mcp-gate.sh"
          }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
# .claude/hooks/mcp-gate.sh

set -euo pipefail

TOOL_NAME=$(jq -r '.tool_name // ""')
TOOL_INPUT=$(jq -r '.tool_input | tostring')

# Read-only MCP tools: always allowed
READONLY_TOOLS=(
  "mcp__database__query_select"
  "mcp__database__list_tables"
  "mcp__database__describe_schema"
)

for allowed in "${READONLY_TOOLS[@]}"; do
  if [[ "$TOOL_NAME" == "$allowed" ]]; then
    exit 0
  fi
done

# Write tools: check if query contains SELECT-only patterns
if echo "$TOOL_INPUT" | jq -r '.query // ""' | grep -qiE "^(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE)"; then
  echo "BLOCKED: Mutating database operation requires manual approval." >&2
  echo "Tool: $TOOL_NAME" >&2
  echo "Query: $(echo "$TOOL_INPUT" | jq -r '.query // ""')" >&2
  exit 2
fi

exit 0

Category 2: Quality Enforcement

Pattern 5 — Auto-Lint After Every File Write

Run your linter automatically whenever Claude writes a file. The linter output feeds back into Claude’s context, letting it fix issues in the same turn:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/auto-lint.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
# .claude/hooks/auto-lint.sh
# PostToolUse: non-blocking — tool already ran, but output feeds back to Claude

set -euo pipefail

FILE_PATH=$(jq -r '.tool_input.file_path // ""')

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

# Determine linter by extension
EXT="${FILE_PATH##*.}"
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"

LINT_OUTPUT=""
LINT_EXIT=0

case "$EXT" in
  ts|tsx)
    LINT_OUTPUT=$(cd "$PROJECT_DIR" && npx eslint "$FILE_PATH" --max-warnings 0 2>&1) || LINT_EXIT=$?
    ;;
  py)
    LINT_OUTPUT=$(cd "$PROJECT_DIR" && python -m ruff check "$FILE_PATH" 2>&1) || LINT_EXIT=$?
    ;;
  go)
    LINT_OUTPUT=$(cd "$PROJECT_DIR" && golangci-lint run "$FILE_PATH" 2>&1) || LINT_EXIT=$?
    ;;
  rb)
    LINT_OUTPUT=$(cd "$PROJECT_DIR" && bundle exec rubocop "$FILE_PATH" 2>&1) || LINT_EXIT=$?
    ;;
  *)
    exit 0
    ;;
esac

if [[ $LINT_EXIT -ne 0 ]]; then
  # Output as JSON so Claude gets structured feedback
  jq -n --arg output "$LINT_OUTPUT" --arg file "$FILE_PATH" '{
    systemMessage: ("Lint issues found in \($file):\n\($output)\n\nPlease fix these issues.")
  }'
fi

exit 0

Pattern 6 — Test Runner on Source File Changes

Run relevant tests after Claude modifies source files. Use file path matching to determine which test suite to run:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/auto-test.sh",
            "timeout": 120,
            "async": true
          }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
# .claude/hooks/auto-test.sh
# async: true — tests run in background, output appears when done

set -euo pipefail

FILE_PATH=$(jq -r '.tool_input.file_path // ""')
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"

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

# Derive test file from source path
# src/auth/token.ts -> tests/auth/token.test.ts
RELATIVE="${FILE_PATH#$PROJECT_DIR/}"

if [[ "$RELATIVE" == src/* ]]; then
  TEST_PATH="$PROJECT_DIR/tests/${RELATIVE#src/}"
  TEST_PATH="${TEST_PATH%.ts}.test.ts"
  TEST_PATH="${TEST_PATH%.py}.test.py"

  if [[ -f "$TEST_PATH" ]]; then
    echo "Running tests for $RELATIVE..." >&2
    cd "$PROJECT_DIR"

    if [[ "$FILE_PATH" == *.ts ]] || [[ "$FILE_PATH" == *.tsx ]]; then
      npx vitest run "$TEST_PATH" 2>&1
    elif [[ "$FILE_PATH" == *.py ]]; then
      python -m pytest "$TEST_PATH" -v 2>&1
    fi
  fi
fi

exit 0

Pattern 7 — TypeScript Type Check Gate

Block Claude from considering a task complete if TypeScript type errors exist. This runs at the Stop event — when Claude tries to stop responding — and can force another pass:

{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/typecheck-gate.sh",
            "timeout": 60
          }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
# .claude/hooks/typecheck-gate.sh
# Stop event: exit 2 prevents Claude from stopping, forcing another turn

set -euo pipefail

PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"

# Only run if TypeScript project
if [[ ! -f "$PROJECT_DIR/tsconfig.json" ]]; then
  exit 0
fi

TYPE_ERRORS=$(cd "$PROJECT_DIR" && npx tsc --noEmit 2>&1) || TYPE_EXIT=$?

if [[ ${TYPE_EXIT:-0} -ne 0 ]]; then
  echo "TypeScript type errors detected. Claude must fix them before stopping." >&2
  echo "$TYPE_ERRORS" >&2
  exit 2
fi

exit 0

Use this pattern sparingly — it creates a loop where Claude can’t stop until type errors are resolved. Set a sensible timeout to prevent infinite loops.


Category 3: Observability

Pattern 8 — Audit Log for All File Operations

Maintain an append-only log of every file Claude reads, writes, or edits. Useful for security audits, compliance, and debugging what Claude did in a long session:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Read|Write|Edit|Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/audit-log.sh",
            "async": true
          }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
# .claude/hooks/audit-log.sh

set -euo pipefail

SESSION_ID=$(jq -r '.session_id // "unknown"')
TOOL_NAME=$(jq -r '.tool_name // "unknown"')
TOOL_INPUT=$(jq -r '.tool_input | tostring')
CWD=$(jq -r '.cwd // "unknown"')
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

LOG_DIR="${CLAUDE_PROJECT_DIR:-$HOME}/.claude/audit-logs"
LOG_FILE="$LOG_DIR/$(date +%Y-%m-%d).jsonl"

mkdir -p "$LOG_DIR"

# Extract the most relevant field for each tool
case "$TOOL_NAME" in
  Read|Write|Edit)
    TARGET=$(echo "$TOOL_INPUT" | jq -r '.file_path // .path // "unknown"')
    ;;
  Bash)
    TARGET=$(echo "$TOOL_INPUT" | jq -r '.command // "unknown"' | head -c 200)
    ;;
  *)
    TARGET=$(echo "$TOOL_INPUT" | head -c 100)
    ;;
esac

jq -cn \
  --arg ts "$TIMESTAMP" \
  --arg session "$SESSION_ID" \
  --arg tool "$TOOL_NAME" \
  --arg target "$TARGET" \
  --arg cwd "$CWD" \
  '{timestamp: $ts, session: $session, tool: $tool, target: $target, cwd: $cwd}' \
  >> "$LOG_FILE"

exit 0

Query the audit log later:

# All files written in the last session
jq 'select(.tool == "Write") | .target' ~/.claude/audit-logs/2026-06-04.jsonl

# All bash commands in chronological order
jq 'select(.tool == "Bash") | "\(.timestamp) \(.target)"' ~/.claude/audit-logs/2026-06-04.jsonl -r

Pattern 9 — Session Summary to File

At session end, write a structured summary of what Claude did. Useful for reviewing long sessions or feeding into project management tools:

{
  "hooks": {
    "SessionEnd": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/session-summary.sh"
          }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
# .claude/hooks/session-summary.sh

set -euo pipefail

SESSION_ID=$(jq -r '.session_id // "unknown"')
TRANSCRIPT_PATH=$(jq -r '.transcript_path // ""')
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

SUMMARY_DIR="${CLAUDE_PROJECT_DIR:-$HOME}/.claude/session-summaries"
mkdir -p "$SUMMARY_DIR"

SUMMARY_FILE="$SUMMARY_DIR/${SESSION_ID}.json"

# Count operations from transcript if available
WRITES=0
READS=0
BASH_CMDS=0

if [[ -f "$TRANSCRIPT_PATH" ]]; then
  WRITES=$(jq '[.[] | select(.type == "tool_use" and .name == "Write")] | length' "$TRANSCRIPT_PATH" 2>/dev/null || echo 0)
  READS=$(jq '[.[] | select(.type == "tool_use" and .name == "Read")] | length' "$TRANSCRIPT_PATH" 2>/dev/null || echo 0)
  BASH_CMDS=$(jq '[.[] | select(.type == "tool_use" and .name == "Bash")] | length' "$TRANSCRIPT_PATH" 2>/dev/null || echo 0)
fi

jq -cn \
  --arg ts "$TIMESTAMP" \
  --arg session "$SESSION_ID" \
  --arg transcript "$TRANSCRIPT_PATH" \
  --argjson writes "$WRITES" \
  --argjson reads "$READS" \
  --argjson bash "$BASH_CMDS" \
  '{
    ended_at: $ts,
    session_id: $session,
    transcript: $transcript,
    operations: {writes: $writes, reads: $reads, bash_commands: $bash}
  }' > "$SUMMARY_FILE"

echo "Session summary written to: $SUMMARY_FILE" >&2
exit 0

Category 4: Collaboration and Notification

Pattern 10 — Slack Alert on Task Completion

Notify a Slack channel when Claude finishes a long-running task. Uses Claude’s Stop event with an HTTP webhook:

{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/slack-notify.sh",
            "async": true
          }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
# .claude/hooks/slack-notify.sh
# Requires: SLACK_WEBHOOK_URL in environment

set -euo pipefail

SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-}"
if [[ -z "$SLACK_WEBHOOK_URL" ]]; then
  exit 0
fi

SESSION_ID=$(jq -r '.session_id // "unknown"')
CWD=$(jq -r '.cwd // "unknown"')
TIMESTAMP=$(date "+%H:%M %Z")
PROJECT=$(basename "$CWD")

# Only notify for significant sessions (optional: check transcript for writes)
TRANSCRIPT_PATH=$(jq -r '.transcript_path // ""')
if [[ -f "$TRANSCRIPT_PATH" ]]; then
  WRITE_COUNT=$(jq '[.[] | select(.type == "tool_use" and .name == "Write")] | length' "$TRANSCRIPT_PATH" 2>/dev/null || echo 0)
  if [[ "$WRITE_COUNT" -eq 0 ]]; then
    exit 0  # Don't notify for read-only sessions
  fi
fi

PAYLOAD=$(jq -cn \
  --arg project "$PROJECT" \
  --arg session "$SESSION_ID" \
  --arg time "$TIMESTAMP" \
  '{
    text: "Claude Code session finished",
    blocks: [
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: ":white_check_mark: *Claude Code finished* in `\($project)`\nSession: `\($session)` at \($time)"
        }
      }
    ]
  }')

curl -s -X POST "$SLACK_WEBHOOK_URL" \
  -H "Content-Type: application/json" \
  -d "$PAYLOAD" \
  > /dev/null 2>&1

exit 0

Make SLACK_WEBHOOK_URL available via your environment or a .env file loaded at shell startup. Do not hardcode it in the script.

Pattern 11 — Cross-Platform Desktop Notification

Send a native desktop notification when Claude finishes — works on macOS and Linux without Slack:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/desktop-notify.sh"
          }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
# .claude/hooks/desktop-notify.sh

set -euo pipefail

CWD=$(jq -r '.cwd // "unknown"')
PROJECT=$(basename "$CWD")

TITLE="Claude Code"
MESSAGE="Finished working in $PROJECT"

if command -v osascript &>/dev/null; then
  # macOS
  osascript -e "display notification \"$MESSAGE\" with title \"$TITLE\" sound name \"Glass\""
elif command -v notify-send &>/dev/null; then
  # Linux (libnotify)
  notify-send "$TITLE" "$MESSAGE" --icon=terminal
elif command -v powershell.exe &>/dev/null; then
  # Windows (WSL)
  powershell.exe -Command "
    [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
    \$template = [Windows.UI.Notifications.ToastTemplateType]::ToastText02
    \$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(\$template)
    \$xml.GetElementsByTagName('text')[0].AppendChild(\$xml.CreateTextNode('$TITLE')) | Out-Null
    \$xml.GetElementsByTagName('text')[1].AppendChild(\$xml.CreateTextNode('$MESSAGE')) | Out-Null
    \$toast = [Windows.UI.Notifications.ToastNotification]::new(\$xml)
    [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Claude Code').Show(\$toast)
  "
fi

exit 0

Pattern 12 — GitHub PR Comment on Worktree Commit

When Claude commits to a worktree that corresponds to a PR branch, post a status comment to GitHub automatically:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "if": "Bash(git commit *)",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/github-pr-comment.sh",
            "async": true
          }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
# .claude/hooks/github-pr-comment.sh
# Requires: gh CLI authenticated, GH_REPO env var set to "owner/repo"

set -euo pipefail

GH_REPO="${GH_REPO:-}"
if [[ -z "$GH_REPO" ]]; then
  exit 0
fi

if ! command -v gh &>/dev/null; then
  exit 0
fi

CWD=$(jq -r '.cwd // "$(pwd)"')
COMMAND=$(jq -r '.tool_input.command // ""')

# Get commit message and hash
cd "$CWD" 2>/dev/null || exit 0
COMMIT_MSG=$(git log -1 --format="%s" 2>/dev/null || exit 0)
COMMIT_HASH=$(git log -1 --format="%h" 2>/dev/null || exit 0)
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || exit 0)

# Find associated PR
PR_NUMBER=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [[ -z "$PR_NUMBER" ]]; then
  exit 0
fi

COMMENT="**Claude Code** committed to this PR:\n\n\`\`\`\n$COMMIT_HASH $COMMIT_MSG\n\`\`\`\n\n_Branch: \`$BRANCH\`_"

gh api \
  -X POST \
  "repos/$GH_REPO/issues/$PR_NUMBER/comments" \
  -f body="$COMMENT" \
  > /dev/null 2>&1

exit 0

Composing Multiple Patterns

These patterns are not mutually exclusive. A production setup might combine:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/security-gate.sh" },
          { "type": "command", "command": ".claude/hooks/path-allowlist.sh" }
        ]
      },
      {
        "matcher": "Write",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/secret-scan.sh" }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/auto-lint.sh", "timeout": 30 },
          { "type": "command", "command": ".claude/hooks/audit-log.sh", "async": true }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/typecheck-gate.sh", "timeout": 60 },
          { "type": "command", "command": ".claude/hooks/slack-notify.sh", "async": true }
        ]
      }
    ],
    "SessionEnd": [
      {
        "hooks": [
          { "type": "command", "command": ".claude/hooks/session-summary.sh" }
        ]
      }
    ]
  }
}

Hooks within the same event/matcher run in order. A blocking hook (exit 2) stops the chain — subsequent hooks in the same array don’t run.


Performance Considerations

Synchronous hooks add latency to every matched event. Guidelines:

Hook TypeRecommended Max Duration
Security gate (PreToolUse)< 500ms
Lint check (PostToolUse)< 30s
Test runner (PostToolUse)< 120s (or use async: true)
Type check (Stop)< 60s
Notification (Stop/SessionEnd)Use async: true

Mark slow hooks as async: true when they don’t need to influence Claude’s next action. Async hooks run in the background — Claude continues without waiting for them to finish.

{
  "type": "command",
  "command": ".claude/hooks/slow-test-suite.sh",
  "async": true,
  "asyncRewake": false
}

Set asyncRewake: true if you want Claude to wake up again when the async hook finishes — useful for integrating test results back into the session.


Scoping Hooks to Project vs User

These 12 patterns typically belong in project scope (.claude/settings.json, committed to the repo). Security gates and audit logs should be consistent across all team members.

Notification hooks (Slack, desktop) belong in user scope (~/.claude/settings.json) since they’re personal preferences:

// ~/.claude/settings.json — user scope, applies to all projects
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/desktop-notify.sh" }
        ]
      }
    ]
  }
}
// .claude/settings.json — project scope, committed to repo
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/security-gate.sh" }
        ]
      }
    ]
  }
}

Differentiating from the Complete Reference

This article focuses on 12 specific, production-ready patterns. The Complete Hooks Reference covers every lifecycle event (25+), all five handler types (command, HTTP, MCP tool, prompt, agent), the full exit code matrix, JSON output schemas, and the complete matcher syntax. Use both: the reference for understanding the system, this article for copy-paste starting points.



Frequently Asked Questions

Q: Can a PostToolUse hook block Claude from proceeding?

PostToolUse hooks cannot block the tool that already ran. They can provide feedback via JSON systemMessage that Claude reads before its next action. To block before execution, use PreToolUse with exit code 2.

Q: How do I pass environment variables to hook scripts safely?

For HTTP hooks, use allowedEnvVars to whitelist variables substituted in headers. For command hooks, shell environment variables are available to the script. Never hardcode secrets — load them from .env or a secrets manager at shell startup.

Q: Can hooks filter by file extension?

The matcher field matches tool names, not file paths. Match the tool name (e.g., Write) and then check file_path in the hook script with a case statement or extension check.

Q: What is the difference between exit code 2 and exit code 1?

Exit code 2 is the blocking code for PreToolUse, UserPromptSubmit, Stop, and several other events — it prevents the action and shows stderr to Claude. Exit code 1 (any non-zero except 2) is a non-blocking error — logs to debug, does not block.

Q: Can I modify tool input before it runs?

Via the PermissionRequest hook with updatedInput in the JSON response. This lets you sanitize or transform tool inputs before execution — more advanced than simple blocking.

Related Articles

Explore the collection

Browse all AI coding rules — CLAUDE.md, .cursorrules, AGENTS.md, and more.

Browse Rules