Library · Hooks and helper agents

Hooks LIVE: building hooks from scratch

Builder75 minUpdated: October 2026
36 of 105 in the library

Module: 7. Hooks: system-level autonomy | Time: ~15 min theory + 60 min practice


The gist

The previous lesson (Hooks: automatic rules) was theory: about 30 events, 5 handler types, the JSON protocol. This one is three real hooks used every day. The first protects you from accidentally leaking API keys. The second keeps you from blowing your budget. The third keeps a complete log of what Claude touched and when. Bonus: an HTTP webhook for external notifications. We build from scratch and go through every line.


Key concepts

  • pre-tool-use-no-secrets.sh: scans files for secret patterns before they're written
  • pre-tool-use-budget-check.sh: checks an operations counter and stops things if the limit is exceeded
  • post-tool-use-audit-log.sh: logs every file change with a timestamp
  • exit code 0 / 2: how a hook tells Claude Code "allow" (0) or "block" (2)
  • matcher: a filter for which tools to react to ("Write|Edit", "Bash", "*")
  • tool_input: a JSON object with the tool's input data (path, content, command)
  • grep: searching for patterns (API keys, tokens) in file contents
  • jq: parsing the JSON that Claude Code passes to the hook through stdin
  • Testing a hook: how to check that the hook fires correctly

Theory

How a hook works technically

🎨 Picture this: a hook is a customs checkpoint at a border. Every shipment (tool call) goes through a scanner (the stdin JSON). Customs checks it and either lets it through (exit 0) or holds it (exit 2). The shipment doesn't know customs exists; it just moves along the conveyor.

Claude Code calls a hook like any ordinary bash script. It passes data in JSON format through stdin. The hook analyzes the data, runs its logic and returns the result through an exit code.

Code
Claude Code wants to write a file
        ↓
Calls the PreToolUse hook (matcher: "Write|Edit")
        ↓
Passes JSON through stdin:
{
  "hook_event_name": "PreToolUse",
  "tool_name": "Write",
  "tool_input": {
    "file_path": "/project/config.py",
    "content": "API_KEY = 'sk-proj-abc123...'"
  },
  "session_id": "abc123",
  "cwd": "/Users/me/project"
}
        ↓
The hook analyzes tool_input.content
        ↓
exit 0 → Claude Code writes the file
exit 2 → Claude Code stops, and the hook's stderr is passed to Claude

Important: exit code 2 is what blocks, not 1. Exit code 1 is just a regular script error, and Claude keeps going.

When blocking, the hook writes a message to stderr (>&2). That's what Claude will see and report to the user.


🎨 Picture this: the no-secrets hook is like a metal detector at a bank entrance. You've got keys in your pocket and the frame beeps. Not because you're a bad person, just because that's the rule: keys don't go through. Drop them in the tray and you're through.

Hook 1: pre-tool-use-no-secrets.sh

The job: prevent API keys, tokens and passwords from being accidentally hardcoded.

The problem it solves: developers often paste a key straight into the code "just for now," forget to remove it, and commit it to git. The hook stops this before the file is written.

Creating the file

bash
mkdir -p ~/.claude/hooks
touch ~/.claude/hooks/pre-tool-use-no-secrets.sh
chmod +x ~/.claude/hooks/pre-tool-use-no-secrets.sh

The script

bash
#!/bin/bash
# pre-tool-use-no-secrets.sh
# Blocks writing files that contain hardcoded secrets

# Read the data from Claude Code through stdin
INPUT=$(cat)

# Extract the data from the official JSON format
# tool_name is at the top level
# file_path and content are inside tool_input
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty')

# For the Edit tool, the content is in the new_string field
if [[ "$TOOL_NAME" == "Edit" ]]; then
  CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // empty')
fi

# Only check file-writing tools
# (the "Write|Edit" matcher in settings.json already filters,
#  but a double check doesn't hurt)
if [[ "$TOOL_NAME" != "Write" && "$TOOL_NAME" != "Edit" ]]; then
  exit 0  # Not a write, let it through
fi

# Patterns we look for (possible API keys and tokens)
PATTERNS=(
  'sk-[a-zA-Z0-9]{20,}'          # OpenAI / Anthropic API keys
  'ghp_[a-zA-Z0-9]{36}'          # GitHub Personal Access Token
  'xoxb-[0-9]+-[a-zA-Z0-9]+'     # Slack Bot Token
  'AKIA[0-9A-Z]{16}'              # AWS Access Key
  'AIza[0-9A-Za-z_-]{35}'         # Google API Key
  'password\s*=\s*["\'][^"\']+["\']'  # Explicit password in code
  'secret\s*=\s*["\'][^"\']+["\']'    # Explicit secret in code
)

# Check the content against each pattern
for PATTERN in "${PATTERNS[@]}"; do
  if echo "$CONTENT" | grep -qE "$PATTERN"; then
    # Message to stderr: Claude will see it and pass it on to the user
    echo "BLOCKED: Possible secret/API key detected in file $FILE_PATH" >&2
    echo "Pattern: $PATTERN" >&2
    echo "Use environment variables (.env) or a secrets manager instead of hardcoding." >&2
    exit 2  # Exit code 2 = block the action
  fi
done

exit 0  # No secrets found, allow

Adding it to settings.json

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/pre-tool-use-no-secrets.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

Pay attention to the structure:

  • "PreToolUse" is CamelCase (not pre_tool_use)
  • "matcher": "Write|Edit": the hook fires only when files are written or edited (not on reads, not on bash commands)
  • "type": "command" (not "type": "bash")
  • "timeout": 30: if the script doesn't respond within 30 seconds, Claude carries on

Testing the hook

Give Claude Code this command:

Type this into the chat
Create a config.py file with the content: API_KEY = 'sk-proj-test123456789012345678901234'

Expected result:

Type this into the chat
BLOCKED: Possible secret/API key detected in file config.py
Use environment variables (.env) or a secrets manager instead of hardcoding.

Claude Code won't write the file. It will suggest using .env.


🎨 Picture this: the budget-check hook is a water meter. Once you've used 500 gallons, the supply shuts off. Not because there's no water, just because a limit is set. Want more? Turn the tap back on by hand tomorrow.

Hook 2: pre-tool-use-budget-check.sh

The job: keep spending under control by stopping Claude Code if there are too many operations in a day.

The problem it solves: long autonomous tasks can perform thousands of operations. The hook sets a hard cap.

bash
#!/bin/bash
# pre-tool-use-budget-check.sh
# Budget control by number of operations per day

COUNTER_FILE="/tmp/claude_ops_$(date +%Y%m%d).count"
DAILY_LIMIT=500  # Maximum operations per day

# Read the current counter
if [ -f "$COUNTER_FILE" ]; then
  CURRENT=$(cat "$COUNTER_FILE")
else
  CURRENT=0
fi

# Check the limit
if [ "$CURRENT" -ge "$DAILY_LIMIT" ]; then
  echo "STOP: Daily operations limit reached ($CURRENT/$DAILY_LIMIT)" >&2
  echo "It resets at midnight. To reset manually: rm $COUNTER_FILE" >&2
  exit 2  # Exit code 2 = block
fi

# Increment the counter
echo $((CURRENT + 1)) > "$COUNTER_FILE"

# Warning at 80% usage (through stdout, doesn't block)
THRESHOLD=$((DAILY_LIMIT * 80 / 100))
if [ "$CURRENT" -ge "$THRESHOLD" ]; then
  echo "WARNING: $CURRENT/$DAILY_LIMIT operations used (80% of the limit)"
fi

exit 0

Adding it to settings.json (next to the first hook)

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/pre-tool-use-no-secrets.sh",
            "timeout": 30
          }
        ]
      },
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/pre-tool-use-budget-check.sh"
          }
        ]
      }
    ]
  }
}

Note: the first hook has "matcher": "Write|Edit", so it only checks file writes. The second has no matcher, which means it fires on all tools.

Multiple PreToolUse hooks run in parallel. If any of them returns exit 2, the action is blocked.


🎨 Picture this: the audit-log hook is an airplane's flight recorder (the black box). It records every move, continuously. After a "crash" (something broke), you open the box and see exactly: at 14:23 Claude changed config.py, at 14:25 it ran a bash command. Without the box, you're just guessing.

Hook 3: post-tool-use-audit-log.sh

The job: keep a complete log of what Claude changed: which files, at what time, with which tool.

The problem it solves: after a session it's unclear what exactly Claude changed. The log lets you trace every change and roll it back if needed.

bash
#!/bin/bash
# post-tool-use-audit-log.sh
# Audit log of all file changes

LOG_FILE="$HOME/.claude/audit-log.txt"
mkdir -p "$(dirname "$LOG_FILE")"

# Read the data from Claude Code through stdin
INPUT=$(cat)

# Extract information about the action
# tool_name is at the top level, the rest is inside tool_input
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // "unknown"')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
PROJECT=$(basename "$(pwd)")

# Only log actions involving files
if [[ -n "$FILE_PATH" ]]; then
  echo "[$TIMESTAMP] PROJECT=$PROJECT TOOL=$TOOL_NAME FILE=$FILE_PATH" >> "$LOG_FILE"
fi

# Also log bash commands (the command field inside tool_input)
BASH_CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if [[ -n "$BASH_CMD" ]]; then
  # Show the first 100 characters of the command
  SHORT_CMD="${BASH_CMD:0:100}"
  echo "[$TIMESTAMP] PROJECT=$PROJECT BASH: $SHORT_CMD" >> "$LOG_FILE"
fi

exit 0  # PostToolUse hooks don't block, always exit 0

The full settings.json with three hooks + a notification

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/pre-tool-use-no-secrets.sh",
            "timeout": 30
          }
        ]
      },
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/pre-tool-use-budget-check.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit|Bash",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/post-tool-use-audit-log.sh"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude finished the task\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}

Format checklist: check yours:

  • Event names are CamelCase: PreToolUse, PostToolUse, Stop (not snake_case)
  • Handler type: "type": "command" (not "type": "bash")
  • Each event → an array → an object with matcher + hooks → an array of handlers
  • matcher filters tools: "Write|Edit", "Bash", or empty for all

How to read the audit log

Code
[2026-10-04 14:23:01] PROJECT=acme-realty TOOL=Write FILE=/project/index.md
[2026-10-04 14:23:04] PROJECT=acme-realty TOOL=Edit FILE=/project/CLAUDE.md
[2026-10-04 14:23:09] PROJECT=acme-realty BASH: mkdir -p .claude/skills
[2026-10-04 14:25:33] PROJECT=my-platform TOOL=Write FILE=/strategy/plan.md

You see: time, project, tool, file. If something broke, you know exactly what Claude touched and when.

bash
# See today's log
tail -50 ~/.claude/audit-log.txt

# Find every change to a specific file
grep "CLAUDE.md" ~/.claude/audit-log.txt

# Find every action in a specific project
grep "PROJECT=acme-realty" ~/.claude/audit-log.txt

🎨 Picture this: protecting the .env file is like a red seal on an electrical panel. "Do not touch without an electrician's permission." Claude sees the seal and stops. Without the seal, it might cross some wires by accident and bring all the equipment down.

From practice: a real case with a .env file

From a session transcript: "Ideally, we wouldn't want Claude touching the .env document, because if it changes it, all the automations break. They all depend on those passwords."

A variation of the hook that protects a specific file:

bash
#!/bin/bash
# Protect the .env file from any changes by Claude

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

# Block any changes to .env
if [[ "$FILE_PATH" == *".env"* ]] && [[ "$TOOL_NAME" == "Write" || "$TOOL_NAME" == "Edit" ]]; then
  echo "BLOCKED: the .env file is protected from changes" >&2
  echo "The file contains secrets. Edit it by hand." >&2
  exit 2  # Exit code 2 = block
fi

exit 0

With this hook in place, Claude Code will literally answer: "I can't do that. A hook is blocking my access to this file."

Even simpler: you can use an "if" filter in settings.json instead of checking in the script:

json
{
  "matcher": "Write|Edit",
  "hooks": [
    {
      "type": "command",
      "if": "Write(*.env)",
      "command": "echo 'BLOCKED: .env is protected' >&2 && exit 2"
    },
    {
      "type": "command",
      "if": "Edit(*.env)",
      "command": "echo 'BLOCKED: .env is protected' >&2 && exit 2"
    }
  ]
}

Here "if" works as an extra filter on the arguments: the Tool(pattern) form checks one tool, which is why there are two handlers for Write and Edit. The hook fires only for .env files.


Bonus: HTTP webhook, a hook without a bash script

You don't have to do everything in bash. If you have a server (or a service like a Slack incoming webhook), you can send data over HTTP.

Example: a chat notification when files change

json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "http",
            "url": "http://localhost:3000/hooks/file-changed",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

Claude Code will send a POST request with JSON data about the file. Your server receives:

json
{
  "hook_event_name": "PostToolUse",
  "tool_name": "Write",
  "tool_input": { "file_path": "/project/index.md", "content": "..." },
  "tool_response": "File written successfully",
  "cwd": "/Users/me/project"
}

The server can forward it to Slack or Discord, write it to a database, anything you like.

🎨 Picture this: a command hook is a guard at a post. An HTTP hook is a guard who calls the central office, and the office decides what to do. Useful when the check logic is complex, or when you need to connect Claude Code to outside systems.

When to use HTTP instead of command:

  • Notifications to an outside service (Slack, Discord, Telegram)
  • Centralized auditing across several machines
  • When the check logic lives on a server (a validation microservice)

Testing hooks: a checklist

After creating each hook, check it:

For the no-secrets hook:

Type this into the chat
Create a test.py file with the content: token = 'sk-proj-realkey123456789012345'

Expected: Claude is blocked, and you see the hook's message.

For the audit-log hook:

Type this into the chat
Create a test-audit.md file with the text "Audit test"

Then: tail -5 ~/.claude/audit-log.txt. A new entry should appear.

For the stop-notification hook:

Type this into the chat
What is Claude Code? (a short question)

Expected: after the answer, a macOS notification pops up.


Practice

Assignment: get all three hooks running

  1. Create the ~/.claude/hooks/ folder
  2. Create the three bash scripts with the content from this lesson
  3. Make them executable: chmod +x ~/.claude/hooks/*.sh
  4. Create or update .claude/settings.json and add all three hooks following the template from the lesson
  5. Test each hook (checklist above)
  6. Look at what the audit log looks like after a few operations

Goal: three working hooks, an understanding of the exit code logic, and a first audit log with real entries


Tools and resources

  • jq: JSON parsing in bash (brew install jq on a Mac)
  • chmod +x: makes a script executable
  • osascript: native macOS notifications (built into macOS)
  • tail -f ~/.claude/audit-log.txt: watch the log live, in real time
  • /hooks: the command for viewing active hooks in the Claude Code terminal

Key takeaways

exit 0 = allow, exit 2 = block. Not 1, specifically 2! Exit 1 is just a script error, and Claude keeps going.

When blocking, write the message to stderr (>&2), not stdout. Stderr is passed to Claude as the reason for the block.

matcher filters by tool: "Write|Edit" means file operations only. Without a matcher, the hook fires on everything.

PostToolUse hooks always exit 0: they log, they don't block. There's no need to stop Claude after the action.

Data from Claude Code arrives as JSON through stdin. The file path is in tool_input.file_path, not just file_path.

Three hooks cover three core needs: security (secrets), economics (budget), auditing (who touched what). Plus an HTTP webhook for external notifications.

The settings.json format: CamelCase event names (PreToolUse), handler type "command" (not "bash"), three levels of nesting.


What's next

→ Subagents: specialization and context

The mark stays in this browser only and is never sent anywhere. My progress