CLAUDE LABJP
FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtaskLIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its ownMCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MSPLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callbackSONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as OctoberFORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtaskLIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its ownMCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MSPLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callbackSONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Articles/Claude Code
Claude Code/2026-04-25Intermediate

When Claude Code Hooks Loop Forever — Stopping Self-Triggering PostToolUse Hooks

You wired up Claude Code hooks and now your terminal is a waterfall of tool calls that won't stop. Here's why PostToolUse hooks loop on themselves, and the patterns I use in real projects to make sure they never do again.

claude-code129hooks14troubleshooting87automation95fix12

If you've used Claude Code hooks for any length of time, you've probably hit this moment: you wire up prettier --write to a PostToolUse hook, the next Edit call kicks it off, and suddenly your terminal is scrolling forever. Edit, PostToolUse, Edit, PostToolUse — Ctrl+C does almost nothing, and your CPU fan starts ramping up. I remember the first time it happened to me. Watching that scroll, my finger hovering on the keyboard, was a small but very real lesson in how easy it is to accidentally build a loop in a system that's designed to react to its own output.

The official docs warn you about hook loops in passing, but in practice this is one of the most common ways a hooks setup blows up in real projects. In this article I'll walk through why these loops happen and the patterns I use day-to-day to make sure they don't.

Why hooks loop in the first place

PostToolUse fires right after Claude Code runs any tool — Edit, Write, Bash, and so on. That makes it tempting to chain extra logic onto every tool call. The problem is that whatever your hook does often looks, from the outside, like another tool call or a file change. Once that's true, the hook becomes its own trigger.

A few patterns I see over and over:

  • Formatters that re-trigger Edit. A PostToolUse hook on Edit runs prettier --write. The file changes. Claude observes the new state and decides another edit is needed. The Edit fires PostToolUse again. Round and round.
  • Auto-commit hooks that look like Bash calls. A PostToolUse hook on Write shells out to git add . && git commit -m "...". From the runtime's view, that's a Bash invocation, so a separate Bash-matched PostToolUse hook fires on it. If that second hook does anything Claude can observe, you're in a loop.
  • Marker files that look like Edits. Hooks that drop a .claude-last-run.json for bookkeeping get picked up by another hook watching for file changes, which then writes its own marker — same pattern, different outfit.

The common shape is always the same: the hook's side effect overlaps with the hook's trigger condition. As long as those two things touch, there's no logical reason the loop will ever stop on its own.

First aid — stopping a runaway hook safely

When a hook is actively looping, Ctrl+C alone may not be enough. The Claude Code session can keep running in the background even if the visible output goes quiet. The first thing I do is the same every time: I physically empty the hooks block in settings.json so nothing can fire on the next call.

# Drain hooks so the loop can't restart
cd ~/.claude
cp settings.json settings.json.bak
jq '.hooks = {}' settings.json > settings.json.tmp && mv settings.json.tmp settings.json

Don't forget that hooks can live in multiple scopes. Project-level .claude/settings.json, the personal .claude/settings.local.json, and the user-global ~/.claude/settings.json are all merged together. Search across them so you don't miss one:

grep -rln '"hooks"' ~/.claude .claude 2>/dev/null

Once the bleeding has stopped, restore your hooks one at a time from the backup. Whichever hook brings the loop back is the one you need to redesign.

Three patterns to prevent loops by design

After triage comes the real work. Here are the three patterns I rely on in production projects.

Pattern 1: a re-entry marker

The simplest and highest-leverage trick is to give the hook a way to recognize that it itself is already running. I use the CLAUDE_HOOK_DEPTH environment variable as a one-bit reentrancy flag.

#!/bin/bash
# .claude/hooks/format.sh — guard against re-entry
if [ -n "${CLAUDE_HOOK_DEPTH:-}" ]; then
  # We were called from inside another hook — don't go deeper
  exit 0
fi
export CLAUDE_HOOK_DEPTH=1
 
# Real work
prettier --write "$1" >/dev/null 2>&1
 
# Expected output: nothing on stdout, so Claude has no reason to re-evaluate
exit 0

The other thing to watch for is silence. Hooks like this should produce no stdout. If you echo the result, Claude reads it as fresh signal and may decide further action is needed — that's another way loops sneak in. For pure side-effect hooks like formatters, success means say nothing.

Pattern 2: tighten the matcher to exclude your own work

The matcher field in the hook config accepts patterns. If you take the time to be specific, you can keep your hook from reacting to its own side effects.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "command": ".claude/hooks/format.sh",
        "exclude_paths": [".claude-last-run.json", "node_modules/**", ".next/**"]
      },
      {
        "matcher": "Bash",
        "command": ".claude/hooks/log-bash.sh",
        "exclude_commands_matching": "^(git |prettier|eslint|jq )"
      }
    ]
  }
}

Excluding paths and commands isn't just about preventing loops — it also makes the logs much easier to read after the fact. When hooks misbehave, my first instinct is always to check whether the matcher is doing what I think it's doing. For a deeper walkthrough of why hooks sometimes don't fire at all, see How to Troubleshoot Claude Code Hooks That Don't Fire.

Pattern 3: file locks so only one runs at a time

The last line of defense is flock. When several hooks touch the same files — formatter, committer, notifier — wrapping each one in a lock means even if multiple are triggered at once, they queue up instead of looping.

#!/bin/bash
# .claude/hooks/safe-format.sh
LOCK_FILE="/tmp/claude-hooks.lock"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
  # Already running — give up rather than queueing
  exit 0
fi
 
prettier --write "$1" >/dev/null 2>&1
# The lock is released automatically on exit

Locks are great for short side effects (formatting, linting). They're a poor fit for long-running tasks because subsequent hook calls will fail to acquire the lock. I keep locks for sub-second work and use the re-entry marker (Pattern 1) for anything that takes meaningful time.

Designing logs that catch loops early

A loop is best caught before it becomes an emergency. I keep a small counter inside my hooks that trips a fuse if the same hook fires too many times in too short a window.

#!/bin/bash
# Self-defeating hook: stops itself if it fires too often
COUNT_FILE="/tmp/claude-hook-count"
NOW=$(date +%s)
LAST=$(cat "$COUNT_FILE.time" 2>/dev/null || echo 0)
COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0)
 
if [ $((NOW - LAST)) -lt 5 ]; then
  COUNT=$((COUNT + 1))
else
  COUNT=1
fi
echo "$COUNT" > "$COUNT_FILE"
echo "$NOW" > "$COUNT_FILE.time"
 
if [ "$COUNT" -ge 10 ]; then
  echo "⚠️ Hook fired 10+ times in 5s — likely a loop. Skipping." >&2
  exit 0
fi
 
# Real work below this line

With a circuit breaker like this, a misconfiguration that would have looped forever now becomes a few noisy lines on stderr and a clean exit. For a deeper take on observability for production hooks, Debugging Claude Code Hooks in Production covers the layered approach I use when even logs aren't telling the whole story.

Variations of the same bug worth recognizing

Once you've debugged a hook loop a few times, you start spotting the same shape in places you wouldn't expect. A few real examples from my own projects, in case they help you see patterns earlier:

  • Cross-tool loops between Edit and Bash. A PostToolUse on Edit runs a shell formatter, and a PostToolUse on Bash logs every command. The formatter shells out, the logger fires, the logger writes a status line to a file, the file change is observed — and now you have two hooks taking turns. The fix is the same idea as Pattern 2: exclude formatter commands from the Bash matcher.
  • Hooks that update their own config. I once had a hook that wrote a last_run_at field back into the project's settings file. Settings is read every cycle, so editing it counts as an event. After three minutes I had a settings file with thousands of timestamps and a clear lesson: hooks should never write to anything in .claude/.
  • Notifier hooks that retry on failure. A Slack notifier failed because of a timeout, returned non-zero, and Claude treated that as "this needs another attempt." A retry loop disguised as a hook loop. The fix here is simple: notifier-style hooks should always exit 0, even on failure, and log their own errors elsewhere.

When you start seeing these as the same problem in different clothes, you'll catch them in code review before they ship.

A small step you can take today

There's no single switch that makes hook loops go away. But the underlying principle is small enough to internalize quickly: never let a hook's side effect overlap with its trigger condition. If you keep that one rule in mind, you'll catch most loops before you write them.

If you want to act on this today, pick exactly one of these three:

  1. Open every PostToolUse hook you have and check whether it writes anything to stdout. For formatters and linters, the right answer is almost always silence.
  2. Add a single exclude_paths or exclude_commands_matching entry to your existing matcher so your hook won't react to its own work.
  3. Drop a circuit-breaker script — like the counter above — into .claude/hooks/ so that any future misconfiguration self-terminates after 10 firings in 5 seconds.

That third one is the foundation I'd recommend if you're planning to grow your hook setup further. For a more complete map of what's possible with hooks once you've made them safe, Claude Code Hooks Master Guide is a good next stop.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Claude Code2026-06-10
When git add -A Sweeps Up .bak Backups — The Quiet Trap of In-Place Fix Scripts
How .bak backups left by sed -i and homegrown --fix scripts get silently swept into production by git add -A, why it is so easy to miss, and how scoped adds and .gitignore close the gap for good.
Claude Code2026-06-09
When Yesterday's Article Bleeds Into Today's — The Stale Fixed-Name Temp File Trap
In a Claude Code automation pipeline, a fixed-name temp file kept stale content from the previous run and leaked old body text into a completely different article. Here is why the write fails silently, and how unique names plus a post-write grep check prevent it.
Claude Code2026-06-03
When git push Says Success but Nothing Lands — the Silent commit Failure in Claude Code
git push prints Everything up-to-date and exits zero, yet your changes never reach the remote. Here is why commit silently fails on a fresh sandbox clone, and how to verify a real push with SHA comparison.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →