Mastering Claude Code Hooks: Automate Formatting, Validation, and Logging
Claude Code hooks are shell commands that execute at specific points in Claude's workflow. They sound simple, but they have completely changed how I work with AI-assisted development. Every file edit now automatically runs through Prettier. Every dangerous command gets blocked before execution. Every tool call gets logged for compliance.
After setting up hooks for my team's projects, I cannot imagine going back to manual formatting or hoping Claude does not accidentally run something destructive.
What Hooks Actually Do
Hooks intercept Claude Code at eight lifecycle points:
| Hook Event | When It Fires | Common Uses |
|---|---|---|
PreToolUse | Before any tool executes | Block dangerous commands, validate inputs |
PostToolUse | After a tool completes | Format code, run linters, send notifications |
PermissionRequest | When permission dialogs appear | Auto-approve safe operations |
UserPromptSubmit | When you submit a prompt | Log queries, transform inputs |
Notification | When Claude sends alerts | Route to Slack, email, etc. |
Stop | When Claude finishes responding | Validate completeness, trigger follow-ups |
SubagentStop | When a sub-agent completes | Aggregate results, chain tasks |
Setup | When Claude Code starts | Initialize environment, load configs |
The real power comes from combining these hooks. I will show you how.
Setting Up Your First Hook
The fastest way to create a hook is the /hooks command:
> /hooksThis interactive wizard walks you through:
- Selecting an event (e.g., PostToolUse)
- Setting a matcher pattern (e.g.,
Write|Editfor file operations) - Defining your command (e.g.,
prettier --write "$file")
For more control, edit the settings file directly.
Configuration Locations
Hooks live in JSON configuration files:
- Global:
~/.claude/settings.json- applies to all projects - Project:
.claude/settings.json- overrides global for this project
Project-specific hooks should go in version control so your team shares the same automation.
Automatic Code Formatting
This is the hook I recommend everyone sets up first. It runs formatters automatically after Claude edits any file:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "prettier --write \"$CLAUDE_FILE_PATH\" 2>/dev/null || true",
"timeout": 30
}
]
}
]
}
}The matcher pattern Write|Edit triggers on both file creation and modification. The command runs Prettier on the affected file. The 2>/dev/null || true ensures the hook does not fail if Prettier is not installed or the file type is not supported.
Multi-Formatter Setup
For projects with multiple languages, chain formatters:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "prettier --write \"$CLAUDE_FILE_PATH\" 2>/dev/null; black \"$CLAUDE_FILE_PATH\" 2>/dev/null; rustfmt \"$CLAUDE_FILE_PATH\" 2>/dev/null; exit 0",
"timeout": 60
}
]
}
]
}
}Each formatter runs and silently skips files it does not handle. TypeScript files get Prettier, Python files get Black, Rust files get rustfmt.
Blocking Dangerous Commands
The PreToolUse hook can prevent commands from executing. I use this to protect production configurations and prevent accidental deletions:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "check-command-safety.sh"
}
]
}
]
}
}The check-command-safety.sh script:
#!/bin/bash
# check-command-safety.sh
COMMAND="$CLAUDE_TOOL_INPUT"
# Block rm -rf on critical paths
if [[ "$COMMAND" =~ rm[[:space:]]+-rf[[:space:]]+(/|/etc|/var|/home|~) ]]; then
echo "BLOCKED: Dangerous rm -rf command targeting critical path" >&2
exit 2
fi
# Block force pushes
if [[ "$COMMAND" =~ git[[:space:]]+push[[:space:]]+.*--force ]]; then
echo "BLOCKED: Force push not allowed" >&2
exit 2
fi
# Block editing production configs
if [[ "$COMMAND" =~ (vi|vim|nano|code)[[:space:]]+.*\.env\.prod ]]; then
echo "BLOCKED: Direct editing of production env files" >&2
exit 2
fi
# Allow everything else
exit 0Exit code 2 tells Claude Code to block the command and show the error message. Exit code 0 allows execution.
PreToolUse Response Format
For more sophisticated control, return JSON instead of using exit codes:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Production environment detected - manual approval required",
"additionalContext": "This command would affect production. Please confirm in the UI."
}
}Permission decisions:
allow- Execute the commanddeny- Block executionask- Prompt the user for confirmation in the UI
Audit Logging for Compliance
For enterprise environments, logging every tool call is often a compliance requirement:
{
"hooks": {
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "log-claude-action.sh"
}
]
}
]
}
}The logging script:
#!/bin/bash
# log-claude-action.sh
LOG_DIR="/var/log/claude-audit"
mkdir -p "$LOG_DIR"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
LOG_FILE="$LOG_DIR/$(date +%Y-%m-%d).log"
# Build JSON log entry
cat >> "$LOG_FILE" << EOF
{
"timestamp": "$TIMESTAMP",
"session_id": "$CLAUDE_SESSION_ID",
"event": "$CLAUDE_HOOK_EVENT",
"tool": "$CLAUDE_TOOL_NAME",
"working_dir": "$CLAUDE_CWD",
"user": "$(whoami)",
"hostname": "$(hostname)"
}
EOF
exit 0This creates daily log files with JSON entries for every tool call. You can pipe these to your SIEM, send to CloudWatch, or aggregate however your compliance team requires.
Intelligent Stop Hooks
The Stop hook fires when Claude finishes responding. You can use it to validate that Claude actually completed the task:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Review the conversation and determine if all requested tasks are complete. If any task is unfinished or there are errors to address, respond with {\"ok\": false, \"reason\": \"explanation\"}. If everything is done, respond with {\"ok\": true}.",
"timeout": 30
}
]
}
]
}
}The type: "prompt" makes this an LLM-evaluated hook rather than a shell command. Claude reviews its own work and decides whether to continue or stop.
I find this particularly useful for complex refactoring tasks where it is easy to forget one of several requested changes.
Environment Variables in Hooks
Hooks receive context through environment variables:
| Variable | Description |
|---|---|
CLAUDE_SESSION_ID | Current conversation identifier |
CLAUDE_HOOK_EVENT | Which hook event fired |
CLAUDE_TOOL_NAME | Tool being called |
CLAUDE_TOOL_INPUT | Input parameters to the tool |
CLAUDE_FILE_PATH | File being modified (for Write/Edit) |
CLAUDE_CWD | Current working directory |
CLAUDE_TRANSCRIPT_PATH | Path to conversation history |
Use these to make context-aware decisions:
#!/bin/bash
# Only format JavaScript files in the src directory
if [[ "$CLAUDE_FILE_PATH" =~ ^src/.*\.(js|ts|jsx|tsx)$ ]]; then
prettier --write "$CLAUDE_FILE_PATH"
fiNotification Routing
Route Claude's notifications to external services:
{
"hooks": {
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "send-to-slack.sh"
}
]
}
]
}
}The Slack script:
#!/bin/bash
# send-to-slack.sh
WEBHOOK_URL="${SLACK_WEBHOOK_URL}"
MESSAGE="$CLAUDE_NOTIFICATION_MESSAGE"
curl -s -X POST "$WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d "{\"text\": \"Claude Code: $MESSAGE\"}"Now when Claude needs your attention, you get a Slack message instead of (or in addition to) terminal notifications.
Hooks for Team Standards
Enforce coding standards before Claude commits code patterns to files:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "validate-standards.sh"
}
]
}
]
}
}The validation script:
#!/bin/bash
# validate-standards.sh
FILE="$CLAUDE_FILE_PATH"
CONTENT="$CLAUDE_TOOL_INPUT"
# Check for console.log in production code
if [[ "$FILE" =~ ^src/ ]] && [[ "$CONTENT" =~ console\.log ]]; then
echo "WARNING: console.log detected in source file. Consider using proper logging." >&2
# Exit 0 to allow but warn, exit 2 to block
exit 0
fi
# Block TODO comments without assignee
if [[ "$CONTENT" =~ TODO[^@] ]]; then
echo "BLOCKED: TODO comments must include assignee (e.g., TODO@username)" >&2
exit 2
fi
exit 0Hook Execution Behavior
Understanding how hooks execute helps avoid surprises:
Parallel Execution: When multiple hooks match an event, they all run in parallel. This improves performance but means hooks should not depend on each other's output.
Deduplication: Identical hook commands are automatically deduplicated. If your global and project configs define the same hook, it runs once.
Timeout Isolation: A timeout in one hook does not affect others. If your formatter times out, your linter still runs.
Exit Code Handling:
0- Success, continue execution2- Blocking error, stop and send stderr to Claude3- Deferred execution (command completed but effects postponed)
Debugging Hooks
When hooks misbehave, add logging:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "echo \"Hook fired: $CLAUDE_HOOK_EVENT for $CLAUDE_FILE_PATH\" >> /tmp/claude-hooks.log; prettier --write \"$CLAUDE_FILE_PATH\"",
"timeout": 30
}
]
}
]
}
}Check /tmp/claude-hooks.log to verify hooks are firing when expected.
For interactive debugging, the /hooks command shows currently active hooks and their status.
Best Practices
Keep hooks idempotent. Hooks may run multiple times for retries or parallel execution. A hook that is safe to run twice is safe to run in Claude Code.
Set appropriate timeouts. The default 60 seconds is generous. Formatters should complete in under 10 seconds. Linters might need 30 seconds for large codebases.
Use exit codes correctly. Exit 0 for success, 2 to block with an error message, anything else for general failure.
Version control project hooks. Put .claude/settings.json in Git so your team shares the same automation. Keep personal preferences in the global ~/.claude/settings.json.
Test incrementally. Start with one simple hook, verify it works, then add complexity. A misconfigured hook can break your entire Claude Code workflow.
Document hook purpose. Add comments in your hook scripts explaining what they protect against and why. Future you will thank present you.
My Production Hook Setup
Here is the complete hooks configuration I use across projects:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "prettier --write \"$CLAUDE_FILE_PATH\" 2>/dev/null; eslint --fix \"$CLAUDE_FILE_PATH\" 2>/dev/null; exit 0",
"timeout": 30
}
]
},
{
"hooks": [
{
"type": "command",
"command": "echo \"[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $CLAUDE_TOOL_NAME\" >> ~/.claude/audit.log"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "if [[ \"$CLAUDE_TOOL_INPUT\" =~ (rm -rf|git push --force|DROP TABLE) ]]; then echo 'Dangerous command blocked' >&2; exit 2; fi"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Verify all requested changes were made and no errors remain. Respond {\"ok\": true} if complete, {\"ok\": false, \"reason\": \"...\"} if not.",
"timeout": 20
}
]
}
]
}
}This setup:
- Formats all edited files automatically
- Logs every tool call for audit
- Blocks dangerous shell commands
- Verifies task completion before stopping