From "I Hope It Does the Right Thing" to "It Always Does"
Skills teach your agent what to do. Hooks are different — they're guarantees about how it behaves, regardless of what it decides. A hook isn't a suggestion. It's a tripwire that fires every time a specific event happens in your session.
Write a hook that blocks edits to .env and your credentials can never
be accidentally overwritten — not by Claude misreading a request, not by a skill
with a bug, not by you being tired and giving a careless instruction. Write a hook
that runs your test suite before Claude declares a task complete and it can never
ship broken code. This is deterministic control.
The Four Handler Types
Hooks can do four different things when they fire. Choose the type based on what you need the hook to actually accomplish:
The Exit Code Protocol
For command type hooks, exit codes control what happens next.
This is the mechanism that gives hooks their power:
Exit 0 → Allow. Claude proceeds. Stdout is parsed for optional JSON decisions.
Exit 2 → Block. Claude does NOT proceed. Whatever you wrote to stderr is fed back
to Claude as context — it will read your reason and respond to it.
Any other → Non-blocking error. Claude proceeds. Stderr is logged but Claude doesn't see it.
The exit 2 feedback loop is what makes hooks more than just blockers. When you exit 2
with a message like "Blocked: .env matches protected file pattern", Claude
reads that reason and adjusts. It's not just stopped — it's informed.
PostToolUse fires. Exit codes on PostToolUse hooks are ignored for blocking —
they're feedback-only. Use PreToolUse if you need to stop something from happening.
Where Hook Configuration Lives
Hooks are configured in a hooks block inside Claude Code's settings files.
Which file you use determines who the hooks apply to:
{
"hooks": {
"EventName": [
{
"matcher": "ToolNameOrPattern",
"hooks": [
{
"type": "command",
"command": "/path/to/script.sh",
"timeout": 30
}
]
}
]
}
}
The matcher field filters which tool calls trigger the hook. Use a tool name
like "Bash", a pipe-separated list like "Edit|Write", or an empty
string "" to match everything. Matchers are case-sensitive —
bash won't match anything. Tool names are Bash, Edit,
Write, Read, Glob, Grep.
The Hook Events That Matter Most
There are 21 hook events total. Most are specialized. For day-to-day use, these six cover 90% of what people actually set up:
matcher for the trigger type:
startup, resume, compact. The compact
trigger is particularly useful — re-inject context after the history gets summarized.
Know When Your Agent Needs You
The most universally recommended hook in the entire community — and the one you should set up first. When you're running an agent on a server and step away to do something else, you have no idea when it stops, gets stuck, or needs a decision from you. A notification hook fixes that. One config block, set once, forget forever.
Desktop Notification Hook
Open your global settings file and add the hooks block. This fires any time Claude sends a notification — idle state, waiting for permission, task complete:
nano ~/.claude/settings.json
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude needs your attention\" with title \"Claude Code\" sound name \"Ping\"'"
}
]
}
]
}
}
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "notify-send 'Claude Code' 'Needs your attention' --urgency=normal --icon=dialog-information"
}
]
}
]
}
}
command with a curl call to your phone via Pushover or Ntfy,
a Discord webhook, a Slack incoming webhook, or a text message via Twilio.
The hook itself is the same — only the delivery method changes.
Notification via Discord Webhook
If you're already using OpenClaw through Discord, routing idle notifications back to a private Discord channel is a natural fit. You'll know Claude is waiting without having to check your terminal:
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "curl -s -X POST \"$DISCORD_WEBHOOK_URL\" -H 'Content-Type: application/json' -d '{\"content\": \"🤖 Claude Code needs your attention\"}'"
}
]
}
]
}
}
Store your webhook URL as an environment variable rather than hardcoding it in the settings file. Add it to your shell profile:
echo 'export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/..."' >> ~/.bashrc
source ~/.bashrc
~/.claude/settings.json global file isn't in a repo, so it's fine there.
If you add notification hooks to a project's .claude/settings.json, use an
environment variable the same way — no credentials in committed files.
Notification via Ntfy (Self-Hosted Push)
Ntfy is a lightweight open-source push notification service. Run it on your Mini PC alongside OpenClaw and you have private push notifications to your phone — no third-party service, no account required:
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "curl -s -d 'Claude Code needs your attention' ntfy.sh/your-topic-name"
}
]
}
]
}
}
Replace your-topic-name with any unique string — that's your private channel.
Subscribe to it in the Ntfy mobile app. Messages appear as push notifications in seconds.
For a self-hosted instance, replace ntfy.sh with your server address.
Task Complete Notification
Want a different alert when the agent finishes a task versus when it's just idle?
Use the Stop event for task completion:
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "notify-send 'Claude Code' 'Waiting for input'"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "notify-send 'Claude Code' 'Task complete ✅' --urgency=low"
}
]
}
]
}
}
PreToolUse — The Guardian Hook
PreToolUse fires before any tool call and can exit 2 to cancel it entirely.
Whatever you write to stderr becomes feedback Claude reads and responds to. This is
the hook pattern that enforces your file protection rules with zero exceptions —
a natural complement to the Securing Your Secrets tutorial, but now enforced at the
agent level, not just the filesystem level.
Protect Sensitive Files
This script blocks any Edit or Write tool call that targets
a file matching your protected patterns. The agent receives the block reason as feedback
and will propose an alternative approach instead of silently failing.
First, create the hooks directory and the script:
mkdir -p .claude/hooks
nano .claude/hooks/protect-files.sh
#!/bin/bash
# Blocks Edit/Write tool calls to sensitive files.
# Exit 2 cancels the call; stderr is fed back to Claude as context.
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Files and patterns to protect — customize this list
PROTECTED_PATTERNS=(
".env"
".env.local"
".env.production"
"package-lock.json"
".git/"
"openclaw.json"
"id_rsa"
"id_ed25519"
".pem"
".key"
)
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "BLOCKED: '$FILE_PATH' matches protected pattern '$pattern'." >&2
echo "To modify this file, the user must edit it directly." >&2
echo "If you need to update environment variables, ask the user to do it." >&2
exit 2
fi
done
exit 0
chmod +x .claude/hooks/protect-files.sh
Wire it into the project settings:
nano .claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh",
"timeout": 10
}
]
}
]
}
}
$CLAUDE_PROJECT_DIR is an environment variable Claude Code
sets to the root of your current project. Using it in the command path means the hook
works correctly regardless of which subdirectory you're running from.
Block Destructive Shell Commands
A second protection hook for Bash calls — catches patterns that can cause irreversible damage before they run. The agent reads the block reason and will either ask you to confirm or find a safer approach:
nano .claude/hooks/guard-bash.sh
#!/bin/bash
# Blocks dangerous shell command patterns before execution.
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# Patterns to block — exit 2 cancels, stderr goes back to Claude
DANGEROUS_PATTERNS=(
"rm -rf"
"rm -r /"
"DROP TABLE"
"DROP DATABASE"
"git push --force"
"> /dev/sda"
"mkfs"
"dd if="
)
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
if echo "$COMMAND" | grep -qiF "$pattern"; then
echo "BLOCKED: Command contains destructive pattern: '$pattern'" >&2
echo "This operation requires explicit user confirmation before proceeding." >&2
echo "Please describe what you're trying to accomplish and ask the user to approve." >&2
exit 2
fi
done
exit 0
chmod +x .claude/hooks/guard-bash.sh
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh",
"timeout": 10
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-bash.sh",
"timeout": 10
}
]
}
]
}
}
Global File Protection (All Projects)
The hooks above live in your project's .claude/ directory and only protect
that project. For rules that should apply everywhere — never touch ~/.ssh/,
never overwrite your global dotfiles — put a protection hook in your global settings:
mkdir -p ~/.claude/hooks
nano ~/.claude/hooks/protect-global.sh
#!/bin/bash
# Global file protection — applies to every project.
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
GLOBAL_PROTECTED=(
".ssh/"
".gnupg/"
".aws/credentials"
".claude/settings.json"
)
for pattern in "${GLOBAL_PROTECTED[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "BLOCKED: '$FILE_PATH' is globally protected and cannot be modified by the agent." >&2
exit 2
fi
done
exit 0
chmod +x ~/.claude/hooks/protect-global.sh
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/protect-global.sh",
"timeout": 10
}
]
}
]
}
}
Global hooks and project hooks stack — both run when you're inside a project. The global protection fires first, then the project-level protection. If either exits 2, the tool call is cancelled.
Hooks That Make Your Workflow Effortless
Protection hooks tell the agent what it can't do. Workflow hooks do the opposite — they make useful things happen automatically so you don't have to think about them. Auto-format every file the agent touches. Run your test suite before it can call a task done. Re-inject your project context after the history compacts. Set these up once and they run silently in the background forever.
Auto-Format After Every Edit
A PostToolUse hook on Edit|Write events. Every time Claude
writes or edits a file, the formatter runs on it automatically. The result: consistently
formatted code with zero effort, even across a multi-file refactor.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path // empty' | xargs -I{} sh -c 'test -f \"{}\" && npx prettier --write \"{}\" 2>/dev/null || true'"
}
]
}
]
}
}
Using Python instead of Node? Swap Prettier for Black:
"command": "jq -r '.tool_input.file_path // empty' | xargs -I{} sh -c 'test -f \"{}\" && black \"{}\" --quiet 2>/dev/null || true'"
Or Go's formatter:
"command": "jq -r '.tool_input.file_path // empty' | xargs -I{} sh -c 'test -f \"{}\" && gofmt -w \"{}\" 2>/dev/null || true'"
jq. The hook reads the file path from the JSON
event data that Claude Code pipes to stdin. Install jq if you don't have it:
sudo apt install jq (Ubuntu) or brew install jq (macOS).
Log Every Bash Command
A lightweight audit log of every shell command the agent runs. Useful for understanding what a long-running task actually did, debugging unexpected changes, or reviewing the session history after the fact.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '\"[\" + (now | todate) + \"] \" + (.tool_input.command // \"(no command)\")' >> ~/.claude/bash-log.txt"
}
]
}
]
}
}
Each line in ~/.claude/bash-log.txt is a timestamped command.
Check it with tail -f ~/.claude/bash-log.txt to watch in real time
or cat ~/.claude/bash-log.txt | grep "rm" to audit deletions.
Tests Before Stop — The Stop Hook
The most impactful workflow hook for anyone writing code. The Stop event
fires when Claude thinks it's done and is about to stop responding. Exit 2 forces it
to keep working — with your stderr reason as its next instruction.
The result: Claude cannot declare a task complete until your test suite is green. It will fix whatever is broken, then try to stop again, and the hook will check again.
nano .claude/hooks/require-tests.sh
#!/bin/bash
# Force Claude to run tests before stopping.
# Exit 2 keeps Claude working; it reads our message and responds.
INPUT=$(cat)
# Prevent infinite loops — if this hook itself is already running, bail out
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
exit 0
fi
# Run the test suite
TEST_OUTPUT=$(npm test 2>&1)
TEST_EXIT=$?
if [ $TEST_EXIT -ne 0 ]; then
echo "Tests are failing. Fix them before stopping." >&2
echo "" >&2
echo "Test output:" >&2
echo "$TEST_OUTPUT" | tail -30 >&2
exit 2
fi
# Tests passed — allow stop
exit 0
chmod +x .claude/hooks/require-tests.sh
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/require-tests.sh",
"timeout": 120
}
]
}
]
}
}
stop_hook_active check is not optional. Without it,
if tests fail and the hook exits 2, Claude tries to fix them and stops again, triggering
the hook again, which checks tests again, creating a loop. Always read
stop_hook_active from the event JSON and exit 0 immediately if it's true.
Not using npm? Swap the test command for yours:
# Python pytest
TEST_OUTPUT=$(python -m pytest 2>&1)
# Go tests
TEST_OUTPUT=$(go test ./... 2>&1)
# Ruby RSpec
TEST_OUTPUT=$(bundle exec rspec 2>&1)
# Bun
TEST_OUTPUT=$(bun test 2>&1)
Re-Inject Context After Compaction
When your conversation history fills the context window, Claude Code automatically compacts it — summarizing old turns to free up space. Useful background rules you set in your memory files survive this, but ad-hoc session reminders ("use Bun not npm", "we're working on the auth refactor") get lost in the summary.
The SessionStart hook with matcher: "compact" fires specifically
after a compaction event. Use it to re-inject whatever context matters most:
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo 'Context restored after compaction. Key reminders: use Bun (not npm), run bun test before committing, current branch is feature/auth-refactor. Continue from where we left off.'"
}
]
}
]
}
}
For a more dynamic version — one that pulls live context rather than a static string — use shell commands in the output:
"command": "echo \"Context compacted. Branch: $(git branch --show-current). Last 5 commits: $(git log --oneline -5 | tr '\n' ' '). Continue from where we left off.\""
Auto-Approve Known Safe Permissions
The PermissionRequest hook fires before the interactive "allow this tool?"
prompt. Return a JSON allow decision to skip the prompt entirely for tools you trust
unconditionally in a given project:
{
"hooks": {
"PermissionRequest": [
{
"matcher": "Read|Glob|Grep",
"hooks": [
{
"type": "command",
"command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PermissionRequest\", \"permissionDecision\": \"allow\", \"permissionDecisionReason\": \"Read-only tools are always safe in this project\"}}'"
}
]
}
]
}
}
Use this for tools you'd click "allow" on every single time anyway. Keep the interactive prompt for anything that modifies files or runs commands — the friction there is intentional.
When Hooks Don't Fire and How to Fix Them
Hooks are powerful but have a handful of sharp edges. Most problems come down to the same three things: wrong tool name casing, unexpected shell output contaminating your JSON, or an infinite loop in a Stop hook. This section covers all of them.
My Hook Isn't Firing
Bash,
Edit, Write, Read, Glob,
Grep — all capitalized. bash and edit
will never match anything. This is the most common hook setup mistake.
chmod +x .claude/hooks/your-script.sh — easy to forget when
creating a new script. If the file isn't executable, the hook silently fails.
~/.claude/settings.json. Project hooks go in
.claude/settings.json inside the project root.
A hook in a project's settings file won't fire in a different project.
PermissionRequest hooks do not fire in headless mode
(claude -p "..." non-interactive flag). If you need to intercept
or auto-approve tool calls in headless mode, use PreToolUse instead.
/hooks
The /hooks command opens a read-only browser of every hook configured
in your current session — global and project. If a hook isn't listed here, it's not
loaded. Check the file path and JSON syntax.
Stop Hook Infinite Loop
The classic Stop hook mistake: your hook exits 2, Claude keeps working, finishes, tries to stop again, your hook exits 2 again, and you're stuck.
The fix: always check stop_hook_active in the event JSON at the top of
every Stop hook script. If it's true, a Stop hook is already running
in this cycle — exit 0 immediately and let Claude stop.
#!/bin/bash
INPUT=$(cat)
# REQUIRED: prevent infinite loop
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
exit 0
fi
# Your actual hook logic below this line...
If you're already in an infinite loop and Claude is stuck, press Ctrl+C to interrupt the session, then fix the script before reconnecting.
Shell Output Contaminating JSON
Hook scripts run in non-interactive shells, but your ~/.zshrc or
~/.bashrc may still produce output — welcome messages, colorized prompts,
nvm load messages, conda initialization text. This output
gets mixed with your hook's intended stdout, breaking the JSON parsing Claude Code does.
if [[ $- == *i* ]]; thenecho "Welcome message"fi
/dev/null for
commands that produce verbose output you don't want Claude to see:
npm test 2>/dev/null. Be intentional about what goes to
stderr — Claude reads it.
bash -l .claude/hooks/your-script.sh < /dev/null.
The -l flag loads your shell profile. If it produces unexpected
output here, it will in the hook too.
Debugging with Verbose Mode
When a hook is configured but behaving unexpectedly, enable verbose mode to see exactly what's firing, what data it received, and what it returned:
Ctrl+O
Or start a session with debug logging enabled:
claude --debug
Debug output shows the full lifecycle of each hook: which event fired, which hooks matched, the stdin JSON payload, stdout/stderr, and the exit code. If something is wrong, it's almost always visible here.
Putting It All Together
A complete .claude/settings.json for a typical project — combining all
the hooks from this tutorial into one file:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh",
"timeout": 10
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-bash.sh",
"timeout": 10
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path // empty' | xargs -I{} sh -c 'test -f \"{}\" && npx prettier --write \"{}\" 2>/dev/null || true'"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/require-tests.sh",
"timeout": 120
}
]
}
],
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo \"Context compacted. Branch: $(git branch --show-current). Continue from where we left off.\""
}
]
}
]
}
}
And the global ~/.claude/settings.json for personal hooks that apply everywhere:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/protect-global.sh",
"timeout": 10
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '\"[\" + (now | todate) + \"] \" + (.tool_input.command // \"(no command)\")' >> ~/.claude/bash-log.txt"
}
]
}
],
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "notify-send 'Claude Code' 'Needs your attention'"
}
]
}
]
}
}