Intermediate 10 min

Claude Code Hooks: Automate Your Workflow

Complete guide to Claude Code hooks. Trigger scripts automatically before or after Claude's actions to secure and optimize your workflow.

hooks automation workflow configuration

What are hooks?

Hooks are shell commands that fire automatically at specific points in the Claude Code lifecycle. Before it edits a file, after it runs a command, when it finishes a task: you define the rules, Claude Code executes them without intervention.

Think of it as an event system. You configure scripts that react to Claude Code’s actions. A few examples of what this enables:

  • Run Prettier automatically after every file change
  • Block any attempt at rm -rf / or git push --force
  • Execute tests after each code modification
  • Send a Slack notification when Claude Code finishes a long task
  • Prevent modifications to sensitive files

Hooks turn Claude Code from an interactive tool into an automated workflow with guardrails.

Event types

Each hook fires on a specific event. Here are the available events:

PreToolUse

Fires before Claude Code uses a tool. This is the most powerful interception point: you can inspect what Claude is about to do and decide whether to block it or let it through.

Use cases:

  • Block writes to protected files
  • Prevent dangerous command execution
  • Validate content before writing

PostToolUse

Fires after Claude Code has successfully used a tool. Ideal for post-processing actions.

Use cases:

  • Format code after modification (Prettier, ESLint, Black)
  • Run tests after a change
  • Update an index or cache

Notification

Fires when Claude Code sends a notification (typically when it needs user action or is informing you of something).

Use cases:

  • Forward notifications to Slack, Discord, or a webhook
  • Log notifications to a file
  • Trigger system alerts

Stop

Fires when Claude Code finishes its main response turn (the agentic loop stops).

Use cases:

  • Summarize the changes made
  • Run a final test suite
  • Send a summary via email or Slack
  • Auto-commit changes

SubagentStop

Fires when a sub-agent (launched via the /agent command or parallel tasks) finishes execution. Same logic as Stop, but for sub-agents.

Configuration

Hooks are configured in JSON, inside Claude Code’s settings files.

Where to place the configuration

Three levels are available:

FileScopeUsage
.claude/settings.jsonProject (versioned)Hooks shared with the team
~/.claude/settings.jsonUser (global)Personal hooks, active everywhere
.claude/settings.local.jsonProject (not versioned)Project-local hooks, personal

JSON structure

The configuration follows this structure:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'File about to be modified'",
            "timeout": 10000
          }
        ]
      }
    ]
  }
}

Each entry in an event type contains:

  • matcher: optional filter to target specific tools or patterns (detailed below)
  • hooks: array of hooks to execute
    • type: always "command" for now
    • command: the shell command to run
    • timeout: timeout in milliseconds (default: 60000, i.e. 60 seconds)

Matchers: targeting the right events

The matcher field lets you filter precisely when a hook fires. If you omit the matcher, the hook fires for all calls of the corresponding event.

Matching by tool name

For PreToolUse and PostToolUse, the matcher corresponds to the Claude Code tool name:

{
  "matcher": "Write"
}

Available tool names:

  • Write: file writing
  • Edit: file modification (text replacement)
  • Bash: shell command execution
  • Read: file reading
  • Glob: file search by pattern
  • Grep: content search within files
  • WebFetch: HTTP requests
  • WebSearch: web search
  • NotebookEdit: Jupyter notebook editing

Regex matchers

The matcher supports regular expressions. For example, to target all writes and edits:

{
  "matcher": "Write|Edit"
}

Or to target MCP tools from a specific server:

{
  "matcher": "mcp__notion__.*"
}

Practical examples

Auto-format code after every modification

The most common hook. After every file write or edit, run the formatter:

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

The $CLAUDE_FILE_PATH variable contains the path of the affected file. The || true prevents a formatting error from blocking Claude Code.

For ESLint with auto-fix:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "npx eslint --fix \"$CLAUDE_FILE_PATH\" 2>/dev/null || true",
            "timeout": 15000
          }
        ]
      }
    ]
  }
}

Block modifications to protected files

Prevent Claude Code from touching certain critical files:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "if echo \"$CLAUDE_FILE_PATH\" | grep -qE '(\\.env|package-lock\\.json|yarn\\.lock|pnpm-lock\\.yaml)$'; then echo 'BLOCK: This file is protected and should not be modified by Claude Code' >&2; exit 2; fi",
            "timeout": 5000
          }
        ]
      }
    ]
  }
}

The BLOCK keyword in stderr output combined with exit code 2 tells Claude Code not to execute the action.

Block dangerous commands

Intercept risky Bash commands before execution:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "if echo \"$CLAUDE_COMMAND\" | grep -qE '(rm\\s+-rf\\s+/|git\\s+push\\s+--force|git\\s+push\\s+-f|git\\s+reset\\s+--hard)'; then echo 'BLOCK: Dangerous command intercepted' >&2; exit 2; fi",
            "timeout": 5000
          }
        ]
      }
    ]
  }
}

Run tests after code changes

Automatically execute tests when Claude modifies a source file:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "if echo \"$CLAUDE_FILE_PATH\" | grep -qE '\\.(ts|tsx|js|jsx)$' && ! echo \"$CLAUDE_FILE_PATH\" | grep -q 'node_modules'; then npm test -- --bail --findRelatedTests \"$CLAUDE_FILE_PATH\" 2>&1 | tail -20; fi",
            "timeout": 30000
          }
        ]
      }
    ]
  }
}

Jest’s --findRelatedTests only runs tests related to the modified file, which keeps the hook fast.

Notification on task completion

Send a notification when Claude Code finishes:

{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "curl -s -X POST 'https://hooks.slack.com/services/XXX/YYY/ZZZ' -H 'Content-Type: application/json' -d '{\"text\": \"Claude Code has finished its task\"}' > /dev/null",
            "timeout": 10000
          }
        ]
      }
    ]
  }
}

On macOS, you can also use a system notification:

{
  "type": "command",
  "command": "osascript -e 'display notification \"Task complete\" with title \"Claude Code\"'",
  "timeout": 5000
}

On Linux:

{
  "type": "command",
  "command": "notify-send 'Claude Code' 'Task complete'",
  "timeout": 5000
}

Auto-commit after successful changes

Automatically commit modifications when Claude Code finishes:

{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "cd \"$CLAUDE_PROJECT_DIR\" && if [ -n \"$(git status --porcelain)\" ]; then git add -A && git commit -m 'auto: changes applied by Claude Code'; fi",
            "timeout": 15000
          }
        ]
      }
    ]
  }
}

Use this with caution. Combine it with file protection hooks to avoid committing secrets.

Hook communication

Hooks communicate with Claude Code through three channels:

Standard output (stdout)

Everything your hook writes to stdout is sent back to Claude Code as user context. Claude sees this output and can take it into account in its next actions.

# Test results are visible to Claude
npm test 2>&1

This is useful for feedback: if tests fail, Claude Code sees the errors and can fix them.

Standard error (stderr)

stderr output is used for blocking messages. When a hook wants to prevent an action:

echo "BLOCK: Reason for blocking" >&2
exit 2

Exit codes

CodeMeaning
0Success, the action proceeds
1Non-fatal error, the action proceeds but the error is reported
2Block: the action is cancelled (mainly for PreToolUse)

Exit code 2 is the primary mechanism for blocking an action in a PreToolUse hook. Claude Code sees the BLOCK message and understands why the action was refused.

Available environment variables

Hooks receive context through environment variables. The available variables depend on the event and tool:

VariableDescriptionAvailable in
CLAUDE_FILE_PATHPath of the affected fileWrite, Edit, Read
CLAUDE_COMMANDShell command to executeBash
CLAUDE_PROJECT_DIRProject rootAll hooks
CLAUDE_TOOL_NAMEName of the triggered toolAll PreToolUse/PostToolUse hooks
CLAUDE_TOOL_INPUTFull JSON input of the toolAll PreToolUse/PostToolUse hooks

You can parse CLAUDE_TOOL_INPUT to access all tool parameters:

# Extract file path from JSON input
FILE=$(echo "$CLAUDE_TOOL_INPUT" | jq -r '.file_path // empty')

Debugging hooks

Test a hook manually

Before configuring a hook, test it in your terminal:

# Simulate the environment variables
export CLAUDE_FILE_PATH="src/index.ts"
export CLAUDE_COMMAND="npm test"
export CLAUDE_PROJECT_DIR="/home/user/my-project"

# Run the hook
bash -c 'if echo "$CLAUDE_FILE_PATH" | grep -qE "\\.env$"; then echo "BLOCK" >&2; exit 2; fi'
echo $?  # Should print 0 (no block for src/index.ts)

Verbose mode

Run Claude Code in verbose mode to see hook execution:

claude --verbose

You will see in the logs:

  • Which hook fires
  • The executed command
  • The hook output
  • The return code

Common pitfalls

The hook does not fire

  • Verify that the matcher matches the tool name (case-sensitive: Write, not write)
  • Verify that the settings file is in the right location
  • Restart Claude Code to reload the configuration

The hook fires but fails

  • Test the command manually in your terminal
  • Verify that the tools used (prettier, eslint, jq) are installed and in PATH
  • Check the timeout: a hook that runs too long will be killed

The hook blocks everything

  • Check your condition logic: a grep without -q or a misplaced exit 2 can block all actions
  • Add temporary logging: echo "DEBUG: FILE=$CLAUDE_FILE_PATH" >> /tmp/hooks.log

Best practices

Keep hooks fast

A slow hook slows down the entire workflow. Aim for under 5 seconds per hook. For long tasks (full test suite, build), limit yourself to Stop hooks which do not block the interaction.

Make hooks idempotent

A hook may be executed multiple times on the same file. Make sure it produces the same result every time. Prettier is naturally idempotent (formatting an already formatted file changes nothing). A hook that appends content to a file is not.

Handle errors gracefully

Add || true or 2>/dev/null when a hook failure should not block Claude Code. A formatter that fails on a binary file should not stop the workflow.

# Good: failure is silent
npx prettier --write "$CLAUDE_FILE_PATH" 2>/dev/null || true

# Bad: an unsupported file crashes everything
npx prettier --write "$CLAUDE_FILE_PATH"

Secure your hooks

Hooks have full access to your system. A few rules:

  • Do not put secrets in the commands (use environment variables or files)
  • Validate inputs: $CLAUDE_FILE_PATH could contain special characters
  • Limit permissions: if a hook does not need write access, do not give it write access
  • Always quote variables: "$CLAUDE_FILE_PATH" not $CLAUDE_FILE_PATH

Version team hooks

Put shared hooks in .claude/settings.json and version them in Git. Every team member benefits from the same protections (forbidden files, auto-formatting, blocked commands).

Personal hooks (notifications, formatting preferences) go in ~/.claude/settings.json or .claude/settings.local.json.

Combine hooks

Hooks are more powerful combined. A robust setup for a TypeScript project:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "if echo \"$CLAUDE_FILE_PATH\" | grep -qE '(\\.env|\\.lock)$'; then echo 'BLOCK: Protected file' >&2; exit 2; fi",
            "timeout": 5000
          }
        ]
      },
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "if echo \"$CLAUDE_COMMAND\" | grep -qE '(rm\\s+-rf|--force|--hard)'; then echo 'BLOCK: Dangerous command' >&2; exit 2; fi",
            "timeout": 5000
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "if echo \"$CLAUDE_FILE_PATH\" | grep -qE '\\.(ts|tsx|js|jsx|json|css|md)$'; then npx prettier --write \"$CLAUDE_FILE_PATH\" 2>/dev/null || true; fi",
            "timeout": 10000
          }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "cd \"$CLAUDE_PROJECT_DIR\" && npm test -- --bail 2>&1 | tail -5",
            "timeout": 30000
          }
        ]
      }
    ]
  }
}

This setup protects sensitive files, blocks dangerous commands, auto-formats code, and runs tests at the end of each session.