Free

The Complete Claude Code Hooks Guide: Take Full Control of Every Claude Action

A complete breakdown of Claude Code Hooks — five event types, exit code control, and four real-world configurations to make AI actions auditable, interceptable, and automated.


Every time Claude Code reads a file, writes code, or runs a command, an event system is running in the background. Hooks are the interface to plug into that system — you can inject your own logic at any point: automatically run linters, log operations, intercept dangerous actions, or trigger any shell command.

This article covers the complete Hooks mechanism, configuration, and real-world usage.


What Are Hooks

Hooks are shell commands configured in settings.json that Claude Code runs automatically when specific events occur.

The closest analogy: git hooks. Git can trigger scripts before and after commit, push, and other operations. Claude Code Hooks work exactly the same way — the difference is that the trigger points are the AI's tool calls.

Why does this matter?

The more capable Claude Code becomes, the more you need a deterministic control layer. Hooks provide:
- Execution guarantees that don't depend on prompts (Claude might ignore instructions, but hooks always run)
- Auditable operation logs
- Automated quality checks


Five Hook Types

Type Trigger Typical Use
PreToolUse Before a tool call Block dangerous operations, log intent
PostToolUse After a tool call Auto-lint, run tests
PreCompact Before context compaction Save current state snapshot
Notification When Claude sends a notification Desktop alerts, Slack messages
Stop When Claude finishes responding Summarize logs, trigger follow-up workflows

PreToolUse and PostToolUse are the most commonly used — they let you intercept and post-process tool calls.


Configuration Format

Hooks go in ~/.claude/settings.json (global) or .claude/settings.json at the project root (project-level):

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "npm run lint --silent"
          }
        ]
      }
    ]
  }
}

Three core fields:

  • matcher: A regex matching tool names — determines which tool calls trigger this hook. "Write|Edit" fires after writing or editing files. Leave empty or use ".*" to match all tools.
  • type: Currently only "command".
  • command: Any shell command.

Tool Name Reference

When writing matchers, you need to know the tool names:

Tool Name Action
Write Write a new file
Edit Edit a file
Bash Execute a shell command
Read Read a file
Glob File search
Grep Content search
TodoWrite Update task list

Hook Execution Environment

During execution, data is passed as JSON via stdin:

{
  "tool_name": "Write",
  "tool_input": {
    "file_path": "/path/to/file.rb",
    "content": "..."
  },
  "tool_response": "..."
}

PreToolUse receives tool_input (the arguments before the call). PostToolUse also receives tool_response (the tool's return value).

Use this data to write hooks with conditional logic:

#!/bin/bash
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path')

# Only run rubocop on .rb files
if [[ "$file" == *.rb ]]; then
  rubocop "$file" --autocorrect-all --no-color
fi

Exit Code Meanings

A hook's exit code controls Claude Code's subsequent behavior:

Exit Code Meaning
0 Success, continue execution
2 Block: cancel the current tool call, feed stderr back to Claude
Other non-zero Log the error, but continue execution

Exit code 2 is the most powerful — it lets you write interception logic in PreToolUse to stop Claude from performing an operation and explain why.


Real-World: Four Hook Configurations

1. Auto-format after writing files

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash -c 'file=$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r .file_path 2>/dev/null); [[ \"$file\" == *.rb ]] && rubocop -A \"$file\" --no-color -q || true'"
          }
        ]
      }
    ]
  }
}

2. Block deletion of specific directories

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash -c 'cmd=$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r .command); if echo \"$cmd\" | grep -qE \"rm.*/(migrations|seeds)\"; then echo \"Deleting migrations or seeds directories is not allowed\" >&2; exit 2; fi'"
          }
        ]
      }
    ]
  }
}

When Claude tries to run rm -rf db/migrations/, the hook returns exit code 2, Claude receives the error message, and the operation is cancelled.

3. Operation audit log

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit|Bash",
        "hooks": [
          {
            "type": "command",
            "command": "echo \"$(date '+%Y-%m-%d %H:%M:%S') $CLAUDE_TOOL_NAME: $(echo $CLAUDE_TOOL_INPUT | jq -c .)\" >> ~/.claude/audit.log"
          }
        ]
      }
    ]
  }
}

Every file write or command execution is logged to ~/.claude/audit.log for easy tracing.

4. Desktop notification on completion

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

Get notified automatically when a long task finishes — no need to stare at the terminal. (macOS uses osascript, Linux uses notify-send)


Global vs. Project-Level

Hooks can go in two places:

Global (~/.claude/settings.json): Rules that apply across all projects — logging, notifications.

Project-level (.claude/settings.json): Project-specific checks — rubocop for one project, eslint for another. Project-level and global hooks merge and both run — they don't override each other.

For team projects, commit the project-level settings.json to git so everyone runs the same hooks.


Debugging Hooks

Steps to troubleshoot when a hook isn't firing:

  1. Check matcher spelling: Tool names are case-sensitive — write is not the same as Write
  2. Run the command standalone: Copy the hook command and run it directly in the terminal to confirm it works on its own
  3. Check Claude Code output: Hook stderr appears in the conversation (especially visible with exit code 2)
  4. Simplify for testing: Start with echo "hook triggered" >> /tmp/hook.log to confirm the trigger fires, then layer in complex logic

Summary

The core value of Hooks is connecting unpredictable AI behavior with deterministic engineering standards. Claude might forget to run tests, but a PostToolUse hook won't. Claude might accidentally delete files, but a PreToolUse interceptor won't let it through.

Start with one hook: auto-lint after writing files. Once that's running smoothly, add more.