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 Type | Recommended 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.
Related Articles
- Claude Code Hooks: Complete Reference 2026 — All 25+ lifecycle events, handler types, and exit code semantics
- Claude Code Permissions and Trust Levels 2026 — How hooks interact with the permission system
- Claude Code Security Rules: OWASP Patterns — Security-first rules and policies
- Claude Code Monitoring and Observability 2026 — Broader observability patterns beyond hooks
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.