# Stop Approving The Same Prompt 40 Times A Day — A Working Hooks Implementation Guide For Claude Code

You clicked approve on npm test 47 times today. A 12-line hook removes the prompt forever without removing the safety. Concrete PreToolUse, PostToolUse, Stop, and UserPromptSubmit examples you can copy into .claude/hooks.json today.

Author: J.A. Watte
Published: April 25, 2026
Source: https://jwatte.com/blog/claude-code-hooks-approval-fatigue/

---

_Part of the Claude Code workflow series. Start with the [install primer](/blog/blog-ai-terminal-kickstart/); then [what to do after install](/blog/ai-terminal-workflow-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.

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/auto-approve-safe-bash.sh"
          }
        ]
      }
    ]
  }
}
```

`.claude/hooks/auto-approve-safe-bash.sh`:

```bash
#!/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.

```json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/format-on-edit.sh"
          }
        ]
      }
    ]
  }
}
```

`.claude/hooks/format-on-edit.sh`:

```bash
#!/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.

```json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/session-summary.sh"
          }
        ]
      }
    ]
  }
}
```

`.claude/hooks/session-summary.sh`:

```bash
#!/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.

```json
{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/inject-git-state.sh"
          }
        ]
      }
    ]
  }
}
```

`.claude/hooks/inject-git-state.sh`:

```bash
#!/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.

```bash
#!/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:

1. `/debug` inside Claude Code — reads the session log, often catches config errors.
2. Check the hook script exits 0 cleanly. Run it by hand with test input.
3. Verify the matcher — `"matcher": "Bash"` matches all Bash calls; `"matcher": "Bash|Edit"` matches both. Matchers are regex against the tool name, not command content.
4. Verify output format — `PreToolUse` hooks that want to approve must emit `{"decision":"allow"}` on stdout AND exit 0. Either alone isn't enough.
5. Check `~/.claude/logs/` — the harness writes hook execution traces there.

## Common mistakes

- **No executable bit on the script.** `chmod +x` is the first thing to check when a hook silently doesn't fire.
- **Allow-list too permissive.** `git *` lets Claude run `git 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.json` runs globally; a repo-local hook at `.claude/hooks.json` overrides specific matchers. Know which one you're editing.

## Related reading

- **[AI Terminal Kickstart](/blog/blog-ai-terminal-kickstart/)** — install prereq.
- **[CLI installed — now what?](/blog/ai-terminal-workflow-after-install/)** — the overview that introduces hooks.
- **[Skills vs Rules vs Memory](/blog/claude-code-skills-rules-memory-deep-dive/)** — hooks are a fourth layer; this post explains the first three.
- **[Auto Mode case studies](/blog/claude-code-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](/blog/claude-code-insights-iteration-workflow/)** — run `/insights` weekly 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](https://code.claude.com/docs/en/slash-commands).
- freeCodeCamp: [The Claude Code Handbook](https://www.freecodecamp.org/news/claude-code-handbook/) — includes working hook examples.
- GitHub: [Claude Code playbook — security best practices](https://github.com/RiyaParikh0112/claude-code-playbook/blob/main/docs/fundamentals/security-best-practices.md) — the hook patterns you want in a production-ish setup.
- Anthropic: [Auto Mode: a safer way to skip permissions](https://www.anthropic.com/engineering/claude-code-auto-mode) — 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](https://code.claude.com/docs/en/changelog) before wiring hooks into a production workflow. Mentions of Anthropic, Claude Code, third-party authors are nominative fair use.*


---

Canonical HTML: https://jwatte.com/blog/claude-code-hooks-approval-fatigue/
RSS: https://jwatte.com/feed.xml
JSON Feed: https://jwatte.com/feed.json
Hero image: https://jwatte.com/images/claude-code-hooks-approval-fatigue.webp
