Hook Recipes: 4 Real-World Automations
Four complete hook implementations: auto-formatting after edits, blocking risky paths, logging every command, and desktop notifications on completion.
Learning objectives
- Implement a post-edit hook that auto-formats changed files
- Build a path-protection hook that blocks edits to sensitive files
- Create a logging hook that audits every shell command
- Write a notification hook that alerts you when Claude finishes
ToolDix original visual
Frame
Name the outcome and constraints.
Build
Try one bounded workflow.
Review
Keep evidence, revise, and share.
This lesson provides four complete, copy-paste-ready hook recipes for real workflows. Each is production-tested and includes the full hook script, settings registration, and expected behavior.
Recipe 1: Auto-format on file edit
Problem: Claude edits code files but doesn't run formatters. You want Prettier, Black, or similar to run automatically on every edit.
Solution: Use PostToolUse after the Edit or Write tool completes, detect the file type, and run the appropriate formatter.
Hook script: .claude/hooks/auto-format.sh
#!/bin/bash
# auto-format.sh
# Runs formatters (Prettier, Black, etc.) after Claude edits files.
# Detects file type and runs the appropriate formatter.
set -euo pipefail
input=$(cat)
# Extract fields
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
event=$(echo "$input" | jq -r '.hook_event_name // empty')
# Only trigger on Edit or Write tool completion
if [[ "$tool_name" != "Edit" && "$tool_name" != "Write" ]]; then
exit 0
fi
# Get list of recently edited files from transcript
transcript_path=$(echo "$input" | jq -r '.transcript_path // empty')
cwd=$(echo "$input" | jq -r '.cwd // empty')
if [[ -z "$transcript_path" || ! -f "$transcript_path" ]]; then
exit 0
fi
# Parse transcript JSONL to find recently edited files
# (Last few lines, extract file paths)
edited_files=$(tail -20 "$transcript_path" \
| jq -r 'select(.type == "tool_result")
| select(.tool == "Edit" or .tool == "Write")
| .content | fromjson | .file // empty' 2>/dev/null | sort -u)
# Format each file based on its extension
for file in $edited_files; do
# Skip if file doesn't exist
[[ -f "$cwd/$file" ]] || continue
ext="${file##*.}"
case "$ext" in
js|jsx|ts|tsx|json|yaml|yml|css|html)
# Prettier for JS/TS/web files
if command -v prettier &>/dev/null; then
prettier --write "$cwd/$file" 2>/dev/null || true
echo "Formatted $file with Prettier" >&2
fi
;;
py)
# Black for Python
if command -v black &>/dev/null; then
black "$cwd/$file" 2>/dev/null || true
echo "Formatted $file with Black" >&2
fi
;;
go)
# gofmt for Go
if command -v gofmt &>/dev/null; then
gofmt -w "$cwd/$file" 2>/dev/null || true
echo "Formatted $file with gofmt" >&2
fi
;;
rs)
# rustfmt for Rust
if command -v rustfmt &>/dev/null; then
rustfmt "$cwd/$file" 2>/dev/null || true
echo "Formatted $file with rustfmt" >&2
fi
;;
esac
done
echo '{"reason": "Auto-formatting complete (or skipped if no formatter installed)"}'
exit 0
Settings registration: .claude/settings.json
{
"hooks": [
{
"event": "PostToolUse",
"matcher": "Edit|Write",
"type": "command",
"command": "/absolute/path/.claude/hooks/auto-format.sh",
"timeout": 10,
"continueOnError": true
}
]
}
Expected behavior
User: "Add a new function to utils.js"
Claude: [edits utils.js]
Hook fires (PostToolUse):
✓ Detected utils.js (JavaScript)
✓ Ran Prettier
✓ Formatted with 2-space indent
Claude: "Done. The code is formatted."
Recipe 2: Block edits to protected files
Problem: You want Claude to never edit .env, .secrets, production config files, or other sensitive paths.
Solution: Use PreToolUse on Edit/Write to intercept and block by file path.
Hook script: .claude/hooks/protect-sensitive-files.sh
#!/bin/bash
# protect-sensitive-files.sh
# Blocks editing of sensitive files (.env, secrets, config, etc.)
# Exits with code 2 (blocking error) if file is protected.
set -euo pipefail
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
tool_input=$(echo "$input" | jq -r '.tool_input // {}')
# Only check Edit and Write tools
if [[ "$tool_name" != "Edit" && "$tool_name" != "Write" ]]; then
echo '{"decision": "allow"}'
exit 0
fi
# Extract the file path from tool input
file=$(echo "$tool_input" | jq -r '.file // empty')
if [[ -z "$file" ]]; then
exit 0
fi
# List of sensitive file patterns (glob patterns)
protected_patterns=(
".env*"
".secrets*"
"*.key"
"*.pem"
"*.p12"
".ssh/*"
"config/credentials*"
"src/config/production*"
".github/workflows/*"
"infrastructure/*"
"terraform/*"
"deploy/*"
)
# Check if file matches any protected pattern
for pattern in "${protected_patterns[@]}"; do
if [[ "$file" == $pattern ]]; then
cat >&2 <<EOF
❌ BLOCKED: Cannot edit protected file: $file
This file is protected from automated editing:
- Environment files (.env, .env.local, etc.)
- Secrets and keys (.key, .pem, .secrets)
- Infrastructure-as-code (terraform/, deploy/)
- CI/CD configuration (.github/workflows/)
If you need to edit this file, do it manually or remove it from the protected list.
EOF
exit 2
fi
done
# File is safe
echo '{"decision": "allow", "reason": "File is not protected"}'
exit 0
Settings registration
{
"hooks": [
{
"event": "PreToolUse",
"matcher": "Edit|Write",
"type": "command",
"command": "/absolute/path/.claude/hooks/protect-sensitive-files.sh",
"timeout": 2,
"continueOnError": false
}
]
}
Expected behavior
User: "Update the database connection string"
Claude: "I'll update the config... I'll modify .env to include the new connection string"
Hook blocks:
❌ BLOCKED: Cannot edit protected file: .env
Claude: (can't proceed; asks user to manually update .env)
Recipe 3: Audit every shell command
Problem: You want a full audit log of every command Claude runs, including timestamp, exit code, and output snippet.
Solution: Use PreToolUse and PostToolUse on Bash to log command execution.
Hook script: .claude/hooks/audit-bash.sh
#!/bin/bash
# audit-bash.sh
# Logs every Bash command Claude executes.
# Creates an audit log at ~/.claude-audit.jsonl
set -euo pipefail
input=$(cat)
hook_event=$(echo "$input" | jq -r '.hook_event_name // empty')
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
session_id=$(echo "$input" | jq -r '.session_id // empty')
cwd=$(echo "$input" | jq -r '.cwd // empty')
tool_call_id=$(echo "$input" | jq -r '.tool_call_id // empty')
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Only log Bash commands
if [[ "$tool_name" != "Bash" ]]; then
exit 0
fi
# Get audit log path
audit_log="${HOME}/.claude-audit.jsonl"
case "$hook_event" in
PreToolUse)
# Log command before execution
command=$(echo "$input" | jq -r '.tool_input.command // empty')
cat >> "$audit_log" <<EOF
{"event": "PreToolUse", "timestamp": "$timestamp", "session_id": "$session_id", "tool_call_id": "$tool_call_id", "cwd": "$cwd", "command": "$command"}
EOF
echo '{"reason": "Logged command"}'
;;
PostToolUse)
# Log successful execution
exit_code=$(echo "$input" | jq -r '.exit_code // 0')
exec_time=$(echo "$input" | jq -r '.execution_time_ms // 0')
cat >> "$audit_log" <<EOF
{"event": "PostToolUse", "timestamp": "$timestamp", "session_id": "$session_id", "tool_call_id": "$tool_call_id", "exit_code": $exit_code, "execution_time_ms": $exec_time}
EOF
echo '{"reason": "Logged completion"}'
;;
PostToolUseFailure)
# Log failed execution
exit_code=$(echo "$input" | jq -r '.exit_code // 1')
error=$(echo "$input" | jq -r '.error // "unknown error"')
cat >> "$audit_log" <<EOF
{"event": "PostToolUseFailure", "timestamp": "$timestamp", "session_id": "$session_id", "tool_call_id": "$tool_call_id", "exit_code": $exit_code, "error": "$error"}
EOF
echo '{"reason": "Logged failure"}'
;;
esac
exit 0
Settings registration
{
"hooks": [
{
"event": "PreToolUse",
"matcher": "Bash",
"type": "command",
"command": "/absolute/path/.claude/hooks/audit-bash.sh",
"continueOnError": true
},
{
"event": "PostToolUse",
"matcher": "Bash",
"type": "command",
"command": "/absolute/path/.claude/hooks/audit-bash.sh",
"continueOnError": true
},
{
"event": "PostToolUseFailure",
"matcher": "Bash",
"type": "command",
"command": "/absolute/path/.claude/hooks/audit-bash.sh",
"continueOnError": true
}
]
}
Audit log inspection
# View the audit log
tail -20 ~/.claude-audit.jsonl | jq .
# Output:
# {"event": "PreToolUse", "timestamp": "2026-07-23T14:30:45Z", "session_id": "sess_abc", "tool_call_id": "tool_1", "cwd": "/home/user/project", "command": "npm test"}
# {"event": "PostToolUse", "timestamp": "2026-07-23T14:30:47Z", "session_id": "sess_abc", "tool_call_id": "tool_1", "exit_code": 0, "execution_time_ms": 2450}
# Analyze: count commands by type
cat ~/.claude-audit.jsonl | jq -r '.command' | sort | uniq -c | sort -rn
# Analyze: find failed commands
cat ~/.claude-audit.jsonl | jq 'select(.event == "PostToolUseFailure")'
Recipe 4: Desktop notification on completion
Problem: You want a desktop notification (macOS, Linux, or Windows) when Claude finishes a task so you don't have to watch the terminal.
Solution: Use Stop hook to trigger system notifications.
Hook script: .claude/hooks/notify-completion.sh
#!/bin/bash
# notify-completion.sh
# Sends desktop notification when Claude finishes a turn.
# Supports macOS (osascript), Linux (notify-send), and Windows (PowerShell).
set -euo pipefail
input=$(cat)
session_id=$(echo "$input" | jq -r '.session_id // "unknown"')
turn=$(echo "$input" | jq -r '.turn_number // 0')
tool_count=$(echo "$input" | jq -r '.tool_call_count // 0')
# Determine OS and send notification accordingly
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS: use osascript
osascript <<EOF
display notification "Claude Code completed turn #$turn ($tool_count tools)" \
with title "Claude Code" \
subtitle "Session: $session_id"
EOF
elif command -v notify-send &>/dev/null; then
# Linux: use notify-send
notify-send -a "Claude Code" "Claude finished" \
"Turn #$turn completed with $tool_count tools\nSession: $session_id"
elif command -v powershell &>/dev/null; then
# Windows: use PowerShell toast
powershell -Command "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null; [Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null; @('<toast><visual><binding template=\"ToastText02\"><text id=\"1\">Claude Code</text><text id=\"2\">Turn #$turn completed with $tool_count tools</text></binding></visual></toast>') | foreach {[xml]\$xml = \$_; [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Claude Code').Show([Windows.UI.Notifications.ToastNotification]::new(\$xml)) }" 2>/dev/null || true
fi
echo '{"reason": "Notification sent"}'
exit 0
Settings registration
{
"hooks": [
{
"event": "Stop",
"type": "command",
"command": "/absolute/path/.claude/hooks/notify-completion.sh",
"continueOnError": true
}
]
}
Expected behavior
When Claude finishes a turn:
- macOS: Small notification pops from top-right corner: "Claude Code: Claude completed turn #3 (5 tools)"
- Linux: System tray notification appears
- Windows: Toast notification in lower-right corner
You can now step away from the terminal and get a notification when Claude finishes.
Testing recipes locally
Before registering in settings, test each hook manually:
# Test Recipe 1 (auto-format)
echo '{
"hook_event_name": "PostToolUse",
"tool_name": "Write",
"transcript_path": "~/.claude/sessions/transcript.jsonl",
"cwd": "/tmp"
}' | bash .claude/hooks/auto-format.sh
# Test Recipe 2 (protect-sensitive)
echo '{
"hook_event_name": "PreToolUse",
"tool_name": "Edit",
"tool_input": {"file": ".env"}
}' | bash .claude/hooks/protect-sensitive-files.sh
# Expected: Exit code 2, error on stderr
# Test Recipe 4 (notify)
echo '{
"hook_event_name": "Stop",
"session_id": "test",
"turn_number": 1,
"tool_call_count": 5
}' | bash .claude/hooks/notify-completion.sh
Common mistake
Assuming hooks fire in the order registered. Hooks don't have a guaranteed execution order. If you depend on one hook running before another (e.g., validation before formatting), register them separately and test thoroughly. Also, never put long-running operations in PreToolUse hooks — if your hook takes 30 seconds, Claude will timeout waiting for it. Keep Pre/Post hooks fast (under 5 seconds) and offload heavy work to background tasks.
Sources and license context
These references informed the lesson. ToolDix adds its own explanation, workflow, and practice rather than reproducing source material. Every link below leaves ToolDix and opens the publisher's own site in a new tab.
- Hooks Guide (opens code.claude.com in a new tab)External · code.claude.com (Anthropic terms apply)
- Hooks Reference (opens code.claude.com in a new tab)External · code.claude.com (Anthropic terms apply)
Keep going
Read these next on ToolDix.
Original lessons that build on what you just read.