$ entry

Claude Code Hooks — The Complete Guide

Hooks let you run shell commands before and after every Claude Code action. Here's how to use them to enforce linting, automate git, and build custom workflows that run silently in the background.

Key takeaways

  • Hooks execute shell scripts at four lifecycle events — PreToolUse, PostToolUse, Notification, and Stop
  • They run as the current user and can block Claude's actions by returning a non-zero exit code
  • Stored in ~/.claude/settings.json (global) or .claude/settings.json (per project)
  • Perfect for auto-formatting, lint checks, logging, and git automation

Claude Code hooks are shell commands that fire automatically at defined points in Claude’s workflow. Think of them as git hooks, but for your AI coding agent: they let you enforce policies, run formatters, and wire up integrations without any manual steps.

The Four Hook Events

Claude Code exposes four lifecycle points where you can attach scripts:

EventWhen it fires
PreToolUseBefore Claude calls any tool (Read, Edit, Bash, etc.)
PostToolUseAfter a tool call completes, before Claude’s next action
NotificationWhen Claude sends a notification (e.g., task complete)
StopWhen Claude finishes a full turn

The most useful pair in practice is PreToolUse (to block bad writes) and PostToolUse (to run formatters or linters after edits).

Configuration Structure

Hooks live in the hooks key of your settings file. You can scope them globally (~/.claude/settings.json) or per-project (.claude/settings.json in the repo root).

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "npx prettier --write $CLAUDE_FILE_PATHS 2>/dev/null || true"
          }
        ]
      }
    ]
  }
}

The matcher field

matcher is a regex matched against the tool name. Common values:

  • "Edit|Write" — fires after any file write
  • "Bash" — fires after every shell command
  • ".*" — fires after everything (use carefully)

The command field

Any shell command. Claude Code injects environment variables you can use:

VariableContents
$CLAUDE_FILE_PATHSSpace-separated list of files affected by the current tool call
$CLAUDE_TOOL_NAMEThe name of the tool that was called
$CLAUDE_TOOL_INPUTJSON-encoded input to the tool
$CLAUDE_TOOL_OUTPUTJSON-encoded output from the tool (PostToolUse only)

Practical Examples

Auto-format on every save

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "npx prettier --write $CLAUDE_FILE_PATHS 2>/dev/null || true"
          }
        ]
      }
    ]
  }
}

Block writes outside the src/ directory

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "echo \"$CLAUDE_FILE_PATHS\" | grep -v '^src/' && exit 1 || exit 0"
          }
        ]
      }
    ]
  }
}

A non-zero exit code from a PreToolUse hook cancels the tool call and shows the hook’s stderr output to Claude as a reason. Claude will typically acknowledge the block and try a different approach.

Run ESLint after TypeScript edits

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "echo \"$CLAUDE_FILE_PATHS\" | grep -qE '\\.(ts|tsx)$' && npx eslint $CLAUDE_FILE_PATHS --fix 2>&1 || true"
          }
        ]
      }
    ]
  }
}

Log every Bash command Claude runs

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "echo \"$(date -u +%Y-%m-%dT%H:%M:%SZ) BASH: $CLAUDE_TOOL_INPUT\" >> ~/.claude/bash-audit.log"
          }
        ]
      }
    ]
  }
}

Auto-stage files after edit

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "git add $CLAUDE_FILE_PATHS 2>/dev/null || true"
          }
        ]
      }
    ]
  }
}

Blocking vs. Non-blocking Hooks

  • PreToolUse: exit non-zero → tool call is cancelled, stderr is fed back to Claude
  • PostToolUse / Notification / Stop: exit code is ignored; the hook runs for side effects only

This asymmetry is intentional. Pre-hooks are for enforcement; post-hooks are for automation.

Debugging Hooks

Set CLAUDE_DEBUG=1 before running claude to see hook execution in the console output:

CLAUDE_DEBUG=1 claude

You can also write a simple hook that logs to a file and tail it in a separate terminal:

{
  "type": "command",
  "command": "echo \"Tool=$CLAUDE_TOOL_NAME Files=$CLAUDE_FILE_PATHS\" >> /tmp/claude-hook.log"
}

Key Constraints

  1. No interactive commands — hooks must run non-interactively. A hook that prompts for input will hang indefinitely.
  2. Timeout — by default hooks time out after 60 seconds. Long-running hooks will be killed.
  3. Working directory — hooks run in the project root (where you launched claude).
  4. No return values — hooks communicate back to Claude only via exit code (PreToolUse) and stderr (PreToolUse error messages). Stdout is discarded.

Hooks are one of the most underused Claude Code features. A project-level .claude/settings.json with a formatter hook and a lint check is the single fastest way to keep Claude’s output clean without adding any review burden.

Patrick Zandl
Patrick Zandl Trainer and author focused on Claude Code and agentic software development. Running workshops in Prague and writing about what actually works in real-world AI-assisted projects. LinkedIn · More articles →