← Back to Blog

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

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

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:

  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

Fact-check notes and sources

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.

← Back to Blog

Accessibility Options

Text Size
High Contrast
Reduce Motion
Reading Guide
Link Highlighting
Accessibility Statement

J.A. Watte is committed to ensuring digital accessibility for people with disabilities. This site conforms to WCAG 2.1 and 2.2 Level AA guidelines.

Measures Taken

  • Semantic HTML with proper heading hierarchy
  • ARIA labels and roles for interactive components
  • Color contrast ratios meeting WCAG AA (4.5:1)
  • Full keyboard navigation support
  • Skip navigation link
  • Visible focus indicators (3:1 contrast)
  • 44px minimum touch/click targets
  • Dark/light theme with system preference detection
  • Responsive design for all devices
  • Reduced motion support (CSS + toggle)
  • Text size customization (14px–20px)
  • Print stylesheet

Feedback

Contact: jwatte.com/contact

Full Accessibility StatementPrivacy Policy

Last updated: April 2026