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
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.
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 ClaudeImportant: 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.
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
mkdir -p ~/.claude/hooks
touch ~/.claude/hooks/pre-tool-use-no-secrets.sh
chmod +x ~/.claude/hooks/pre-tool-use-no-secrets.shThe script
#!/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, allowAdding it to settings.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 (notpre_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:
Create a config.py file with the content: API_KEY = 'sk-proj-test123456789012345678901234'
Expected result:
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.
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.
#!/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 0Adding it to settings.json (next to the first hook)
{
"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.
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.
#!/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 0The full settings.json with three hooks + a notification
{
"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 matcherfilters tools:"Write|Edit","Bash", or empty for all
How to read the audit log
[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.mdYou see: time, project, tool, file. If something broke, you know exactly what Claude touched and when.
# 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.txtFrom 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:
#!/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 0With 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:
{
"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
{
"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:
{
"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.
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:
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:
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:
What is Claude Code? (a short question)
Expected: after the answer, a macOS notification pops up.
Practice
Assignment: get all three hooks running
- Create the
~/.claude/hooks/folder - Create the three bash scripts with the content from this lesson
- Make them executable:
chmod +x ~/.claude/hooks/*.sh - Create or update
.claude/settings.jsonand add all three hooks following the template from the lesson - Test each hook (checklist above)
- 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 jqon a Mac)chmod +x: makes a script executableosascript: 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.
matcherfilters 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 justfile_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
The mark stays in this browser only and is never sent anywhere. My progress