DEV Community

Cover image for Claude Code Hooks
Helder Burato Berto
Helder Burato Berto

Posted on • Originally published at helderberto.com

Claude Code Hooks

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

Hook When it runs Can block?
PreToolUse Before a tool executes Yes (non-zero exit)
PostToolUse After a tool executes No
Notification When Claude sends a notification No
Stop When Claude's turn ends No
SubagentStop When a subagent finishes No

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..."
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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..."
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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..."
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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\"'"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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..."
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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..."
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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..."
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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..."
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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..."
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
        }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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:

Top comments (0)