Originally published at chudi.dev
I ran a secret scanner on every project for months before I realized Claude Code was writing .env files with real credentials baked in. Not because it was malicious. Just because the context had a key, and it needed a value.
The fix took five minutes once I knew hooks existed.
Claude Code hooks let you run any shell command automatically when tool events fire. Before a file gets written, after a bash command runs, when Claude finishes a task. You get full context about what's happening via stdin, and for PreToolUse hooks, you can block the operation entirely.
This is the guide I wish I had when I started.
What Are Claude Code Hooks and Why Do You Need Them?
Claude Code is an autonomous agent. It reads files, writes code, runs commands, and makes decisions faster than you can review each one. That autonomy is the point. But it creates a gap: how do you enforce standards without reviewing every action manually?
Hooks close that gap. They're your enforcement layer — running in the background, checking every operation against your rules, and either approving it or blocking it before any damage is done.
Think of them as middleware for your AI agent. The tool fires an event, your hook intercepts it, does its check, and returns a decision. If the hook returns {"continue": false}, Claude stops. If it returns {"continue": true} (or nothing), Claude proceeds.
What Are the Four Claude Code Hook Events?
Claude Code exposes four events you can hook into (see official hooks docs):
PreToolUse — fires before any tool runs. You can inspect the tool input and block execution. This is where guardrails live.
PostToolUse — fires after a tool completes. You get the tool output. Use this for logging, formatting, or triggering follow-on actions.
Notification — fires when Claude sends a notification (waiting for input, task complete, etc.). Good for custom alerts.
Stop — fires when the agent finishes a task. Use this for cleanup, summaries, or Slack notifications.
There's also SubagentStop which fires when a subagent finishes, if you're running parallel agents.
Where Do You Configure Claude Code Hooks?
Everything goes in ~/.claude/settings.json. The structure looks like this:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "/Users/you/scripts/scan-secrets.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "/Users/you/scripts/auto-format.sh"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "/Users/you/scripts/notify-complete.sh"
}
]
}
]
}
}
The matcher field is a regex matched against the tool name. Write|Edit matches both the Write tool and the Edit tool. Leave it out to match all tools for that event.
What your hook receives
Every hook gets a JSON object via stdin. For a PreToolUse hook on the Write tool, it looks like this:
{
"session_id": "abc123",
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/Users/you/project/.env",
"content": "STRIPE_SECRET_KEY=sk_live_abc123..."
}
}
For a Bash tool, tool_input contains command instead of file_path. For Edit, you get file_path, old_string, and new_string. The shape matches the tool's schema.
PostToolUse hooks also get tool_response — the actual output the tool returned.
What your hook must return
This is the part that trips people up.
If your hook writes anything to stdout, it must be valid JSON. The Claude Code protocol reads stdout as structured data. If you print plain text, you'll get protocol errors.
#!/bin/bash
# WRONG - will break the protocol
echo "Scanning for secrets..."
echo '{"continue": true}'
# RIGHT - suppress all non-JSON output
exec 2>/dev/null
echo '{"continue": true}'
The valid return fields are:
{
"continue": true,
"suppressOutput": false,
"decision": "approve",
"reason": "No secrets found"
}
continue: false blocks the tool. suppressOutput: true hides the hook output from Claude's context. reason gets shown in the UI when you block.
If your script exits with code 0 and returns nothing, Claude proceeds. If it exits non-zero, Claude treats it as a blocking error.
Hook 1: Secret scanner
This is the one I wish I'd had from day one. It runs before any Write or Edit and blocks the operation if it finds credentials.
#!/bin/bash
# ~/.claude/scripts/scan-secrets.sh
exec 2>/dev/null
INPUT=$(cat)
CONTENT=$(echo "$INPUT" | python3 -c "
import json, sys
d = json.load(sys.stdin)
ti = d.get('tool_input', {})
print(ti.get('content', '') + ti.get('new_string', ''))
" 2>/dev/null || echo "")
# Check for common secret patterns
PATTERNS=(
'sk_live_[A-Za-z0-9]+'
'xoxb-[A-Za-z0-9-]+'
'AKIA[A-Z0-9]{16}'
'ghp_[A-Za-z0-9]{36}'
'rpa_[A-Za-z0-9]+'
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'
)
for pattern in "$\{PATTERNS[@]}"; do
if echo "$CONTENT" | grep -qE "$pattern"; then
echo "{\"continue\": false, \"reason\": \"Blocked: potential secret detected matching pattern $pattern\"}"
exit 0
fi
done
echo '{"continue": true}'
Register it in settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|NotebookEdit",
"hooks": [{ "type": "command", "command": "/Users/you/.claude/scripts/scan-secrets.sh" }]
}
]
}
}
Now every file write goes through the scanner. If it finds a Stripe live key, Slack token, or AWS key, it blocks with a reason Claude can read and explain.
Hook 2: Auto-formatter
After Claude edits a TypeScript or Python file, run the formatter automatically. No more "Claude wrote valid code but wrong indentation."
#!/bin/bash
# ~/.claude/scripts/auto-format.sh
exec 2>/dev/null
INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(d.get('tool_input', {}).get('file_path', ''))
" 2>/dev/null || echo "")
if [[ -z "$FILE" ]]; then
echo '{"continue": true}'
exit 0
fi
case "$FILE" in
*.ts|*.tsx)
command -v prettier &>/dev/null && prettier --write "$FILE" &>/dev/null
;;
*.py)
command -v ruff &>/dev/null && ruff format "$FILE" &>/dev/null
;;
esac
echo '{"continue": true}'
PostToolUse on Edit:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "/Users/you/.claude/scripts/auto-format.sh" }]
}
]
}
}
The formatter runs silently after every edit. Claude's next read of the file sees clean, formatted code without any back-and-forth.
Hook 3: Slack notification on task complete
I work with Claude running in the background while I do other things. The Stop hook lets me know when it's done without watching the terminal.
#!/bin/bash
# ~/.claude/scripts/notify-complete.sh
exec 2>/dev/null
TOKEN="$\{SLACK_BOT_TOKEN:-}"
CHANNEL="$\{SLACK_NOTIFY_CHANNEL:-}"
if [[ -z "$TOKEN" || -z "$CHANNEL" ]]; then
echo '{"continue": true}'
exit 0
fi
INPUT=$(cat)
SESSION=$(echo "$INPUT" | python3 -c "
import json, sys
print(json.load(sys.stdin).get('session_id', 'unknown'))
" 2>/dev/null || echo "unknown")
curl -sf -X POST https://slack.com/api/chat.postMessage \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"channel\":\"$CHANNEL\",\"text\":\":white_check_mark: Claude finished task (session: $SESSION)\"}" \
> /dev/null
echo '{"continue": true}'
{
"hooks": {
"Stop": [
{
"hooks": [{ "type": "command", "command": "/Users/you/.claude/scripts/notify-complete.sh" }]
}
]
}
}
Now when a long refactor finishes, my phone buzzes.
Hook 4: Approval gate for destructive bash commands
This one requires more care. Some bash commands are irreversible — dropping databases, deleting branches, modifying production configs. The PreToolUse hook on Bash lets you intercept these.
#!/bin/bash
# ~/.claude/scripts/approve-destructive.sh
exec 2>/dev/null
INPUT=$(cat)
CMD=$(echo "$INPUT" | python3 -c "
import json, sys
print(json.load(sys.stdin).get('tool_input', {}).get('command', ''))
" 2>/dev/null || echo "")
DESTRUCTIVE_PATTERNS=(
'rm -rf'
'drop table'
'DROP TABLE'
'git push --force'
'git reset --hard'
'kubectl delete'
'systemctl stop'
)
for pattern in "$\{DESTRUCTIVE_PATTERNS[@]}"; do
if echo "$CMD" | grep -qF "$pattern"; then
echo "{\"continue\": false, \"reason\": \"Blocked: '$pattern' requires explicit approval. Run the command manually if intended.\"}"
exit 0
fi
done
echo '{"continue": true}'
This doesn't ask for approval interactively — that would deadlock. Instead it blocks and explains. You review the command, run it yourself if it's correct, and Claude continues from there.
Gotchas that cost me time
Shell profile output breaks hooks. If your .zshrc or .bashrc prints anything (greeting messages, nvm output, conda activation), it will pollute the hook stdout. Either suppress it or use exec 2>/dev/null at the top of every hook script.
Hooks run in a non-interactive shell. Your PATH, aliases, and shell functions aren't loaded. Use full absolute paths to commands (/opt/homebrew/bin/prettier, not prettier).
PreToolUse latency adds up. If your hook takes 500ms and Claude runs 50 Edit operations, that's 25 extra seconds. Profile your hooks. Secret scanning should be under 50ms. If it's slow, check for regex backtracking.
The matcher is a regex, not a glob. Write|Edit works. Write* does not.
Empty stdout is fine, but non-JSON stdout breaks things. Add exec 2>/dev/null to redirect stderr, then only ever echo valid JSON.
My actual settings.json hooks section
This is what I run across all projects:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|NotebookEdit",
"hooks": [{ "type": "command", "command": "/Users/chudinnorukam/.claude/scripts/scan-secrets.sh" }]
},
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "/Users/chudinnorukam/.claude/scripts/approve-destructive.sh" }]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "/Users/chudinnorukam/.claude/scripts/auto-format.sh" }]
}
],
"Stop": [
{
"hooks": [{ "type": "command", "command": "/Users/chudinnorukam/.claude/scripts/notify-complete.sh" }]
}
]
}
}
Four hooks. They cover the 90% case: secrets, destructive commands, formatting, and completion notifications. Everything else I handle manually because the hook overhead isn't worth it for low-frequency events.
Where to go from here
Hooks are composable. You can chain multiple hooks on the same event. You can use them to log every tool call to a file for auditing. You can build approval workflows that post to Slack and wait for a reply before proceeding.
The pattern I'm building toward: a full audit log of every Claude action, with replay capability. Every Write, Edit, and Bash call gets logged with the session ID, timestamp, and tool input. When something goes wrong, I can reconstruct exactly what happened and in what order.
That's the next post. For now, start with the secret scanner. It's the one hook that pays for itself the first time it catches something.
If you're using Claude Code for real projects, you already know the trust issue. You can't review every edit. Hooks are how you stop trusting blindly and start trusting with guardrails.
Top comments (0)