Skip to content

Mastering Claude Code Hooks: Automate Formatting, Validation, and Logging

9 min read1747 words

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 EventWhen It FiresCommon Uses
PreToolUseBefore any tool executesBlock dangerous commands, validate inputs
PostToolUseAfter a tool completesFormat code, run linters, send notifications
PermissionRequestWhen permission dialogs appearAuto-approve safe operations
UserPromptSubmitWhen you submit a promptLog queries, transform inputs
NotificationWhen Claude sends alertsRoute to Slack, email, etc.
StopWhen Claude finishes respondingValidate completeness, trigger follow-ups
SubagentStopWhen a sub-agent completesAggregate results, chain tasks
SetupWhen Claude Code startsInitialize 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:

> /hooks

This interactive wizard walks you through:

  1. Selecting an event (e.g., PostToolUse)
  2. Setting a matcher pattern (e.g., Write|Edit for file operations)
  3. 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 0

Exit 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 command
  • deny - Block execution
  • ask - 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 0

This 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:

VariableDescription
CLAUDE_SESSION_IDCurrent conversation identifier
CLAUDE_HOOK_EVENTWhich hook event fired
CLAUDE_TOOL_NAMETool being called
CLAUDE_TOOL_INPUTInput parameters to the tool
CLAUDE_FILE_PATHFile being modified (for Write/Edit)
CLAUDE_CWDCurrent working directory
CLAUDE_TRANSCRIPT_PATHPath 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"
fi

Notification 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 0

Hook 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 execution
  • 2 - Blocking error, stop and send stderr to Claude
  • 3 - 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:

  1. Formats all edited files automatically
  2. Logs every tool call for audit
  3. Blocks dangerous shell commands
  4. Verifies task completion before stopping

Resources