Claude Code has two mechanisms for controlling behavior: CLAUDE.md instructions and hooks. They solve different problems. CLAUDE.md is guidance - Claude reads it and mostly follows it. Hooks are executable scripts that fire on specific events - they run deterministically, every time, regardless of what Claude decides to do. Understanding the boundary between these two systems is the difference between a setup that mostly works and one that always works.

The Compliance Gap

CLAUDE.md instructions get roughly 80% compliance. Tell Claude “always run prettier before committing” in CLAUDE.md, and it will do it most of the time. But on that one complex refactoring where it makes 15 file changes and gets focused on getting the logic right, it will forget. This is not a bug - it is how LLMs work. Instructions compete for attention in context, and longer conversations dilute earlier instructions.

Hooks get 100% compliance. A PostToolUse hook that runs prettier after every file write will fire every single time. No exceptions. No “it forgot.” The hook is a script on disk, executed by the Claude Code harness, not by the LLM.

Hook Events

Hooks fire on five events:

Event When It Fires Common Use
PreToolUse Before Claude executes a tool Block dangerous commands, validate inputs
PostToolUse After a tool execution completes Auto-format, lint, scan for secrets
SessionStart When a Claude Code session begins Set up environment, check prerequisites
Stop When Claude finishes its response Run final validation, generate summary
Notification When Claude sends a notification Custom alerting, logging

Hooks are configured in .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "write_file",
        "command": "prettier --write $CLAUDE_FILE_PATH"
      }
    ],
    "PreToolUse": [
      {
        "matcher": "bash",
        "command": "/scripts/block-dangerous-commands.sh"
      }
    ]
  }
}

The matcher field filters which tool invocations trigger the hook. Without a matcher, the hook fires on every tool use for that event, which is almost never what anyone wants.

Real Hook Examples

Auto-Format on Every File Write

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "write_file|edit_file",
        "command": "prettier --write $CLAUDE_FILE_PATH 2>/dev/null; exit 0"
      }
    ]
  }
}

The exit 0 is important. If a hook returns a non-zero exit code, Claude Code treats it as a failure and may retry or abort. Prettier failing on a non-JS file should not block the workflow.

Block Dangerous Bash Commands

#!/bin/bash
# .claude/hooks/block-dangerous.sh
BLOCKED_PATTERNS=(
  "rm -rf /"
  "git push --force"
  "DROP TABLE"
  "DROP DATABASE"
  "kubectl delete namespace"
)

for pattern in "${BLOCKED_PATTERNS[@]}"; do
  if echo "$CLAUDE_TOOL_INPUT" | grep -qi "$pattern"; then
    echo "BLOCKED: Command matches dangerous pattern: $pattern" >&2
    exit 1
  fi
done
exit 0
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "bash",
        "command": ".claude/hooks/block-dangerous.sh"
      }
    ]
  }
}

A non-zero exit from a PreToolUse hook prevents the tool from executing. This is a hard block, not a suggestion. Claude cannot override it.

Security Scanning After File Changes

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "write_file|edit_file",
        "command": "detect-secrets scan $CLAUDE_FILE_PATH --baseline .secrets.baseline 2>/dev/null || echo 'WARNING: Potential secret detected in $CLAUDE_FILE_PATH' >&2"
      }
    ]
  }
}

Auto-Lint TypeScript Files

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "write_file|edit_file",
        "command": "if [[ $CLAUDE_FILE_PATH == *.ts || $CLAUDE_FILE_PATH == *.tsx ]]; then eslint --fix $CLAUDE_FILE_PATH 2>/dev/null; fi; exit 0"
      }
    ]
  }
}

Environment Check on Session Start

{
  "hooks": {
    "SessionStart": [
      {
        "command": "node -v && pnpm -v && docker info > /dev/null 2>&1 || echo 'WARNING: Docker is not running' >&2"
      }
    ]
  }
}

This runs once when the session starts. If Docker is required for the project, catching it early saves a debugging cycle later.

When CLAUDE.md Is Better

Hooks are powerful but they are the wrong tool for many situations. CLAUDE.md excels at:

Architectural guidance. “Use the repository pattern for database access” is context that shapes how Claude thinks about a problem. A hook cannot enforce architectural patterns - it can only validate surface-level properties of the output.

Preferences and conventions. “Prefer named exports over default exports” or “Use date-fns, not moment” are style preferences that influence code generation. Hooks fire after code is written. Making Claude write correct code the first time is cheaper than fixing it after.

Project context. “This is a monorepo with three packages” and “The auth module is in packages/shared/auth” give Claude understanding. Hooks do not provide understanding - they provide enforcement.

Nuanced decisions. “Server components by default, client components only when interactivity is needed” requires judgment. Hooks are binary - pass or fail. CLAUDE.md instructions handle the gray areas.

When Hooks Are Better

Formatting and linting. These are deterministic transformations. Running prettier via a hook is faster and more reliable than asking Claude to format code correctly.

Security enforcement. Blocking dangerous commands, scanning for secrets, preventing force pushes - these are non-negotiable. Advisory instructions are not acceptable for security.

Consistency across sessions. CLAUDE.md compliance varies. A long session with lots of context might see instructions slip. Hooks do not degrade over time.

Team-wide enforcement. If every developer on the team commits .claude/settings.json to the repo, hooks run the same way for everyone. CLAUDE.md instructions can be overridden by user-level CLAUDE.md files.

The Decision Tree

Need to control HOW Claude thinks about a problem?
  -> CLAUDE.md

Need to enforce a rule with 100% reliability?
  -> Hook

Need to transform output (format, lint, fix)?
  -> PostToolUse hook

Need to block dangerous operations?
  -> PreToolUse hook

Need to provide project context or architecture info?
  -> CLAUDE.md

Need to validate something before Claude's response is final?
  -> Stop hook

Need environment setup?
  -> SessionStart hook

Combining Both

The most effective setups use both systems together. CLAUDE.md provides the guidance so Claude writes correct code on the first attempt. Hooks catch the cases where it does not.

Example: CLAUDE.md says “All TypeScript files must use the project’s ESLint config.” This makes Claude generate cleaner code. A PostToolUse hook runs eslint --fix on every written TypeScript file. Even if Claude misses a rule, the hook catches it.

This layered approach means Claude produces code that is 80% correct from CLAUDE.md guidance, and hooks fix the remaining 20% automatically. The result is output that is consistently correct without wasting tokens on retry loops.

The Cost Consideration

Hooks run locally and cost nothing in API tokens. CLAUDE.md content is loaded into context and costs tokens on every interaction. A 50-line CLAUDE.md might add a few hundred tokens per message. Over a long session, that adds up.

Moving enforcement from CLAUDE.md to hooks is a cost optimization. “Always run prettier” in CLAUDE.md costs tokens every message and works 80% of the time. A prettier hook costs zero tokens and works 100% of the time. That is a strict improvement on both dimensions.

The ideal CLAUDE.md is lean - focused on context and judgment calls that only the LLM can handle. Everything that can be automated should be a hook.