Skip to main content
Module 3: Real Architecture 3 / 6
Advanced Session 15 Hooks Events

Hooks System

Build event subscribers for PreToolUse, PostToolUse, and other lifecycle events.

March 20, 2026 18 min read

What You’ll Learn

Claude Code has built-in permissions, but organizations need more. A security team might want to block all writes to production config files. A compliance team might need an audit log of every command executed. A platform team might want to auto-tag files modified by AI. The hooks system makes all of this possible.

By the end, you’ll know:

  • What hooks are and when they fire
  • The four hook event types and their purposes
  • How to configure hooks in settings.json
  • How matcher patterns filter which tools trigger a hook
  • The input/output contract (JSON stdin, exit codes)
  • How to build security gates and audit systems

The Problem

The built-in permission system is binary: allow or deny. But real-world policies are nuanced:

  • “Allow Bash, but block any command containing rm -rf
  • “Allow file edits, but not to files in config/production/
  • “Allow everything, but log every action to an audit file”
  • “Allow git commits, but require a ticket number in the message”

You can’t express these rules through the standard permission UI. You need programmable policy enforcement — code that runs at specific points in the tool execution lifecycle and can inspect, modify, or block actions.

Without hooks:
  AI calls tool → Permission check (allow/deny) → Execute

With hooks:
  AI calls tool → Permission check → PreToolUse hook

                                    inspect, validate,
                                    block if needed


                                    Execute tool


                                    PostToolUse hook

                                    log, audit,
                                    side effects

How It Works

Hook Types

Claude Code fires hooks at four lifecycle events:

┌──────────────────────────────────────────────────┐
│              Hook Lifecycle                       │
│                                                   │
│  ┌─────────────┐                                  │
│  │ Notification │  Fires when AI produces a        │
│  │    Hook      │  notification (status messages)  │
│  └─────────────┘                                  │
│                                                   │
│  ┌─────────────┐                                  │
│  │ PreToolUse  │  Fires BEFORE a tool executes    │
│  │    Hook     │  Can BLOCK the tool call          │
│  └──────┬──────┘                                  │
│         │                                         │
│         ▼                                         │
│  ┌─────────────┐                                  │
│  │ Tool        │  The actual tool execution        │
│  │ Execution   │                                   │
│  └──────┬──────┘                                  │
│         │                                         │
│         ▼                                         │
│  ┌─────────────┐                                  │
│  │ PostToolUse │  Fires AFTER a tool executes     │
│  │    Hook     │  For logging and side effects     │
│  └─────────────┘                                  │
│                                                   │
│  ┌─────────────┐                                  │
│  │   Stop      │  Fires when the agent loop        │
│  │   Hook      │  is about to end a turn           │
│  └─────────────┘                                  │
│                                                   │
└──────────────────────────────────────────────────┘
HookWhenCan Block?Common Use
PreToolUseBefore tool runsYesSecurity gates, validation
PostToolUseAfter tool runsNoLogging, auditing, side effects
NotificationOn status messagesNoCustom notifications
StopBefore turn endsYesFinal validation, cleanup

Configuration

Hooks are defined in .claude/settings.json (project-level) or ~/.claude/settings.json (user-level):

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "command": "/path/to/validate-command.sh"
      },
      {
        "matcher": "Edit",
        "command": "/path/to/check-file-path.sh"
      }
    ],
    "PostToolUse": [
      {
        "matcher": "*",
        "command": "/path/to/audit-log.sh"
      }
    ]
  }
}

Each hook entry has:

  • matcher — Which tool triggers this hook. Use a tool name like "Bash" or "*" for all tools.
  • command — The shell command to run. This is your hook script.

Matcher Patterns

The matcher determines which tool calls trigger the hook:

Matcher      Matches
────────     ────────────────────────────
"Bash"       Only Bash tool calls
"Edit"       Only Edit tool calls
"Read"       Only Read tool calls
"mcp__*"     All MCP tool calls
"*"          Every tool call

Multiple hooks can match the same tool call. They execute in order. If any PreToolUse hook blocks, the tool call is stopped.

The Input/Output Contract

When a hook fires, Claude Code passes context as JSON on stdin and reads the decision from the exit code:

Input (stdin):

{
  "hook_type": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "rm -rf /tmp/build-cache"
  },
  "session_id": "abc-123",
  "message_id": "msg-456"
}

Output (exit code):

Exit CodeMeaningEffect
0AllowTool call proceeds normally
2BlockTool call is blocked, AI is told why
OtherErrorTreated as allow (fail-open)

Stdout (optional, for exit code 2):

When blocking, the hook can write a reason to stdout. This reason is shown to the AI so it can adjust:

BLOCKED: Cannot delete directories outside of project root.

The AI receives this as a tool error and can try a different approach.

Execution Flow (Detailed)

Here’s the complete flow when a PreToolUse hook is configured:

AI wants to run: Bash("npm install express")


  ┌──────────────────┐
  │ Permission Check  │   Standard allow/deny
  └────────┬─────────┘
           │ (allowed)

  ┌──────────────────┐
  │ Matcher Check     │   Does "Bash" match any hooks?
  └────────┬─────────┘
           │ (yes, matched)

  ┌──────────────────┐
  │ Run Hook Script   │   Spawn process, pipe JSON to stdin
  │                   │
  │ stdin: {          │
  │   "tool_name":    │
  │     "Bash",       │
  │   "tool_input": { │
  │     "command":     │
  │     "npm install"  │
  │   }               │
  │ }                 │
  └────────┬─────────┘

     ┌─────┴─────┐
     │           │
  exit 0      exit 2
  (allow)     (block)
     │           │
     ▼           ▼
  Execute     Return error
  tool        to AI:
              "BLOCKED: ..."

Key Insight

Hooks are the extension point for organizational policies. They let you enforce rules that the AI must follow without modifying the AI’s instructions.

This distinction is important. You could write “never delete production files” in CLAUDE.md, and the AI would usually comply. But instructions can be overridden, forgotten after compaction, or ignored in edge cases. A PreToolUse hook that checks file paths is deterministic — it cannot be bypassed, no matter what the AI decides.

Think of it this way:

LayerMechanismGuarantee
CLAUDE.md instructionsAI reads and followsBest-effort (AI judgment)
Permission systemAllow/deny per toolBinary, no nuance
HooksCustom code per callDeterministic, programmable

Hooks fill the gap between “ask the AI nicely” and “hard-code it into Claude Code.” They are the policy enforcement layer.

Hands-On Example

Example 1: Block Dangerous Commands

A PreToolUse hook that prevents destructive shell commands:

#!/bin/bash
# hooks/block-dangerous.sh
# PreToolUse hook for Bash tool

# Read JSON input from stdin
INPUT=$(cat)

# Extract the command
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

# Define blocked patterns
BLOCKED_PATTERNS=(
  "rm -rf /"
  "rm -rf ~"
  "DROP DATABASE"
  "DROP TABLE"
  "mkfs"
  "> /dev/sda"
  "chmod -R 777 /"
)

# Check each pattern
for pattern in "${BLOCKED_PATTERNS[@]}"; do
  if echo "$COMMAND" | grep -qi "$pattern"; then
    echo "BLOCKED: Command contains dangerous pattern: $pattern"
    exit 2
  fi
done

# Allow the command
exit 0

Example 2: Audit Log

A PostToolUse hook that logs every tool execution:

#!/bin/bash
# hooks/audit-log.sh
# PostToolUse hook for all tools

INPUT=$(cat)

# Extract fields
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
SESSION=$(echo "$INPUT" | jq -r '.session_id')

# Build log entry
LOG_ENTRY=$(jq -n \
  --arg ts "$TIMESTAMP" \
  --arg tool "$TOOL" \
  --arg session "$SESSION" \
  --argjson input "$(echo "$INPUT" | jq '.tool_input')" \
  '{timestamp: $ts, tool: $tool, session: $session, input: $input}')

# Append to audit log
echo "$LOG_ENTRY" >> ~/.claude/audit.jsonl

# PostToolUse hooks always exit 0 (can't block after execution)
exit 0

This produces a JSONL audit trail:

{"timestamp":"2026-03-14T10:00:01Z","tool":"Bash","session":"abc","input":{"command":"npm test"}}
{"timestamp":"2026-03-14T10:00:03Z","tool":"Edit","session":"abc","input":{"file_path":"src/auth.ts",...}}
{"timestamp":"2026-03-14T10:00:05Z","tool":"Read","session":"abc","input":{"file_path":"package.json"}}

Example 3: Protect Production Files

A PreToolUse hook that prevents edits to production configuration:

#!/bin/bash
# hooks/protect-prod-config.sh
# PreToolUse hook for Edit and Write tools

INPUT=$(cat)

FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

# Define protected paths
PROTECTED=(
  "config/production"
  ".env.production"
  "docker-compose.prod"
  "k8s/production"
)

for protected in "${PROTECTED[@]}"; do
  if echo "$FILE_PATH" | grep -q "$protected"; then
    echo "BLOCKED: Cannot modify production file: $FILE_PATH. Use a staging config instead."
    exit 2
  fi
done

exit 0

Putting It All Together

Here’s a complete settings.json with all three hooks:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "command": "/project/.claude/hooks/block-dangerous.sh"
      },
      {
        "matcher": "Edit",
        "command": "/project/.claude/hooks/protect-prod-config.sh"
      },
      {
        "matcher": "Write",
        "command": "/project/.claude/hooks/protect-prod-config.sh"
      }
    ],
    "PostToolUse": [
      {
        "matcher": "*",
        "command": "/project/.claude/hooks/audit-log.sh"
      }
    ]
  }
}

With this configuration:

  1. Every Bash command is checked against a blocklist before execution
  2. Every file edit is checked against protected paths before execution
  3. Every tool execution (all tools) is logged to an audit file after execution

The AI is unaware of the hooks. It calls tools normally. If a hook blocks, the AI receives an error message and adapts — typically by choosing a safer alternative.

What Changed

Without HooksWith Hooks
Policies are instructions (best-effort)Policies are code (deterministic)
No audit trailEvery action logged
Protection relies on AI complianceProtection enforced at runtime
Same rules for all projectsPer-project hook configuration
No way to integrate with external systemsHooks can call any external API
Security is trust-basedSecurity is verify-then-trust

Next Session

This completes Module 3: Real Architecture. You now understand the three pillars that make Claude Code extensible: the control protocol (how CLI and SDK communicate), MCP (how external tools plug in), and hooks (how policies are enforced).

Session 16 begins Module 4: Configuration & Customization with Session Storage — how Claude Code persists state across sessions, enabling continuity and memory.