Chapter 42

Skills Development: SKILL.md Complete Parameters, Dynamic Context Injection and Invocation Control Matrix

Chapter 42: Claude Code Hooks: pre-tool, post-tool, and Automated Quality Gating

42.1 The Motivation Behind Hooks

Claude Code is a powerful AI programming assistant, but when used in team and production environments, you need a mechanism to ensure that every operation Claude performs conforms to your standards — not just by telling Claude what the standards are through CLAUDE.md, but by automatically enforcing those standards before and after actions occur.

That is what Claude Code Hooks are for.

Hooks are shell command triggers that fire before and after Claude executes tool calls. They are configured in .claude/settings.json and run completely independently of Claude's AI decision-making. Regardless of what Claude decides to do, the hooks execute your specified commands at the defined points.

Core value: Hooks transform team conventions from "suggestions" into "enforced rules."

Consider this scenario: your team requires that all Python files be formatted with Black after modification. With a post-tool hook, every time Claude writes or modifies a .py file, Black runs automatically — no need to remind Claude, no reliance on Claude's initiative. The formatting just happens.

42.2 The Hook Configuration Format

Hooks are configured in .claude/settings.json, the project-level Claude Code configuration file, which should be committed to version control:

{
  "hooks": {
    "pre-tool": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'About to modify files...' >> .claude/hooks.log"
          }
        ]
      }
    ],
    "post-tool": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "scripts/post-write-hook.sh"
          }
        ]
      }
    ]
  }
}

Configuration Structure

hooks
├── pre-tool              # List of hooks that fire before tool execution
│   └── [Hook Entry]
│       ├── matcher       # Which tools to match (regex)
│       └── hooks         # List of commands to run
│           └── [Hook]
│               ├── type  # Currently only "command" is supported
│               └── command  # The shell command to execute
└── post-tool             # List of hooks that fire after tool execution
    └── [same structure]

Writing Matchers

matcher is a regex string that matches against tool names. The primary Claude Code tool names are:

File operations:
- Write          # Write a new file
- Edit           # Edit an existing file (single replacement)
- MultiEdit      # Multiple edits in one call
- Read           # Read a file

Shell operations:
- Bash           # Execute a shell command

Search tools:
- Grep           # Text content search
- Glob           # Filename pattern search

Others:
- WebFetch       # Fetch web page content
- TodoWrite      # Write to the Todo list

Matcher examples:

"matcher": "Write|Edit|MultiEdit"   // match all write operations
"matcher": "Bash"                   // match only the Bash tool
"matcher": ".*"                     // match all tools
"matcher": "Write"                  // match only Write

42.3 pre-tool Hooks: Execute Before an Action

pre-tool hooks fire before Claude executes a tool. Their primary uses are:

Example 1: Prevent Direct Commits to the Main Branch

{
  "hooks": {
    "pre-tool": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "scripts/check-branch.sh"
          }
        ]
      }
    ]
  }
}

scripts/check-branch.sh:

#!/bin/bash
# Check current git branch; warn if on main or master

BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
    echo "WARNING: You are on the $BRANCH branch."
    echo "Direct commits to $BRANCH are discouraged."
    echo "Consider: git checkout -b feature/your-feature"
    # A non-zero exit code would block tool execution.
    # Here we only warn, so we exit 0:
    exit 0
fi
exit 0

Example 2: Audit Log for All File Operations

{
  "hooks": {
    "pre-tool": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "echo \"$(date -Iseconds) PRE-TOOL: $CLAUDE_TOOL_NAME $CLAUDE_TOOL_INPUT\" >> .claude/audit.log"
          }
        ]
      }
    ]
  }
}

The Meaning of pre-tool Exit Codes

The exit code of a pre-tool hook has critical significance:

This makes pre-tool hooks genuine "quality gates" — you can write check scripts that directly prevent Claude's actions when conditions are not met.

Example 3: Block Modifications to Production Config Files

#!/bin/bash
# pre-write-check.sh
# Prevent Claude from modifying specific production config files

PROTECTED_FILES=(
    "config/production.yaml"
    "config/production.json"
    ".env.production"
    "kubernetes/prod/"
)

TARGET_FILE="${CLAUDE_TOOL_INPUT_FILE_PATH:-}"

for protected in "${PROTECTED_FILES[@]}"; do
    if [[ "$TARGET_FILE" == *"$protected"* ]]; then
        echo "ERROR: Modification of protected production config is not allowed."
        echo "File: $TARGET_FILE"
        echo "Use the deployment pipeline to change production configs."
        exit 1  # non-zero exit code blocks the operation
    fi
done

exit 0

42.4 post-tool Hooks: Execute After an Action

post-tool hooks fire after Claude's tool execution completes. Their primary uses are:

Example 1: Automatic Python Formatting

{
  "hooks": {
    "post-tool": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "scripts/post-write-format.sh"
          }
        ]
      }
    ]
  }
}

scripts/post-write-format.sh:

#!/bin/bash
# Auto-format and lint Python files after modification

MODIFIED_FILE="${CLAUDE_TOOL_INPUT_FILE_PATH:-}"

if [[ "$MODIFIED_FILE" == *.py ]]; then
    echo "Running Black formatter on $MODIFIED_FILE..."
    black "$MODIFIED_FILE" 2>&1

    echo "Running isort on $MODIFIED_FILE..."
    isort "$MODIFIED_FILE" 2>&1

    echo "Running flake8 lint check..."
    flake8 "$MODIFIED_FILE" 2>&1
    LINT_EXIT=$?

    if [ $LINT_EXIT -ne 0 ]; then
        echo "WARNING: flake8 found issues in $MODIFIED_FILE"
    fi
fi

exit 0

Example 2: TypeScript Type Checking

{
  "hooks": {
    "post-tool": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "scripts/ts-typecheck.sh"
          }
        ]
      }
    ]
  }
}

scripts/ts-typecheck.sh:

#!/bin/bash
# Type-check modified TypeScript files

MODIFIED_FILE="${CLAUDE_TOOL_INPUT_FILE_PATH:-}"

if [[ "$MODIFIED_FILE" == *.ts ]] || [[ "$MODIFIED_FILE" == *.tsx ]]; then
    echo "Running TypeScript type check..."
    npx tsc --noEmit --project tsconfig.json 2>&1
    TS_EXIT=$?

    if [ $TS_EXIT -ne 0 ]; then
        echo "TypeScript type errors found. Claude should review and fix."
        exit 1  # signals to Claude that there is a problem to address
    fi
fi

exit 0

When a post-tool hook returns a non-zero exit code, Claude Code feeds the error output back to Claude, which can then continue working to fix the problem. This creates an automatic feedback loop: Claude modifies code → hook runs type check → errors found → Claude is notified → Claude keeps fixing.

42.5 Environment Variables: How Hooks Access Context

Hooks receive context about the current operation through environment variables:

# Environment variables injected into hook processes by Claude Code

CLAUDE_TOOL_NAME             # Name of the tool that triggered the hook (e.g., "Write")
CLAUDE_TOOL_INPUT            # Full tool input as JSON
CLAUDE_TOOL_INPUT_FILE_PATH  # For file operations, the target file path
CLAUDE_TOOL_INPUT_COMMAND    # For Bash tool, the command being run
CLAUDE_TOOL_OUTPUT           # (post-tool only) The tool's output
CLAUDE_SESSION_ID            # Current session ID
CLAUDE_PROJECT_DIR           # Project root directory path

Parsing JSON Input

For hooks that need more detailed information, parse CLAUDE_TOOL_INPUT:

#!/bin/bash
# Parse tool input for detailed information

TOOL_INPUT="${CLAUDE_TOOL_INPUT:-{}}"

# Use jq to parse JSON
FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // empty')
CONTENT=$(echo "$TOOL_INPUT" | jq -r '.content // empty')
COMMAND=$(echo "$TOOL_INPUT" | jq -r '.command // empty')

echo "Tool: $CLAUDE_TOOL_NAME"
echo "File: $FILE_PATH"
echo "Command: $COMMAND"

42.6 Building a Complete Automated Quality Gate

By combining pre-tool and post-tool hooks, you can build a comprehensive automated quality gate. Here is a complete example for a Node.js project:

{
  "hooks": {
    "pre-tool": [
      {
        "matcher": "Bash",
        "hooks": [{"type": "command", "command": "scripts/hooks/pre-bash.sh"}]
      },
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [{"type": "command", "command": "scripts/hooks/pre-write.sh"}]
      }
    ],
    "post-tool": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [{"type": "command", "command": "scripts/hooks/post-write.sh"}]
      },
      {
        "matcher": "Bash",
        "hooks": [{"type": "command", "command": "scripts/hooks/post-bash.sh"}]
      }
    ]
  }
}

scripts/hooks/post-write.sh (the core quality gate):

#!/bin/bash

MODIFIED_FILE="${CLAUDE_TOOL_INPUT_FILE_PATH:-}"
EXIT_CODE=0

# 1. JavaScript/TypeScript files
if [[ "$MODIFIED_FILE" =~ \.(js|jsx|ts|tsx)$ ]]; then

    # Auto-format with Prettier (modifies the file in place)
    if command -v prettier &>/dev/null; then
        prettier --write "$MODIFIED_FILE" 2>&1
        echo "✓ Prettier formatting applied"
    fi

    # ESLint check (report, do not auto-fix)
    if command -v eslint &>/dev/null; then
        eslint "$MODIFIED_FILE" 2>&1
        ESLINT_EXIT=$?
        if [ $ESLINT_EXIT -ne 0 ]; then
            echo "✗ ESLint found issues"
            EXIT_CODE=$ESLINT_EXIT
        else
            echo "✓ ESLint passed"
        fi
    fi
fi

# 2. Test file detection
if [[ "$MODIFIED_FILE" =~ src/(.+)\.(ts|js)$ ]] && [[ ! "$MODIFIED_FILE" =~ \.(test|spec)\. ]]; then
    RELATIVE_PATH="${BASH_REMATCH[1]}"
    TEST_FILE="src/${RELATIVE_PATH}.test.ts"
    SPEC_FILE="src/${RELATIVE_PATH}.spec.ts"

    if [ ! -f "$TEST_FILE" ] && [ ! -f "$SPEC_FILE" ]; then
        echo "WARNING: No test file found for $MODIFIED_FILE"
        echo "Consider creating: $TEST_FILE"
    fi
fi

# 3. Detect package.json changes
if [[ "$MODIFIED_FILE" == "package.json" ]]; then
    echo "package.json modified. Running npm install to sync node_modules..."
    npm install 2>&1
fi

exit $EXIT_CODE

42.7 Hooks and CLAUDE.md: Complementary Tools

Hooks and CLAUDE.md are complementary tools that address different layers of the problem:

Dimension CLAUDE.md Hooks
Execution Influences Claude's AI decisions Independent of AI; shell commands
Reliability Depends on Claude following rules Mechanically enforced
Best for Style guides, architecture decisions Formatting, linting, testing
Bypassable Claude may forget or ignore Cannot be ignored
Flexibility High (natural language) Low (must be executable scripts)

The best practice is to use both together:

For example, CLAUDE.md states "all TypeScript files must pass type checking," and a post-tool hook is configured to run tsc --noEmit automatically after every file write. Even if Claude forgets the convention, the hook will catch the problem and feed it back.

42.8 Debugging Hooks

When hooks go wrong, debugging can be challenging. Here are some practical techniques.

Enable Hook Logging

# Add logging at the start of each hook script
LOG_FILE=".claude/hooks-debug.log"
echo "$(date -Iseconds) HOOK: $CLAUDE_TOOL_NAME" >> "$LOG_FILE"
echo "  INPUT: $CLAUDE_TOOL_INPUT" >> "$LOG_FILE"

Document Debugging in CLAUDE.md

## Hook Debugging

If hooks are behaving unexpectedly, check .claude/hooks-debug.log.
Run `scripts/hooks/test-hooks.sh` to test all hooks without involving Claude.

Test Hook Scripts Locally

# Simulate Claude Code's environment variables and test a hook directly
CLAUDE_TOOL_NAME="Write" \
CLAUDE_TOOL_INPUT_FILE_PATH="src/utils/format.ts" \
CLAUDE_TOOL_INPUT='{"file_path":"src/utils/format.ts","content":"export function foo() {}"}' \
bash scripts/hooks/post-write.sh

Summary

Claude Code Hooks are the key mechanism for elevating AI-assisted programming from "inconsistent quality" to "predictable quality."

Key takeaways:

Rate this chapter
4.7  / 5  (3 ratings)

💬 Comments