Hooks

Intercept and control Claude Code's behavior at key lifecycle points with custom scripts or LLM evaluation.

Overview

Hooks allow you to intercept Claude Code's operations at specific points in the workflow. They can approve, deny, or modify tool invocations, add context to responses, and automate permission decisions.

Workflow Control

Approve, deny, or modify tool calls before execution

Context Enhancement

Add additional information to tool outputs

Automation

Auto-approve safe operations to reduce friction

Intelligent Decisions

Use LLM evaluation for complex approval logic

Configuration

Settings File Locations

Hooks are configured in your Claude Code settings files, checked in order of precedence:

Location Scope Priority
.claude/settings.local.json Project (gitignored) Highest
.claude/settings.json Project (version controlled) Medium
~/.claude/settings.json User-level (all projects) Lowest

Basic Structure

{
  "hooks": [
    {
      "matcher": "Read",
      "event": "PreToolUse",
      "type": "command",
      "command": "python ~/.claude/hooks/auto_approve_reads.py"
    }
  ]
}

Hook Configuration Fields

Field Required Description
matcher Yes Tool name or regex pattern to match (e.g., "Read", ".*")
event Yes When to trigger: PreToolUse, PostToolUse, etc.
type Yes "command" (bash script) or "prompt" (LLM evaluation)
command If type=command Shell command to execute
prompt If type=prompt Template for LLM evaluation

Hook Types

Command Hooks

type: command Fast Deterministic

Execute custom bash commands or scripts to evaluate tool calls. Best for fast, deterministic validation logic.

{
  "matcher": "Bash",
  "event": "PreToolUse",
  "type": "command",
  "command": "python ~/.claude/hooks/validate_bash.py"
}

Use cases: File path validation, simple regex checks, environment variable checks

Prompt-Based Hooks

type: prompt LLM-powered Higher latency

Use LLM evaluation for complex, context-aware decision making. The prompt has access to full hook context via template variables.

{
  "matcher": "Write",
  "event": "PreToolUse",
  "type": "prompt",
  "prompt": "Review this file write operation:\n{{toolCall}}\n\nApprove only if the change seems safe and follows project conventions."
}

Use cases: Code review, semantic validation, complex approval logic

Hook Events

Hooks can be triggered at different points in Claude Code's lifecycle:

PreToolUse

Invoked before a tool is executed. Can approve, deny, or modify the tool call.

Matcher: Tool name (e.g., Read, Write, Bash)

Actions: Approve, deny, modify via updatedInput

PostToolUse

Invoked after a tool completes. Can block with feedback or add context to the result.

Matcher: Tool name

Actions: Block with feedback, add context via additionalContext

UserPromptSubmit

Triggered when user submits a prompt. Can modify the prompt before it's sent to Claude.

Matcher: Not used (always triggers)

Actions: Modify prompt, add context

Stop

Triggered when the main agent conversation stops (completes or is interrupted).

Matcher: Not used

Use case: Cleanup, logging, follow-up actions

SubagentStop

Triggered when a subagent completes or is interrupted.

Matcher: Subagent name

Use case: Extract subagent results, logging

SessionStart

Triggered at the beginning of a Claude Code session.

Matcher: Not used

Use case: Initialization, environment setup

SessionEnd

Triggered when Claude Code session ends.

Matcher: Not used

Use case: Cleanup, save state, generate reports

Notification

Triggered on system notifications (errors, warnings, info).

Matcher: Notification type or pattern

Use case: Custom logging, alerts, error handling

PermissionRequest

Triggered when Claude requests permission for an action.

Matcher: Tool name being requested

Actions: Auto-approve or auto-deny based on rules

PreCompact

Triggered before conversation history is compacted (when context limit is reached).

Matcher: Not used

Use case: Extract information before compaction, save context

Hook Input/Output

Input Format (stdin)

Hooks receive JSON input via stdin with context about the event:

{
  "event": "PreToolUse",
  "toolName": "Write",
  "toolCall": {
    "file_path": "/path/to/file.txt",
    "content": "Hello, world!"
  },
  "context": {
    "projectDir": "/Users/user/project",
    "conversationId": "abc123"
  }
}

Exit Codes

Exit Code Meaning Effect
0 Success/Approve Continue with the operation
2 Blocking Error/Deny Abort the operation with error message
Other Non-blocking error Log warning but continue operation

Output Format (stdout)

Hooks return JSON to stdout to communicate decisions:

{
  "decision": "approve",
  "reason": "File path is safe and within project directory",
  "updatedInput": {
    "file_path": "/validated/path/file.txt",
    "content": "Hello, world!"
  },
  "additionalContext": "This file was auto-approved by security hook",
  "hookSpecificOutput": {
    "validationPassed": true,
    "checks": ["path", "content", "permissions"]
  }
}

Response Fields

Field Type Description
decision string "approve" or "deny"
reason string Human-readable explanation shown to user
updatedInput object Modified tool parameters (PreToolUse only)
additionalContext string Extra context added to Claude's view (PostToolUse)
hookSpecificOutput object Custom data for logging or chaining hooks

Practical Examples

Bash Command Validation

Prevent dangerous commands from being executed:

{
  "hooks": [
    {
      "matcher": "Bash",
      "event": "PreToolUse",
      "type": "command",
      "command": "python ~/.claude/hooks/validate_bash.py"
    }
  ]
}
#!/usr/bin/env python3
import sys
import json

# Read hook input
hook_input = json.loads(sys.stdin.read())
command = hook_input.get("toolCall", {}).get("command", "")

# Dangerous patterns
dangerous_patterns = [
    "rm -rf /",
    ":(){ :|:& };:",  # Fork bomb
    "> /dev/sda",
    "mkfs",
    "dd if=/dev/random"
]

# Check for dangerous commands
for pattern in dangerous_patterns:
    if pattern in command:
        print(json.dumps({
            "decision": "deny",
            "reason": f"Blocked dangerous command pattern: {pattern}"
        }))
        sys.exit(2)

# Approve safe commands
print(json.dumps({
    "decision": "approve",
    "reason": "Command passed safety validation"
}))
sys.exit(0)

Auto-Approve Read Operations

Automatically approve safe read operations to reduce friction:

{
  "hooks": [
    {
      "matcher": "Read",
      "event": "PreToolUse",
      "type": "command",
      "command": "python ~/.claude/hooks/auto_approve_reads.py"
    }
  ]
}
#!/usr/bin/env python3
import sys
import json
import os

hook_input = json.loads(sys.stdin.read())
file_path = hook_input.get("toolCall", {}).get("file_path", "")
project_dir = hook_input.get("context", {}).get("projectDir", "")

# Auto-approve if within project directory
if file_path.startswith(project_dir):
    # Check file size to avoid reading huge files
    if os.path.exists(file_path):
        file_size = os.path.getsize(file_path)
        if file_size > 10 * 1024 * 1024:  # 10MB limit
            print(json.dumps({
                "decision": "deny",
                "reason": f"File too large: {file_size / 1024 / 1024:.2f}MB"
            }))
            sys.exit(2)

    print(json.dumps({
        "decision": "approve",
        "reason": "Auto-approved: file within project directory"
    }))
    sys.exit(0)

# Deny reads outside project
print(json.dumps({
    "decision": "deny",
    "reason": "File is outside project directory"
}))
sys.exit(2)

Intelligent Stop Hook

Extract action items when conversation ends:

{
  "hooks": [
    {
      "event": "Stop",
      "type": "prompt",
      "prompt": "Review this conversation and extract any action items, TODOs, or follow-up tasks:\n\n{{conversation}}\n\nFormat as a markdown checklist."
    }
  ]
}

Context Enhancement

Add architectural context after reading files:

{
  "hooks": [
    {
      "matcher": "Read",
      "event": "PostToolUse",
      "type": "command",
      "command": "python ~/.claude/hooks/add_architecture_context.py"
    }
  ]
}
#!/usr/bin/env python3
import sys
import json
import os

hook_input = json.loads(sys.stdin.read())
file_path = hook_input.get("toolCall", {}).get("file_path", "")

# Add context based on file type
context = ""
if file_path.endswith(".py"):
    context = "Note: This project uses FastAPI framework with async/await patterns."
elif file_path.endswith((".tsx", ".jsx")):
    context = "Note: This is a React component using TypeScript and hooks."
elif "database" in file_path.lower():
    context = "Note: This project uses PostgreSQL with SQLAlchemy ORM."

print(json.dumps({
    "decision": "approve",
    "additionalContext": context
}))
sys.exit(0)

Environment Variables

Hooks have access to special environment variables:

CLAUDE_PROJECT_DIR

Absolute path to the current project directory

#!/bin/bash
echo "Project: $CLAUDE_PROJECT_DIR"
CLAUDE_CODE_REMOTE

Set to "true" if running in remote mode (SSH, containers)

if [ "$CLAUDE_CODE_REMOTE" = "true" ]; then
  echo "Running in remote environment"
fi
CLAUDE_ENV_FILE

Path to the project's .env file (if configured)

import os
env_file = os.environ.get("CLAUDE_ENV_FILE")
if env_file:
    # Load environment variables from file
    pass

MCP Tools Support

Hooks can intercept MCP (Model Context Protocol) tools using the naming pattern:

mcp__<server-name>__<tool-name>

Example: MCP Tool Hook

{
  "hooks": [
    {
      "matcher": "mcp__filesystem__read_file",
      "event": "PreToolUse",
      "type": "command",
      "command": "python ~/.claude/hooks/validate_mcp_read.py"
    }
  ]
}

Match All MCP Tools

Use regex to match all tools from a specific MCP server:

{
  "hooks": [
    {
      "matcher": "mcp__github__.*",
      "event": "PreToolUse",
      "type": "command",
      "command": "python ~/.claude/hooks/audit_github_access.py"
    }
  ]
}

Best Practices

1

Keep hooks fast

Hooks block Claude's execution. Optimize for speed and use caching when possible.

2

Prefer command hooks for simple logic

Use bash/python scripts for deterministic checks. Reserve prompt hooks for complex decisions.

3

Provide clear error messages

When denying operations, explain why in the "reason" field so users understand.

4

Use specific matchers

Target specific tools instead of matching everything to avoid unnecessary overhead.

5

Test hooks thoroughly

Broken hooks can block Claude entirely. Test edge cases and error handling.

6

Version control project hooks

Check hooks into .claude/settings.json to share with team members.

7

Use local settings for personal preferences

Put personal hooks in .claude/settings.local.json (gitignored).

Security Considerations

Important Security Notes

  • Hooks execute with your user's permissions - validate all inputs carefully
  • Never trust tool parameters without validation (path traversal, injection attacks)
  • Be cautious with hooks from untrusted sources
  • Avoid exposing sensitive data in hook output (it's visible to Claude)

Security Checklist

Validate file paths to prevent directory traversal attacks

Sanitize bash commands to prevent command injection

Use allowlists instead of denylists for validation

Set appropriate file permissions on hook scripts (chmod 700)

Review hooks before running in production environments

Avoid storing secrets in hook scripts - use environment variables

Example: Path Validation

#!/usr/bin/env python3
import sys
import json
import os
from pathlib import Path

hook_input = json.loads(sys.stdin.read())
file_path = hook_input.get("toolCall", {}).get("file_path", "")
project_dir = hook_input.get("context", {}).get("projectDir", "")

# Resolve to absolute path and check if within project
try:
    abs_path = Path(file_path).resolve()
    abs_project = Path(project_dir).resolve()

    # Check if path is within project (prevents directory traversal)
    if not str(abs_path).startswith(str(abs_project)):
        print(json.dumps({
            "decision": "deny",
            "reason": "Path is outside project directory"
        }))
        sys.exit(2)

    # Additional checks for sensitive files
    sensitive_patterns = [".env", "secrets", "credentials", ".ssh", ".aws"]
    if any(pattern in str(abs_path).lower() for pattern in sensitive_patterns):
        print(json.dumps({
            "decision": "deny",
            "reason": "Access to sensitive files requires manual approval"
        }))
        sys.exit(2)

    print(json.dumps({"decision": "approve"}))
    sys.exit(0)

except Exception as e:
    print(json.dumps({
        "decision": "deny",
        "reason": f"Path validation error: {str(e)}"
    }))
    sys.exit(2)

Related