Claude Code Hooks

7 min read

Claude Code moves fast: editing files, running commands, pushing code in quick succession. Hooks let you intercept that flow and enforce your standards automatically.

What Are Hooks?

Hooks are shell commands that run at lifecycle events during a Claude Code session. They're configured in settings.json and execute outside Claude's control. The harness runs them, not the AI.

This matters: Claude can't skip hooks or work around them. They're infrastructure, not suggestions.

Hook Types

HookWhen it runsCan block?
PreToolUseBefore a tool executesYes (non-zero exit)
PostToolUseAfter a tool executesNo
NotificationWhen Claude sends a notificationNo
StopWhen Claude's turn endsNo
SubagentStopWhen a subagent finishesNo

PreToolUse is the most powerful: it can stop a tool from running entirely.

Configuration

Hooks live in settings.json. Two scopes:

  • ~/.claude/settings.json (global, applies everywhere)
  • .claude/settings.json (project-level, merged with global)

Structure:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "your-shell-command-here",
            "timeout": 60,
            "statusMessage": "Running check..."
          }
        ]
      }
    ]
  }
}

Fields:

  • matcher: tool name to match ("Bash", "Edit", "Write", "" for all)
  • command: shell command to run
  • timeout: milliseconds before the hook is killed
  • statusMessage: shown in the UI while the hook runs

Hook Input

Hooks receive the tool's input via stdin as JSON. Use jq to extract what you need:

# Get the bash command being run
cmd=$(jq -r '.tool_input.command')

# Get the file path being edited
path=$(jq -r '.tool_input.file_path')

This lets you write conditional hooks that only fire for specific operations.

Practical Examples

Lint Before Push

Block git push unless lint passes:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "cmd=$(jq -r '.tool_input.command'); echo \"$cmd\" | grep -q 'git push' || exit 0; npm run lint",
            "timeout": 120,
            "statusMessage": "Running lint before push..."
          }
        ]
      }
    ]
  }
}

exit 0 on non-push commands lets them through. Only push triggers the lint check. Lint fails = push blocked.

Type-Check After TypeScript Edits

Run tsc after editing .ts files:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit",
        "hooks": [
          {
            "type": "command",
            "command": "path=$(jq -r '.tool_input.file_path'); echo \"$path\" | grep -q '\\.ts$' || exit 0; npx tsc --noEmit",
            "timeout": 60,
            "statusMessage": "Type-checking..."
          }
        ]
      }
    ]
  }
}

Desktop Notification When Claude Stops

Know when a long task finishes without watching the terminal:

{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude finished\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}

Block Writes to Sensitive Files

Prevent accidental edits to .env files:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "path=$(jq -r '.tool_input.file_path'); echo \"$path\" | grep -q '\\.env' && echo 'Blocked: cannot write to .env files' && exit 1 || exit 0"
          }
        ]
      }
    ]
  }
}

Non-zero exit blocks the tool. Claude sees the output and adjusts.

Frontend-Specific Examples

Run Tests After Component Changes

Trigger Vitest after editing React components:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit",
        "hooks": [
          {
            "type": "command",
            "command": "path=$(jq -r '.tool_input.file_path'); echo \"$path\" | grep -qE '\\.(tsx|jsx)$' || exit 0; npx vitest run --reporter=verbose 2>&1 | tail -20",
            "timeout": 120,
            "statusMessage": "Running component tests..."
          }
        ]
      }
    ]
  }
}

Only fires on .tsx/.jsx edits. You see failing tests immediately, not after the next push.

Block console.log Before Commit

Prevent debug statements from shipping:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "cmd=$(jq -r '.tool_input.command'); echo \"$cmd\" | grep -q 'git commit' || exit 0; git diff --cached | grep -q 'console\\.log' && echo 'Blocked: staged changes contain console.log' && exit 1 || exit 0",
            "statusMessage": "Checking for console.log..."
          }
        ]
      }
    ]
  }
}

Scans the staged diff before every commit. Fails if console.log is found.

ESLint on Every File Write

Catch lint errors the moment a file is created:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "path=$(jq -r '.tool_input.file_path'); echo \"$path\" | grep -qE '\\.(ts|tsx|js|jsx)$' || exit 0; npx eslint \"$path\" --max-warnings=0",
            "timeout": 30,
            "statusMessage": "Linting new file..."
          }
        ]
      }
    ]
  }
}

Useful when Claude writes new files from scratch. Errors surface before any follow-up edits build on broken code.

Accessibility Check After Component Edits

Run axe-core via CLI after touching UI components:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit",
        "hooks": [
          {
            "type": "command",
            "command": "path=$(jq -r '.tool_input.file_path'); echo \"$path\" | grep -q 'components/' || exit 0; echo \"$path\" | grep -qE '\\.tsx$' || exit 0; npx axe-cli http://localhost:3000 --exit",
            "timeout": 60,
            "statusMessage": "Checking accessibility..."
          }
        ]
      }
    ]
  }
}

Requires a running dev server. Best paired with a PreToolUse hook that starts it if not running.

Prettier Format Check After Write

Enforce consistent formatting on new files without running format-on-save globally:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "path=$(jq -r '.tool_input.file_path'); echo \"$path\" | grep -qE '\\.(ts|tsx|css|json)$' || exit 0; npx prettier --check \"$path\" || (echo \"Run: npx prettier --write \\\"$path\\\"\" && exit 1)",
            "timeout": 15,
            "statusMessage": "Checking formatting..."
          }
        ]
      }
    ]
  }
}

Outputs the fix command when it fails, so Claude can auto-correct.

Hooks vs. CLAUDE.md

CLAUDE.md tells Claude what to do. Claude might forget or skip steps under pressure.

Hooks run regardless of what Claude does. They're external to the AI.

Use CLAUDE.md for preferences. Use hooks for guarantees.

Example: "Run lint before committing" in CLAUDE.md = a suggestion. A PreToolUse hook on Bash that checks for git commit = enforced.

Ordering Multiple Hooks

You can stack hooks on the same event:

{
  "PreToolUse": [
    {
      "matcher": "Bash",
      "hooks": [
        {
          "type": "command",
          "command": "check-push-lint.sh"
        },
        {
          "type": "command",
          "command": "check-push-secrets.sh"
        }
      ]
    }
  ]
}

Hooks run in order. First failure blocks the rest.

What I Use Hooks For

  • Lint before push (catches style errors before CI)
  • Type-check on edit (immediate feedback on TypeScript errors)
  • Tests after component changes (know immediately if something broke)
  • console.log guard on commit (never ship debug statements)
  • Secret scanning on push (last line of defense before remote)
  • Notifications on stop (no more watching the terminal)

When Not to Use Hooks

The real cost isn't latency. A grep or jq command adds single-digit milliseconds. The cost is pairing a slow operation with a high-frequency tool call.

Avoid:

  • Pairing heavy operations with broad matchers. Running tsc --noEmit on every Edit recompiles the full project each time. Scope it to Bash and trigger only on git push.
  • Overly broad matchers. If your hook only cares about .ts files, don't match all Write calls and filter inside the script. Use the most specific matcher you can.

Lightweight checks (grep, jq, file path tests) are essentially free. Save the heavy tools (tsc, vitest, npm run build) for infrequent triggers like git push or git commit.

Wrapping Up

Hooks are the difference between hoping Claude does the right thing and knowing it will.

Your CI catches broken builds. Your hooks catch them before the push. The earlier you catch problems, the cheaper they are to fix.

Define your quality gates once. Let hooks enforce them on every session.

Further reading: