Switch to light mode

Claude Code Hooks: The Reliability Layer Your CLAUDE.md Can't Replace

- 8 min read

Claude Code Hooks - The Reliability Layer Your CLAUDE.md Cannot Replace

Claude Code Hooks: The Reliability Layer Your CLAUDE.md Can’t Replace

Here’s the honest situation with CLAUDE.md instructions: they work most of the time. Claude reads your configuration, understands your conventions, and follows them - until it doesn’t. Long sessions, complex tasks, instructions buried in a dense config file - any of these can cause Claude to drift from what you specified. The research puts compliance at 70-90%, which sounds good until you’re debugging a production issue caused by the 10-30%.

Claude Code hooks are different. They’re user-defined shell commands that execute at specific lifecycle checkpoints. They don’t ask Claude to remember anything. They run unconditionally, every time, regardless of session state or context length. They achieve 100% compliance because they’re not asking Claude to do something - they’re doing it themselves.

This is the most underused feature in Claude Code. Here’s how it works and what to actually build with it.

How Hooks Work

Hooks are configured in ~/.claude/settings.json under a hooks key. There are three lifecycle events that cover the vast majority of production use cases:

  • PreToolUse - runs before Claude executes a tool call. Can block the call entirely.
  • PostToolUse - runs after a tool call completes.
  • Stop - runs when Claude signals it’s finished with a task.

The configuration structure:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "your-command-here"
          }
        ]
      }
    ],
    "PostToolUse": [],
    "Stop": []
  }
}

The matcher field accepts a tool name (Write, Bash, Edit) or "*" for all tools. Multiple matchers can be configured for the same lifecycle event. Each hook is a shell command that receives context about the tool call via environment variables and stdin.

Exit codes control what happens next:

  • 0 - continue normally
  • 1 - block the tool call and abort the entire task
  • 2 - block the tool call and surface a message to Claude (Claude can then adjust its approach)

Exit code 2 is the nuanced one. It’s not “stop everything” - it’s “don’t do this specific thing, here’s why, try again.” That distinction matters for building useful guardrails rather than brittle hard blocks.

Pattern 1: Auto-Format After File Writes

The classic CLAUDE.md instruction: “always run prettier after editing TypeScript files.” Compliance rate in practice: maybe 85%. With a PostToolUse hook, it’s 100%.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "if echo \"$CLAUDE_TOOL_INPUT\" | jq -r '.file_path' | grep -qE '\\.(ts|tsx|js|jsx)$'; then prettier --write \"$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r '.file_path')\"; fi"
          }
        ]
      }
    ]
  }
}

Same pattern works for Python with Black or Ruff:

{
  "type": "command",
  "command": "FILE=$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r '.file_path'); if echo \"$FILE\" | grep -q '\\.py$'; then black \"$FILE\"; fi"
}

The tool input is passed as JSON via the CLAUDE_TOOL_INPUT environment variable. Parse it with jq to get the file path, then run your formatter. Claude sees the formatted output and learns to expect the formatting to match - positive feedback loop.

Pattern 2: Block Dangerous Operations

PreToolUse hooks on the Bash tool are your safety net for commands you never want Claude to run. Exit code 2 blocks the command and tells Claude why, so it can suggest a safer alternative.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "CMD=$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r '.command'); if echo \"$CMD\" | grep -qE 'rm -rf|git push --force|git push -f'; then echo 'Blocked: destructive command requires manual execution' >&2; exit 2; fi"
          }
        ]
      }
    ]
  }
}

You can stack these. Block writes to .env files, block production database connections, block package publishes without confirmation. The key is using exit code 2 rather than 1 for most of these - you want Claude to know what was blocked and why, not just abort silently.

A harder block for .env writes (exit code 1 - full abort, not just advisory):

{
  "matcher": "Write",
  "hooks": [
    {
      "type": "command",
      "command": "FILE=$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r '.file_path'); if echo \"$FILE\" | grep -qE '\\.env(\\..*)?$'; then echo 'Blocked: .env file writes are prohibited' >&2; exit 1; fi"
    }
  ]
}

Pattern 3: Full Audit Log

Every tool call Claude makes in a session can be appended to a JSONL file. This gives you complete session replay - what Claude did, in what order, with what arguments - which is invaluable when something goes wrong and you need to understand why.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "echo \"{\\\"timestamp\\\": \\\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\\\", \\\"event\\\": \\\"PreToolUse\\\", \\\"tool\\\": \\\"$CLAUDE_TOOL_NAME\\\", \\\"input\\\": $CLAUDE_TOOL_INPUT}\" >> ~/.claude/audit.jsonl"
          }
        ]
      }
    ]
  }
}

The "*" matcher catches everything. With timestamps and tool names, you can reconstruct exactly what happened in any session. For teams using Claude Code on shared codebases, this log also serves as a lightweight change audit trail.

Pattern 4: Run Tests on Stop

This is the one that changes how you work with Claude Code most dramatically. Instead of asking Claude to run tests (which it will sometimes skip, sometimes do at the wrong time), wire tests directly to the Stop event.

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "cd \"$CLAUDE_PROJECT_DIR\" && npm test 2>&1; if [ $? -ne 0 ]; then echo 'Tests failed - task not complete' >&2; exit 2; fi"
          }
        ]
      }
    ]
  }
}

Exit code 2 on Stop means Claude gets the test failure output and is instructed not to consider the task done. It will continue working until tests pass - or tell you it’s stuck and needs your input. Either outcome is better than Claude declaring success on broken code.

For Python projects:

{
  "type": "command",
  "command": "cd \"$CLAUDE_PROJECT_DIR\" && python -m pytest --tb=short 2>&1; if [ $? -ne 0 ]; then echo 'Tests failed' >&2; exit 2; fi"
}

The CLAUDE_PROJECT_DIR environment variable points to the project root. You don’t need to hardcode paths.

Pattern 5: Lint on Every File Write

Similar to auto-format, but for lint errors you want surfaced immediately rather than accumulated. The difference is that lint failures should probably block with exit code 2 so Claude sees the errors and fixes them in the same session.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit",
        "hooks": [
          {
            "type": "command",
            "command": "FILE=$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r '.file_path'); if echo \"$FILE\" | grep -qE '\\.(ts|tsx)$'; then npx eslint \"$FILE\" --format compact 2>&1; EXIT=$?; if [ $EXIT -ne 0 ]; then exit 2; fi; fi"
          }
        ]
      }
    ]
  }
}

Note the matcher is Edit here, not Write. The Edit tool handles in-place file modifications. You may want hooks on both Write and Edit depending on which Claude uses more frequently in your workflow.

What CLAUDE.md Is Still For

Hooks handle enforcement. CLAUDE.md handles context. They’re not competing - they cover different jobs.

CLAUDE.md is where you put:

  • Architectural decisions and why you made them
  • Which patterns to use for which problems
  • What’s in scope for the current project
  • References to specific files Claude should use as examples
  • Conventions that differ from framework defaults

Hooks are where you put:

  • Formatting requirements
  • Safety boundaries
  • Test gates
  • Audit requirements
  • Anything where “usually” isn’t acceptable

If you’ve been trying to enforce formatting or test-running through CLAUDE.md instructions and getting inconsistent results, hooks are the fix. If you’ve been writing “never do X” in CLAUDE.md for things that would be genuinely dangerous, a PreToolUse hook that actually blocks X is the correct solution.

Getting Started

Start with one hook. The PostToolUse auto-formatter is the lowest-friction entry point - it only runs after writes, it doesn’t block anything, and the upside (consistently formatted code) is immediately visible.

Add the dangerous-command block next if you’re using Claude Code on any codebase where an accidental rm -rf would be painful.

Then add the Stop hook with tests once you’ve confirmed the formatter isn’t causing issues.

The audit log is optional but worth adding once you’re comfortable with the system - having a full record of what Claude did in each session becomes valuable quickly.

The configuration lives in ~/.claude/settings.json so hooks apply across all your projects. You can also put a settings.json in .claude/ at the project root for project-specific hooks. Project-level hooks run in addition to user-level hooks.

One last thing: hooks run as shell commands in your environment, with your permissions. A hook that accidentally runs in the wrong directory or with wrong file paths can cause real damage. Test new hooks in a throwaway project before wiring them up to your main codebase.

© 2024 Shawn Mayzes. All rights reserved.