I've been running Claude Code as my primary pair programming setup since late 2025. Somewhere around February 2026, I noticed a hooks key in the settings schema. No blog posts about it. No YouTube tutorials. The official docs mentioned it in maybe two paragraphs. I started experimenting, and within a day my entire workflow changed.
Hooks let you run arbitrary shell commands that fire automatically before or after Claude Code does something. Write a file? Your formatter runs. About to execute a bash command? Your safety script checks it first. Agent finishes a task? Slack gets pinged. It's the difference between babysitting an AI agent and letting it actually work for you.
I've been tuning my hook setup for about six weeks now. Here's everything I've learned.
What Hooks Actually Are
If you've used Claude Code for any real project, you know it operates through tool calls. It calls Write to create files, Edit to modify them, Bash to run terminal commands. Hooks are shell commands that intercept these tool calls at two points - right before they execute, and right after.
Four hook types exist:
- PreToolUse - fires before a tool call runs. You can approve it, deny it, or inject a message back to the agent.
- PostToolUse - fires after a tool call completes. Great for formatting, testing, validation.
- Notification - fires when Claude Code sends a notification (like asking for permission).
- Stop - fires when the agent decides it's done. Perfect for alerts and cleanup.
Each hook gets a JSON blob on stdin with context about what's happening. The tool name, the input parameters, and for PostToolUse hooks, the output. Your script reads that JSON, does whatever it needs to do, and optionally prints a JSON response to stdout that tells Claude Code what to do next.
The response can be one of three things: {"decision": "approve"} to let it proceed, {"decision": "deny", "reason": "..."} to block it, or {"decision": "approve", "message": "..."} to add context that the agent will see. That last one is sneaky powerful - I'll get to why.
Where Hooks Live
Hooks go in your settings.json. You've got two options:
- Global:
~/.claude/settings.json- applies to every project - Project-level:
.claude/settings.jsonin your repo root - scoped to that project
I keep dangerous-command blocking global. Everything else goes project-level because my Node projects need Prettier and my Python projects need Black, and mixing those up is not fun. If you haven't set up your base config yet, I wrote about my exact Claude Code configuration a while back.
Here's the basic structure:
{
"hooks": [
{
"type": "PreToolUse",
"matcher": "Bash",
"command": "/path/to/your/script.sh"
},
{
"type": "PostToolUse",
"matcher": "Write|Edit",
"command": "/path/to/another/script.sh"
}
]
}
The matcher field is a regex pattern against the tool name. "Write|Edit" catches both. "Bash" catches bash commands. You can use ".*" to match everything, but I wouldn't - you'll slow down every single operation.
Auto-Format on Every Write
This was my first hook and it's still my favorite. Claude Code writes decent code, but it doesn't always match your project's formatting rules. AI-generated code quality varies, and formatting is one of the easiest things to fix automatically.
The hook:
{
"type": "PostToolUse",
"matcher": "Write|Edit",
"command": "~/.claude/hooks/format-on-save.sh"
}
The script:
#!/bin/bash
# format-on-save.sh
# Reads the tool output from stdin, extracts the file path, runs prettier
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [ -z "$FILE" ]; then
exit 0
fi
# Only format files prettier knows about
case "$FILE" in
*.js|*.ts|*.jsx|*.tsx|*.json|*.css|*.md|*.html)
npx prettier --write "$FILE" 2>/dev/null
echo '{"decision": "approve", "message": "Formatted with prettier."}'
;;
*.py)
black "$FILE" 2>/dev/null
echo '{"decision": "approve", "message": "Formatted with black."}'
;;
*)
exit 0
;;
esac
Simple. Every file Claude writes gets formatted instantly. No more "hey can you run prettier on that" back and forth. The agent sees the "Formatted with prettier" message and knows the file was cleaned up, so it won't fight your formatting on the next edit.
One thing I learned the hard way: make sure your script exits cleanly even if prettier isn't installed in that project. The 2>/dev/null handles it, but I spent a frustrating afternoon watching hooks fail silently before I figured that out.
Safety Hooks That Actually Matter
This is where hooks go from "nice to have" to "I can't work without this." I wrote about running Claude Code autonomously last month, and the biggest risk is the agent running something destructive. Hooks fix that.
My global safety hook blocks commands that should never run without me explicitly typing them:
{
"type": "PreToolUse",
"matcher": "Bash",
"command": "~/.claude/hooks/safety-check.sh"
}
#!/bin/bash
# safety-check.sh
# Block dangerous bash commands before they execute
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# Patterns that should never run from an AI agent
BLOCKED_PATTERNS=(
"rm -rf /"
"rm -rf ~"
"rm -rf \."
"git push.*--force"
"git push.*-f"
"git reset --hard"
"chmod -R 777"
"curl.*| bash"
"wget.*| bash"
"dd if="
"> /dev/sd"
"mkfs\."
":(){ :|:& };:"
)
for pattern in "${BLOCKED_PATTERNS[@]}"; do
if echo "$CMD" | grep -qE "$pattern"; then
echo "{\"decision\": \"deny\", \"reason\": \"Blocked by safety hook: pattern '$pattern' matched. Run this manually if you really need it.\"}"
exit 0
fi
done
# Let everything else through
exit 0
Last Tuesday, I let Claude Code refactor a deploy script and it tried to git push --force origin main. The hook caught it. That alone justified every minute I spent setting this up.
The deny response sends a message back to the agent explaining why the command was blocked. Claude Code actually reads this and adjusts - it'll usually suggest a safer alternative or ask you to run the command manually. That's the kind of agentic behavior that makes these tools worth using.
Lint Before Every Commit
Another PreToolUse hook, but this one only fires when the bash command contains "git commit":
{
"type": "PreToolUse",
"matcher": "Bash",
"command": "~/.claude/hooks/pre-commit-lint.sh"
}
#!/bin/bash
# pre-commit-lint.sh
# Run linting before any git commit
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# Only intercept git commit commands
if ! echo "$CMD" | grep -q "git commit"; then
exit 0
fi
# Run eslint on staged files
STAGED=$(git diff --cached --name-only --diff-filter=d | grep -E '\.(js|ts|tsx|jsx)$')
if [ -z "$STAGED" ]; then
exit 0
fi
LINT_OUTPUT=$(echo "$STAGED" | xargs npx eslint --no-error-on-unmatched-pattern 2>&1)
LINT_EXIT=$?
if [ $LINT_EXIT -ne 0 ]; then
# Truncate output to avoid overwhelming the context
SHORT_OUTPUT=$(echo "$LINT_OUTPUT" | head -30)
RESPONSE=$(jq -n --arg reason "Lint errors found. Fix these before committing:\n$SHORT_OUTPUT" \
'{"decision": "deny", "reason": $reason}')
echo "$RESPONSE"
exit 0
fi
echo '{"decision": "approve", "message": "Lint passed. Commit is clean."}'
This pairs well with a proper CI/CD pipeline, but catches problems before they even hit your remote. The agent sees the lint errors and fixes them in the same session. No failed CI runs. No "fix lint" commits polluting your history.
Auto-Run Tests After Changes
I go back and forth on this one. Running your full test suite after every file write is overkill for big projects. But for smaller codebases - or if you can scope it to the right files - it's incredible.
{
"type": "PostToolUse",
"matcher": "Write|Edit",
"command": "~/.claude/hooks/run-related-tests.sh"
}
#!/bin/bash
# run-related-tests.sh
# Find and run tests related to the changed file
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [ -z "$FILE" ]; then
exit 0
fi
# Skip if the file itself is a test
if echo "$FILE" | grep -qE '\.(test|spec)\.(js|ts|tsx)$'; then
exit 0
fi
# Try to find a matching test file
BASENAME=$(basename "$FILE" | sed 's/\.[^.]*$//')
TEST_FILE=$(find . -name "${BASENAME}.test.*" -o -name "${BASENAME}.spec.*" 2>/dev/null | head -1)
if [ -n "$TEST_FILE" ]; then
TEST_OUTPUT=$(npx jest "$TEST_FILE" --no-coverage 2>&1 | tail -10)
echo "{\"decision\": \"approve\", \"message\": \"Related test results:\\n$TEST_OUTPUT\"}"
else
exit 0
fi
The "message" response is the key here. The agent sees the test output in its context, so if a test fails, it knows immediately and can fix the code without you saying a word. I've watched Claude Code write a function, see the test fail via the hook, and correct the bug in the next turn. Fully automatic. That's the AI workflow I always wanted.
Advanced Hooks I'm Running
Notification to Slack
When I'm running Claude Code on a long task - say, refactoring 40 files across a module - I don't want to sit there watching. The Stop hook pings me when it's done:
{
"type": "Stop",
"matcher": ".*",
"command": "~/.claude/hooks/notify-slack.sh"
}
#!/bin/bash
# notify-slack.sh
WEBHOOK_URL="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
INPUT=$(cat)
STOP_REASON=$(echo "$INPUT" | jq -r '.stop_reason // "unknown"')
curl -s -X POST "$WEBHOOK_URL" \
-H 'Content-type: application/json' \
-d "{\"text\": \"Claude Code finished. Reason: $STOP_REASON\"}" \
> /dev/null 2>&1
Dirt simple. I set the task, switch to another terminal (or go make coffee), and Slack tells me when it's done. If you're interested in automating repetitive tasks in general, this pattern extends to basically anything.
Morning Data Pull
This one's a bit unusual. I have a PreToolUse hook on my BenchGecko project that checks if my local benchmark data is stale. Before any Bash command runs, it checks when the cache was last updated, and if it's been more than 12 hours, it pulls fresh benchmark scores from BenchGecko's API. Sounds excessive, but when you're building tools around AI model data, working with yesterday's numbers is a waste of time. Models drop weekly now.
Context Injection
Here's the sneaky one I mentioned earlier. A PreToolUse hook can return {"decision": "approve", "message": "..."}, and that message gets injected into the agent's context. I use this to remind the agent about project conventions:
{
"type": "PreToolUse",
"matcher": "Write|Edit",
"command": "~/.claude/hooks/style-reminder.sh"
}
#!/bin/bash
# style-reminder.sh
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
case "$FILE" in
*.tsx|*.jsx)
echo '{"decision": "approve", "message": "Reminder: This project uses single quotes, 2-space indent, no semicolons. Components go in PascalCase folders."}'
;;
*)
exit 0
;;
esac
This is better than cramming everything into your CLAUDE.md or AGENTS.md file, because it fires at the exact moment the agent is about to write code. Just-in-time context. The agent doesn't have to remember rules from 50,000 tokens ago - the hook reminds it right when it matters.
Mistakes I Made So You Don't
A few things I got wrong during setup:
Don't match everything. My first attempt used "matcher": ".*" on a PostToolUse hook that ran tests. Every Read, every Glob, every single tool call triggered my test suite. My 2-second operations turned into 30-second ones. Match only what you need.
Also: keep hooks fast. If your PostToolUse hook takes 10 seconds, you've added 10 seconds to every file write. I keep formatters and lint checks under 2 seconds. Anything heavier (like a full test suite) gets scoped to specific file patterns.
Handle missing tools gracefully. Your hook script might run in a project that doesn't have prettier or eslint installed. Always use 2>/dev/null or check for the binary first. Silent failure beats a broken hook.
And one more thing - don't echo random text to stdout. Claude Code tries to parse your hook's stdout as JSON. If you have debug prints or stray echo statements, the hook breaks. I spent way too long debugging a hook that had echo "running..." at the top. Stderr is fine for debug output. Stdout is for JSON responses only.
My Full Settings File
Here's what my global ~/.claude/settings.json hooks section looks like as of April 2026. I keep project-specific stuff (formatters, test runners) in each repo's .claude/settings.json.
{
"hooks": [
{
"type": "PreToolUse",
"matcher": "Bash",
"command": "~/.claude/hooks/safety-check.sh"
},
{
"type": "PreToolUse",
"matcher": "Bash",
"command": "~/.claude/hooks/pre-commit-lint.sh"
},
{
"type": "Stop",
"matcher": ".*",
"command": "~/.claude/hooks/notify-slack.sh"
}
]
}
Three global hooks. That's it. Safety net, lint gate, and a Slack ping. Everything else is per-project. I know some people go wild with 15+ hooks, but I've found that the more hooks you add, the harder they are to debug when something goes wrong. Start with the safety hook. Add one more when you feel a specific pain point. Don't over-engineer it.
If you're comparing different AI coding tools, hooks are one of the things that sets Claude Code apart from Devin and the other options. Devin gives you a whole VM, sure, but you can't intercept its tool calls with two lines of bash. The Opus 4 model is smart enough to actually respond to hook messages intelligently, which is what makes the "message" response type so useful compared to running Sonnet for lighter tasks where the context injection sometimes gets ignored.
I've also started adding a PostToolUse hook on one of my side projects that pings BenchGecko's API after every deploy to check if there's a newer model I should be testing against. It adds maybe 500ms to a deploy, and it's caught two model upgrades I would have missed otherwise. Small thing, big payoff.
The whole hook system feels like it was designed by someone who actually uses Claude Code for real work - not just demos. That's rare. If you haven't tried automating your code review flow yet, hooks make it almost trivially easy to bolt on linting, formatting, and safety checks without changing anything about how you interact with the agent.
FAQ
Where do I put Claude Code hooks in settings.json?
Hooks go in ~/.claude/settings.json for global hooks or .claude/settings.json at your project root for project-specific hooks. The hooks key is a top-level array in the settings object.
What hook types does Claude Code support?
Four types: PreToolUse (runs before a tool call), PostToolUse (runs after a tool call), Notification (runs when notifications fire), and Stop (runs when the agent stops working).
Can Claude Code hooks block dangerous commands?
Yes. A PreToolUse hook on the Bash tool can inspect the command string and return a deny decision to block commands like rm -rf, git push --force, or any other pattern you want to prevent. I block about a dozen patterns globally.
How do I auto-format code after Claude Code writes a file?
Add a PostToolUse hook with a matcher for Write|Edit, then set the command to run your formatter on the changed file. The file path comes through on stdin as JSON. I showed my full formatter script above.
Do Claude Code hooks work with project-level settings?
Yes. Project-level hooks in .claude/settings.json get merged with your global hooks from ~/.claude/settings.json. I use this to keep safety hooks global and formatter/test hooks project-specific.