You've got skills. You've got CLAUDE.md. You've got agents running on cron jobs at 6 AM while you sleep. But every time one of those agents edits a production file, pushes to a repo, or calls an external API, you're trusting it on a handshake. There's no tripwire. No automatic log. No safety net running in the background.
That gap between "agents that run" and "agents that run safely" is exactly what Claude Code hooks fill.
I run 30+ agents across four ventures. Hooks are the invisible layer that makes the whole thing trustworthy. They're the part nobody talks about because they're not flashy — they don't generate content or write code. They just quietly ensure that every agent action gets checked, logged, or extended without me having to watch.
Here's how I set them up, the exact configs I use, and the mistakes that cost me before I got the patterns right.
What Are Claude Code Hooks?
Claude Code hooks are shell commands that run automatically before or after every tool call your agent makes. Think of them as event-driven triggers bolted onto your AI workflow.
When Claude Code is about to edit a file, run a bash command, or use any tool, a hook can intercept that action. It can approve it, block it, log it, or trigger something else entirely — all without you typing a word.
There are four hook types:
- PreToolUse — Runs before a tool executes. Can approve or block the action.
- PostToolUse — Runs after a tool completes. Good for logging, formatting, and notifications.
- Notification — Fires when Claude Code sends a notification (task complete, permission needed).
- Stop — Fires when the agent session ends. Good for cleanup and summary reports.
You configure them in your settings files:
~/.claude/settings.json— User-level hooks that apply to every project on your machine..claude/settings.json— Project-level hooks shared with the team via git..claude/settings.local.json— Local hooks, gitignored, for personal preferences.
Each hook entry takes a matcher (a regex matching tool names like Bash, Edit, or Write) and a command (the shell command to run). The hook receives a JSON payload on stdin with context about what the agent is doing — tool name, tool input, and for PostToolUse, the tool output.
Why Hooks Matter More Than You Think
If you're running one agent interactively, hooks are nice to have. If you're running ten agents on schedules, hooks are infrastructure.
Here's the problem I hit at agent number fifteen: I couldn't tell what my agents were actually doing. The daily briefing agent ran at 6 AM and pushed a summary to Slack. Great. But did it also edit three files I didn't expect? Did the listing audit agent call an API that costs money per request? Did the keyword expander push to a branch I wasn't watching?
Without hooks, the only way to answer those questions was to read transcripts. That doesn't scale past five agents. It barely scales past two.
Hooks gave me three capabilities that changed how I operate:
- Automatic guardrails — agents can't do things I haven't explicitly allowed, even when I'm asleep.
- Complete audit trails — every tool call gets logged with timestamp, input, and output.
- Event-driven extensions — when an agent finishes, other systems (Slack, dashboards, downstream agents) get notified automatically.
The ROI is hard to quantify because it's insurance. The one time a hook blocked an agent from overwriting a production config file at 3 AM, it paid for every minute I'd spent on setup. The logging hooks have saved me dozens of hours of detective work when something looked off in a client deliverable. And the notification hooks mean I never have to wonder whether a scheduled agent actually ran.
PreToolUse Hooks: The Guardrail Layer
PreToolUse hooks are the most important type. They run before the agent acts, and they can block actions that cross a line.
Here's the config I use to prevent agents from editing files outside their designated scope:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"command": ".claude/hooks/check-file-scope.sh"
}
]
}
}
And the script itself:
#!/bin/bash
# .claude/hooks/check-file-scope.sh
# Block edits to production configs and sensitive files
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ "$FILE_PATH" =~ \.(env|pem|key)$ ]] || \
[[ "$FILE_PATH" =~ ^\.claude/settings\.json$ ]] || \
[[ "$FILE_PATH" =~ config/production ]]; then
echo "Blocked: agents cannot modify $FILE_PATH" >&2
exit 2
fi
exit 0
Exit code 0 means "approved, carry on." Exit code 2 means "blocked" — the agent sees whatever you print to stderr as the reason, which lets it adjust its approach rather than just failing silently.
A few patterns I've found essential for PreToolUse:
Block destructive bash commands. This is the first hook I add to any project where agents run unattended:
#!/bin/bash
# .claude/hooks/check-bash-safety.sh
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if [[ "$COMMAND" =~ rm\ -rf ]] || \
[[ "$COMMAND" =~ git\ push\ --force ]] || \
[[ "$COMMAND" =~ git\ reset\ --hard ]] || \
[[ "$COMMAND" =~ DROP\ TABLE ]]; then
echo "Blocked: destructive command not permitted in automated runs" >&2
exit 2
fi
exit 0
Restrict API calls during off-hours. This one saved me from a rogue loop that would have burned through an API quota overnight:
#!/bin/bash
# .claude/hooks/restrict-off-hours-api.sh
HOUR=$(date +%H)
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if [[ "$COMMAND" =~ curl|wget|http ]] && (( HOUR >= 22 || HOUR <= 5 )); then
echo "Blocked: external API calls disabled between 10pm and 5am" >&2
exit 2
fi
exit 0
The agent hit that guard at 4 AM, saw the reason, and adapted by queuing the API calls for its next scheduled run. That's the beauty of PreToolUse with clear error messages — the agent can work around the constraint instead of just crashing.
PostToolUse Hooks: The Logging and Extension Layer
PostToolUse hooks run after a tool completes. They receive the full context: tool name, input, and output. This is where you build your audit trail and extend agent behavior.
Here's my standard logging hook:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash|Edit|Write",
"command": ".claude/hooks/log-tool-use.sh"
}
]
}
}
#!/bin/bash
# .claude/hooks/log-tool-use.sh
INPUT=$(cat)
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
LOG_DIR=".claude/logs"
mkdir -p "$LOG_DIR"
echo "$INPUT" | jq -c --arg ts "$TIMESTAMP" \
'{timestamp: $ts, tool: .tool_name, input: .tool_input}' \
>> "$LOG_DIR/$(date +%Y-%m-%d).jsonl"
Every tool call, every day, one line in a JSONL file. I can grep these later, pipe them into a quick analysis script, or scan them when something looks off in a deliverable. Last month I traced a formatting regression to a specific Edit call at 6:47 AM — took two minutes instead of the hour it would have taken reading transcripts.
Auto-format after file edits is another PostToolUse pattern I run everywhere:
#!/bin/bash
# .claude/hooks/auto-format.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
case "$FILE_PATH" in
*.js|*.ts|*.jsx|*.tsx)
npx prettier --write "$FILE_PATH" 2>/dev/null
;;
*.py)
ruff format "$FILE_PATH" 2>/dev/null
;;
*.md)
npx prettier --write --prose-wrap always "$FILE_PATH" 2>/dev/null
;;
esac
The agent writes the code. The hook formats it. No more style inconsistencies, no more formatter commits cluttering the history. It just happens.
Stop and Notification Hooks: The Communication Layer
Stop hooks fire when the agent session ends. Notification hooks fire when Claude Code needs to tell you something. Together, they form the communication bridge between your agents and the rest of your stack.
For scheduled agents, the Stop hook sends a session summary to Slack:
#!/bin/bash
# .claude/hooks/on-stop.sh
LOG_FILE=".claude/logs/$(date +%Y-%m-%d).jsonl"
if [[ -f "$LOG_FILE" ]]; then
EDIT_COUNT=$(grep -c '"Edit"\|"Write"' "$LOG_FILE" 2>/dev/null || echo 0)
BASH_COUNT=$(grep -c '"Bash"' "$LOG_FILE" 2>/dev/null || echo 0)
SUMMARY="Agent session complete: $EDIT_COUNT file changes, $BASH_COUNT commands"
else
SUMMARY="Agent session complete: no tool calls logged"
fi
if [[ -n "$SLACK_WEBHOOK_URL" ]]; then
curl -s -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{\"text\": \"$SUMMARY\"}" > /dev/null
fi
The Notification hook catches permission requests and task completions. I route these to a monitoring Slack channel so I can see at a glance which agents are stuck waiting for approval — which usually means the permission config needs fixing, not that the agent needs my attention.
#!/bin/bash
# .claude/hooks/notify.sh
INPUT=$(cat)
MESSAGE=$(echo "$INPUT" | jq -r '.message // "Agent notification (no message)"')
if [[ -n "$SLACK_OPS_WEBHOOK" ]]; then
curl -s -X POST "$SLACK_OPS_WEBHOOK" \
-H 'Content-Type: application/json' \
-d "{\"text\": \"$MESSAGE\"}" > /dev/null
fi
The result: I check one Slack channel in the morning and know exactly which agents ran, what they did, and whether any need attention. Zero transcripts opened. Zero terminals checked.
How to Build Your First Claude Code Hook in Ten Minutes
If you've never used hooks, start here. Don't try to build the full guardrail suite on day one. Start with logging and add guards as you learn what your agents actually do.
- Create the directories in your project:
mkdir -p .claude/hooks .claude/logs
echo ".claude/logs/" >> .gitignore
- Write a simple logging hook. This captures every tool call as one JSON line:
#!/bin/bash
# .claude/hooks/log-tool-use.sh
mkdir -p .claude/logs
cat >> ".claude/logs/$(date +%Y-%m-%d).jsonl"
- Add it to your project settings in
.claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": ".*",
"command": ".claude/hooks/log-tool-use.sh"
}
]
}
}
- Make it executable:
chmod +x .claude/hooks/log-tool-use.sh
- Run an agent session and check the log file afterward:
cat .claude/logs/$(date +%Y-%m-%d).jsonl | jq '.tool_name' | sort | uniq -c | sort -rn
That command shows you which tools the agent used most. Five minutes to set up, and you now have a complete audit trail for every agent session in this project. From here, add one PreToolUse guard for your most sensitive file or command pattern, and you're already ahead of 95% of operators running Claude Code.
Common Mistakes With Claude Code Hooks
I've made all of these. Save yourself the debugging sessions.
Writing slow hooks. Every PreToolUse hook adds latency to every tool call. If your hook takes 2 seconds and the agent makes 50 tool calls, you've added almost two minutes to the session. Keep PreToolUse hooks fast — under 100ms. Do heavy work in PostToolUse or Stop hooks instead, where latency doesn't block the agent.
Forgetting to read stdin. Hooks receive JSON on stdin. If your script doesn't read stdin, the pipe can block. Always start with INPUT=$(cat) even if you don't use the payload, or redirect stdin with < /dev/null if you truly don't need the context.
Blocking too aggressively. I once set up a PreToolUse hook that blocked any bash command containing rm. That also blocked npm run build and npx prettier --write. Be specific with your regex patterns — match the exact dangerous form (rm -rf), not substrings that appear in legitimate commands.
Not making hooks executable. Everyone hits this one. chmod +x your hook scripts after creating them, or they silently fail and you spend 20 minutes wondering why your guardrails aren't firing.
Logging full tool output. PostToolUse receives the complete tool output, including the full contents of any file that was read. For a 2000-line file, that's a lot of JSON in your log. Log the tool name and input, but truncate or skip the output — especially for Read operations.
Ignoring exit codes. PreToolUse hooks must exit 0 to approve. If your script crashes or exits with code 1, the behavior is undefined. Be explicit: end every code path with exit 0 (approve) or exit 2 (block). No ambiguity.
Frequently Asked Questions
Do Claude Code hooks work with scheduled and headless agent sessions?
Yes. Hooks are defined in settings files that get loaded whenever Claude Code starts, whether you're at the keyboard or it's a cron-triggered run at 4 AM. This is exactly why hooks matter most for operators: they're the guardrails and logging that work when you're not watching.
Can a hook modify the tool input before it executes?
PreToolUse hooks are designed to approve or block, not transform. If you need to modify how a tool behaves, build that logic into a skill or your CLAUDE.md instructions instead. Hooks are for gating and reacting — the control plane, not the data plane.
How do I debug a hook that isn't firing?
Add a debug line at the top of your script: echo "Hook fired: $(date)" >> /tmp/hook-debug.log. If that log stays empty after a session, the hook isn't being triggered. Check three things: the matcher regex matches the tool name exactly (case-sensitive), the script file is executable (chmod +x), and the settings file is in the right location (.claude/settings.json in the project root).
Should I put hooks in settings.json or settings.local.json?
Guardrail hooks (file scope protection, bash safety checks) go in .claude/settings.json so they're version-controlled and shared with the team. Personal preferences (auto-formatting, notification routing, custom logging) go in .claude/settings.local.json so they don't create merge conflicts or interfere with other people's workflows.
What's the performance impact of running multiple hooks?
For PostToolUse, Stop, and Notification hooks: negligible, since they run after the action is already complete. For PreToolUse hooks, each one adds its execution time before every matching tool call. I keep PreToolUse hooks under 100ms each and rarely have more than two or three per project. The total overhead is barely noticeable — far less than the cost of an unguarded agent doing something wrong.
The Three Actions to Take Today
Claude Code hooks are the invisible infrastructure that separates "agents that run" from "agents you actually trust." Here's where to start:
-
Add a logging hook today. Drop the PostToolUse logger into your most active project. Run it for a week. You'll be surprised what your agents actually do — and you'll have the data to make them better.
-
Add one guardrail. Pick the one file or command pattern that would hurt most if an agent got it wrong. Write a PreToolUse hook that blocks it. That single guard is worth more than a hundred lines of CLAUDE.md instructions telling the agent to be careful.
-
Add a Stop notification. Route it to Slack, email, or wherever you check first in the morning. When your scheduled agents finish, you should know what happened — without opening a terminal or reading a transcript.
Claude Code hooks aren't glamorous. They don't generate revenue or create content. But they're the difference between an agent stack you babysit and one you trust to run while you sleep. And trust is what lets you go from three agents to thirty without your operational overhead scaling alongside them.