Practical Hook Patterns
From theory to automation
The previous section covered hook mechanics: events, input contracts, exit codes, and JSON output. This section puts that knowledge to work with patterns that solve real problems.
Each pattern here follows a consistent structure: what problem it solves, how the hook implements it, and the configuration. These are not abstract examples. They are the patterns that production teams actually deploy.
Automatic formatting after edits
The most common hook pattern: format code after every edit, regardless of what Claude generates. A PostToolUse hook intercepts file modifications and runs the appropriate formatter.
Language-aware formatting
A single hook can handle multiple languages by inspecting the file extension:
#!/bin/bash
# .claude/hooks/format-code.sh
input=$(cat /dev/stdin)
file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty')
if [ -z "$file_path" ] || [ ! -f "$file_path" ]; then
exit 0
fi
extension="${file_path##*.}"
case "$extension" in
js|jsx|ts|tsx|json|css|scss|md|mdx|yaml|yml)
if command -v prettier &> /dev/null; then
prettier --write "$file_path" 2>/dev/null
fi
;;
py)
if command -v ruff &> /dev/null; then
ruff format "$file_path" 2>/dev/null
elif command -v black &> /dev/null; then
black --quiet "$file_path" 2>/dev/null
fi
;;
go)
gofmt -w "$file_path" 2>/dev/null
if command -v goimports &> /dev/null; then
goimports -w "$file_path" 2>/dev/null
fi
;;
rs)
if command -v rustfmt &> /dev/null; then
rustfmt "$file_path" 2>/dev/null
fi
;;
kt|kts)
if command -v ktlint &> /dev/null; then
ktlint --format "$file_path" 2>/dev/null
fi
;;
esac
exit 0Configure the hook to fire after any file modification:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format-code.sh"
}
]
}
]
}
}The hook silences formatter output by redirecting stderr to /dev/null.
No noisy formatting messages in the conversation.
Claude sees only the final formatted result when it reads files back.
Why PostToolUse, not instructions
The temptation is to put formatting instructions in CLAUDE.md: "Always format code with Prettier before committing." This fails in practice.
Claude might forget. Claude might decide formatting is unnecessary for a quick change. Claude might hit an error and skip formatting to finish faster.
A PostToolUse hook removes that uncertainty. The formatter runs after every edit. Every time. Claude's formatting habits become irrelevant because formatting happens at the application level.
This principle extends beyond formatting. Anything that must happen every time belongs in a hook, not in instructions. Instructions are for guidance Claude should usually follow. Hooks are for requirements that cannot be skipped.
File protection patterns
PreToolUse hooks block operations before they happen. The agent cannot bypass this protection, even accidentally.
Blocking sensitive files
The basic pattern checks file paths against a blocklist:
#!/bin/bash
# .claude/hooks/protect-sensitive.sh
input=$(cat /dev/stdin)
file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty')
# Patterns to block
blocked_patterns=(
".env"
".env.local"
".env.production"
"credentials.json"
"secrets.yaml"
"*.pem"
"*.key"
"id_rsa"
"id_ed25519"
)
for pattern in "${blocked_patterns[@]}"; do
case "$file_path" in
*$pattern*)
echo "Blocked: cannot modify sensitive file matching pattern '$pattern'" >&2
exit 2
;;
esac
done
# Block path traversal attempts
if [[ "$file_path" == *".."* ]]; then
echo "Blocked: path traversal detected" >&2
exit 2
fi
exit 0Configure for Edit, Write, and Read operations:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|Read",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/protect-sensitive.sh"
}
]
}
]
}
}Blocking Read, Edit, and Write does not block Bash.
A command like cat .env or echo "secret" >> .env bypasses the hook entirely.
For complete protection, add a separate Bash hook that inspects command content, or use Bash deny patterns in permissions.
Protecting generated files
Some files should never be manually edited because they are generated from other sources:
#!/bin/bash
# .claude/hooks/protect-generated.sh
input=$(cat /dev/stdin)
file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty')
generated_files=(
"package-lock.json"
"yarn.lock"
"pnpm-lock.yaml"
"composer.lock"
"Cargo.lock"
"poetry.lock"
"dist/"
"build/"
"node_modules/"
".next/"
"__pycache__/"
)
for pattern in "${generated_files[@]}"; do
case "$file_path" in
*$pattern*)
echo "Blocked: '$file_path' is generated. Modify the source file instead." >&2
exit 2
;;
esac
done
exit 0The error message guides Claude toward the correct approach.
Instead of editing package-lock.json, Claude should modify package.json and run npm install.
The hook enforces the workflow without requiring Claude to remember it.
Logging for compliance
Enterprise environments often require audit trails of agent activity. Hooks enable logging without modifying Claude Code itself.
Tool call logging
A PreToolUse hook can log every tool invocation:
#!/bin/bash
# .claude/hooks/audit-log.sh
input=$(cat /dev/stdin)
log_file="$CLAUDE_PROJECT_DIR/.claude/audit.log"
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
session_id=$(echo "$input" | jq -r '.session_id')
tool_name=$(echo "$input" | jq -r '.tool_name')
tool_input=$(echo "$input" | jq -c '.tool_input')
echo "$timestamp | session=$session_id | tool=$tool_name | input=$tool_input" >> "$log_file"
exit 0This creates an append-only log of every tool Claude attempts to use:
2026-01-26T14:32:15Z | session=abc123 | tool=Edit | input={"file_path":"/src/app.ts","old_string":"...","new_string":"..."}
2026-01-26T14:32:18Z | session=abc123 | tool=Bash | input={"command":"npm test"}
2026-01-26T14:32:45Z | session=abc123 | tool=Write | input={"file_path":"/src/utils.ts","content":"..."}The log records what Claude attempted, not what succeeded. Pair with a PostToolUse hook to log outcomes:
#!/bin/bash
# .claude/hooks/audit-log-result.sh
input=$(cat /dev/stdin)
log_file="$CLAUDE_PROJECT_DIR/.claude/audit.log"
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
session_id=$(echo "$input" | jq -r '.session_id')
tool_name=$(echo "$input" | jq -r '.tool_name')
# Truncate output to avoid massive logs
tool_output=$(echo "$input" | jq -r '.tool_output[:500]')
echo "$timestamp | session=$session_id | tool=$tool_name | result=success | output=$tool_output" >> "$log_file"
exit 0Structured JSON logging
For integration with log aggregation systems, output structured JSON:
#!/usr/bin/env python3
# .claude/hooks/structured-audit.py
import json
import sys
import os
from datetime import datetime
input_data = json.load(sys.stdin)
log_file = os.path.join(os.environ.get('CLAUDE_PROJECT_DIR', '.'), '.claude', 'audit.jsonl')
log_entry = {
"timestamp": datetime.utcnow().isoformat() + "Z",
"session_id": input_data.get("session_id"),
"event": input_data.get("hook_event_name"),
"tool": input_data.get("tool_name"),
"input": input_data.get("tool_input"),
"user": os.environ.get("USER"),
"project": os.environ.get("CLAUDE_PROJECT_DIR")
}
os.makedirs(os.path.dirname(log_file), exist_ok=True)
with open(log_file, "a") as f:
f.write(json.dumps(log_entry) + "\n")
sys.exit(0)JSONL format integrates with tools like Elasticsearch, Splunk, and Datadog for compliance monitoring and anomaly detection.
Custom notifications
The Notification event fires when Claude Code wants to alert the user. Hooks can route these notifications to external systems.
Desktop notifications
On macOS, use osascript for native notifications:
#!/bin/bash
# .claude/hooks/notify-macos.sh
input=$(cat /dev/stdin)
message=$(echo "$input" | jq -r '.message // "Claude Code notification"')
osascript -e "display notification \"$message\" with title \"Claude Code\""
exit 0On Linux, use notify-send:
#!/bin/bash
# .claude/hooks/notify-linux.sh
input=$(cat /dev/stdin)
message=$(echo "$input" | jq -r '.message // "Claude Code notification"')
notify-send "Claude Code" "$message"
exit 0Slack notifications
For team visibility, route notifications to Slack:
#!/bin/bash
# .claude/hooks/notify-slack.sh
input=$(cat /dev/stdin)
message=$(echo "$input" | jq -r '.message // "Claude Code notification"')
session_id=$(echo "$input" | jq -r '.session_id')
webhook_url="$SLACK_WEBHOOK_URL"
if [ -n "$webhook_url" ]; then
curl -s -X POST "$webhook_url" \
-H "Content-type: application/json" \
--data "{\"text\": \"*Claude Code*: $message\nSession: \`$session_id\`\"}" \
> /dev/null
fi
exit 0Stop event notifications
The Stop event fires when Claude finishes responding. A hook can notify when long-running tasks complete:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Task complete\" with title \"Claude Code\" sound name \"Glass\"'"
}
]
}
]
}
}The sound cue helps when working on other tasks while Claude runs a lengthy operation.
Input modification with updatedInput
Since Claude Code v2.0.10, PreToolUse hooks can modify tool inputs before execution. This enables transformations that would otherwise require blocking and re-prompting.
Enforcing safe defaults
A hook can inject safety flags into commands:
#!/usr/bin/env python3
# .claude/hooks/safe-bash.py
import json
import sys
input_data = json.load(sys.stdin)
if input_data.get('tool_name') != 'Bash':
sys.exit(0)
command = input_data.get('tool_input', {}).get('command', '')
# Add --dry-run to destructive git commands
if 'git clean' in command and '--dry-run' not in command:
modified_command = command.replace('git clean', 'git clean --dry-run')
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Added --dry-run for safety",
"updatedInput": {
"command": modified_command,
"description": input_data.get('tool_input', {}).get('description', '')
},
"additionalContext": "Note: --dry-run was automatically added. Review output before running without it."
}
}
print(json.dumps(output))
sys.exit(0)Claude sees the modified command in the output, understands the safety flag was added, and can adjust if actually destructive execution is needed.
Redirecting writes to sandbox
For risky experimentation, redirect writes to a sandbox directory:
#!/usr/bin/env python3
# .claude/hooks/sandbox-writes.py
import json
import sys
import os
input_data = json.load(sys.stdin)
tool_name = input_data.get('tool_name', '')
if tool_name not in ['Write', 'Edit']:
sys.exit(0)
tool_input = input_data.get('tool_input', {})
original_path = tool_input.get('file_path', '')
# Only sandbox paths outside project
project_dir = os.environ.get('CLAUDE_PROJECT_DIR', '')
if not original_path.startswith(project_dir):
sandbox_path = os.path.join(project_dir, '.sandbox', original_path.lstrip('/'))
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": f"Redirected to sandbox: {sandbox_path}",
"updatedInput": {
**tool_input,
"file_path": sandbox_path
}
}
}
print(json.dumps(output))
sys.exit(0)Hook composition patterns
Complex automation requires multiple hooks working together.
Layered validation
Separate hooks for different concerns keep configuration readable:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{"type": "command", "command": ".claude/hooks/protect-sensitive.sh"},
{"type": "command", "command": ".claude/hooks/protect-generated.sh"},
{"type": "command", "command": ".claude/hooks/validate-paths.sh"}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{"type": "command", "command": ".claude/hooks/format-code.sh"},
{"type": "command", "command": ".claude/hooks/audit-log.sh"}
]
}
]
}
}Hooks in the same array execute sequentially. If any PreToolUse hook exits with code 2, subsequent hooks do not run and the tool call is blocked.
Team hook distribution
Store hooks in the repository so the team shares automation:
.claude/
├── settings.json # Hook configuration
└── hooks/
├── protect-sensitive.sh
├── format-code.sh
├── audit-log.sh
└── README.md # Documents what each hook doesThe README documents what each hook does. New team members understand the automation without reading every script. When hooks change, code review ensures everyone agrees on the new behavior.
Hooks in .claude/settings.json affect everyone working on the project.
Personal hooks that should not apply to teammates belong in ~/.claude/settings.json instead.
Hooks transform Claude Code from a conversational tool into a programmable platform. Formatting, protection, logging, notifications, input modification: these patterns are the foundation. The automation you build on top depends on your workflow and compliance requirements.