Part of the Claude Code workflow series. Start with the install primer; then what to do after install; then this post when you're ready to stop approving the same thing every session.
At some point you realize you've clicked approve on npm test so many times your index finger has a muscle memory for it. The command is safe. The approval was never protection — it was a speed bump that everyone eventually learns to step over without looking. Auto Mode partially fixes this with a classifier. Hooks fix it deterministically.
A hook is a shell command the Claude Code harness runs in response to an event. Four event types you'll actually use:
PreToolUse— runs before Claude invokes a tool. Can approve, deny, or modify.PostToolUse— runs after a tool call. Can format, log, trigger tests.Stop— runs when the session ends. Good for cleanup and session summaries.UserPromptSubmit— runs before each user prompt is sent. Can inject context.
Hooks live in .claude/hooks.json at the repo root (or globally at ~/.claude/hooks.json). Below are the five hook recipes that cover most approval-fatigue pain points.
Recipe 1 — auto-approve known-safe Bash commands
The biggest source of approval fatigue. Every npm test, git status, eleventy build is a click you didn't need to make.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/auto-approve-safe-bash.sh"
}
]
}
]
}
}
.claude/hooks/auto-approve-safe-bash.sh:
#!/usr/bin/env bash
# Read the tool input from stdin, decide whether to auto-approve.
# Exit 0 = allow, exit 2 = block, anything else = prompt user.
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command')
# Exact commands we always allow
case "$cmd" in
"npm test"|"npm run build"|"npm run lint"|"git status"|"git diff"|"git log --oneline -20"|"npx @11ty/eleventy"|"ls"|"pwd")
echo '{"decision":"allow"}'
exit 0
;;
esac
# Patterns we always allow
if [[ "$cmd" =~ ^(cat|head|tail|less|wc|grep|rg)\ ]]; then
echo '{"decision":"allow"}'
exit 0
fi
if [[ "$cmd" =~ ^git\ (log|diff|status|show)\ ]]; then
echo '{"decision":"allow"}'
exit 0
fi
# Everything else falls through to user prompt
exit 1
Make executable: chmod +x .claude/hooks/auto-approve-safe-bash.sh. Next session, npm test runs without a prompt. Anything outside the allowlist still pauses for your review. The allowlist is a living file — add patterns as you notice them.
Recipe 2 — format every file on save
You don't want Claude to remember the 50 different formatters you run. Put them in a hook.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/format-on-edit.sh"
}
]
}
]
}
}
.claude/hooks/format-on-edit.sh:
#!/usr/bin/env bash
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path')
case "$file" in
*.ts|*.tsx|*.js|*.jsx|*.mjs)
npx prettier --write "$file" 2>/dev/null
npx eslint --fix "$file" 2>/dev/null
;;
*.py)
ruff format "$file" 2>/dev/null
;;
*.go)
gofmt -w "$file" 2>/dev/null
;;
*.md|*.json|*.yaml|*.yml)
npx prettier --write "$file" 2>/dev/null
;;
esac
Every file edit now leaves a properly-formatted file. Claude stops worrying about style because style is no longer its problem. You stop reviewing formatting in diffs.
Recipe 3 — session summary on stop
Ending a session without a handoff note is how tomorrow-you ends up re-discovering what today-you already figured out.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/session-summary.sh"
}
]
}
]
}
}
.claude/hooks/session-summary.sh:
#!/usr/bin/env bash
date_iso=$(date -Iseconds)
session_dir="$HOME/.claude/session-log"
mkdir -p "$session_dir"
cat >> "$session_dir/$(date +%Y-%m-%d).md" <<EOF
## Session ended $date_iso
Branch: $(git -C "$PWD" branch --show-current 2>/dev/null)
Files changed:
$(git -C "$PWD" status --short 2>/dev/null)
Last 3 commits:
$(git -C "$PWD" log --oneline -3 2>/dev/null)
---
EOF
Now closing a session appends a dated log entry. Tomorrow morning, cat ~/.claude/session-log/$(date +%Y-%m-%d -d yesterday).md tells you exactly where you left off.
Recipe 4 — inject git state into every prompt
The most underused hook type. UserPromptSubmit runs before your prompt reaches Claude, and you can prepend context that the model would otherwise have to ask for.
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/inject-git-state.sh"
}
]
}
]
}
}
.claude/hooks/inject-git-state.sh:
#!/usr/bin/env bash
branch=$(git branch --show-current 2>/dev/null)
status=$(git status --short 2>/dev/null | head -20)
diff_stat=$(git diff --stat 2>/dev/null | tail -1)
cat <<EOF
[git context]
Branch: $branch
Uncommitted: $status
Diff: $diff_stat
[end git context]
EOF
Every prompt now has a fresh snapshot of repo state. Claude stops needing to run git status on its own — it's already there. A small thing that compounds to a meaningfully smoother session.
Recipe 5 — hard deny on dangerous patterns
Auto Mode's classifier catches most risky operations, but classifier-based defense has false negatives. A hook with an explicit deny list is deterministic.
#!/usr/bin/env bash
# .claude/hooks/hard-deny.sh — runs before every Bash call.
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command')
# Patterns we ALWAYS block, even in Auto Mode
deny_patterns=(
"rm -rf /"
"rm -rf ~"
"rm -rf \\*"
"git push --force origin main"
"git push --force origin master"
"dd if="
":(){:|:&};:"
)
for pattern in "${deny_patterns[@]}"; do
if [[ "$cmd" == *"$pattern"* ]]; then
echo "{\"decision\":\"block\",\"reason\":\"hard-deny rule: $pattern\"}"
exit 2
fi
done
exit 1 # fall through to next hook or user prompt
Wire this BEFORE the auto-approve hook in the PreToolUse chain. Order matters — deny wins.
The hooks-vs-skills-vs-rules decision
You have three places to put a behavior:
- Rule — "Files in
src/auth/need 100% coverage." Describes a constraint Claude should follow. Expressed in prose in.claude/rules/. - Skill — "Run the pre-deploy sequence: build, test, rebuild-nav, deploy." A multi-step recipe Claude invokes. Lives in
.claude/skills/. - Hook — "Every file save gets Prettier + ESLint." An automatic action the harness runs, not the model. Lives in
.claude/hooks.json.
Guiding rule: if the model can forget to do it, it's a rule or a skill. If the harness can do it without the model's involvement, it's a hook. Hooks win on determinism; rules win on flexibility.
Debugging hooks when they don't fire
Hooks fail silently by default. When your auto-approve isn't working:
/debuginside Claude Code — reads the session log, often catches config errors.- Check the hook script exits 0 cleanly. Run it by hand with test input.
- Verify the matcher —
"matcher": "Bash"matches all Bash calls;"matcher": "Bash|Edit"matches both. Matchers are regex against the tool name, not command content. - Verify output format —
PreToolUsehooks that want to approve must emit{"decision":"allow"}on stdout AND exit 0. Either alone isn't enough. - Check
~/.claude/logs/— the harness writes hook execution traces there.
Common mistakes
- No executable bit on the script.
chmod +xis the first thing to check when a hook silently doesn't fire. - Allow-list too permissive.
git *lets Claude rungit push --force origin main. Pattern-match carefully. - Over-broad PostToolUse formatting. Running Prettier on a 50 MB CSV is slow. Gate by extension.
- Hooks that themselves need approval. The hook runs outside the approval flow, but if your hook calls something that spawns a sub-process needing approval, you get stuck.
- Shared hooks across repos without overrides. A hook in
~/.claude/hooks.jsonruns globally; a repo-local hook at.claude/hooks.jsonoverrides specific matchers. Know which one you're editing.
Related reading
- AI Terminal Kickstart — install prereq.
- CLI installed — now what? — the overview that introduces hooks.
- Skills vs Rules vs Memory — hooks are a fourth layer; this post explains the first three.
- Auto Mode case studies — hooks pair naturally with Auto Mode; the hard-deny hook is exactly what covers the Auto Mode classifier's blind spots.
- /insights-driven iteration — run
/insightsweekly to find which approvals dominate your friction; those become your next hooks.
Fact-check notes and sources
- Official: Claude Code docs on slash commands + hooks.
- freeCodeCamp: The Claude Code Handbook — includes working hook examples.
- GitHub: Claude Code playbook — security best practices — the hook patterns you want in a production-ish setup.
- Anthropic: Auto Mode: a safer way to skip permissions — explains where classifier-based defense stops and hooks take over.
Informational, not security consulting advice. The hook APIs and matcher syntax reflect Q1 2026 behavior. Verify against the official changelog before wiring hooks into a production workflow. Mentions of Anthropic, Claude Code, third-party authors are nominative fair use.