Free playbooks in your inbox

What to Do When Claude Code Gets Stuck in a Loop

When Claude Code gets stuck in a loop overnight, make it try something different each time and stop with a note after 4 tries. Wrong API keys, blocked files and an empty budget are never retried, and each try gets its own slice of the budget.

From the youcanbuildthings catalog ▸ Build-tested

Hello builders,

The best morning after an overnight Claude Code run is one where the loop hit a wall, recovered by itself, and shipped, or stopped with a note saying exactly why it gave up. The usual morning is worse: the gate refused at 2:14am, the loop tried the same thing again, and by the time you woke up it had done it eleven times and billed you for all eleven. If you have a Claude Code agent stuck in a loop like that, the fix is a classify step plus a four-rung recovery ladder the loop climbs on a failure count, with nobody awake to decide.

Why does the plain retry do so badly? When our first attempt fails, that attempt is now in the context, so attempt two is conditioned on a transcript where the wrong approach is the most recent, most detailed thing present. What we have really handed it is a worked example of the thing that failed, plus a request to keep going. I will be straight about the strength of that claim, because I have not measured it and I haven’t found anyone who has. Anthropic’s own guidance on long-running work points the same way: record failed approaches, or successive sessions re-attempt the same dead ends.

So every rung we build has to change a condition. A retry that changes nothing is a re-roll of dice already weighted against you.

The pattern has a public name too. Code Intel Log’s production hardening write-up puts agent loops behind a circuit breaker with four states:

  • CLOSED: normal operation, no limits
  • WATCHING: failure rate elevated (>10%), monitoring closely
  • DEGRADED: high failure rate (>30%), reduced max iterations
  • OPEN: critical failure rate (>50%), agent disabled

Its degradation moves are model downgrade, tool set reduction, max iterations reduction and human handoff. Our ladder borrows that shape and swaps failure rates for a per-step count.

Claude Code recovery ladder: classify() sends SCOPE REFUSAL, budget_exhausted, 401 Unauthorized and ECONNREFUSED to halt, never retry these; everything else climbs failures == 1 retry_with_reason 0.15, failures == 2 retry_fresh_context 0.25, failures == 3 retry_downgraded 0.35, failures >= 4 handoff 0.00, handing off should not cost anything. At 3am there is nobody to decide.

Classify before you climb

Some failures are the loop being told no, and retrying them is paying to hear it again. A SCOPE REFUSAL from your hook is a rule you wrote on purpose, and four creative attempts to get around it is the exact behaviour the boundary exists to stop. budget_exhausted means you already said there’s no money to climb with. A 401 or ECONNREFUSED stays broken however many times you retry, and the agent will get inventive against a wrong API key. If you’ve ever found a fabricated mock where a real integration should be, this is usually how it got there. Never retry these four.

classify() {                      # reads the terminal reason, returns an action
  case "$1" in
    *SCOPE\ REFUSAL*)  echo halt ;;   # a rule, not an obstacle
    budget_exhausted)  echo halt ;;   # no money to climb with
    *ENOENT*|*ECONNREFUSED*|*401\ Unauthorized*|*403\ Forbidden*) echo halt ;;
    *)                 echo climb ;;
  esac
}

Four rungs on a count

Everything else climbs, and we make every trigger a count the loop can evaluate by itself. At 3am there is nobody to decide. We keep the policy as data in loop-contract/recovery.tsv:

# rung  trigger                   action                budget_share
1       failures_this_step == 1   retry_with_reason     0.15
2       failures_this_step == 2   retry_fresh_context   0.25
3       failures_this_step == 3   retry_downgraded      0.35
4       failures_this_step >= 4   handoff               0.00

Why give each rung its own slice of the budget? Because a loop that spends freely on rungs one and two arrives at rung three broke. The last column is each rung’s share of the run’s budget. Set LOOP_BUDGET_USD once and rungs one to three get 15%, 25% and 35% of it, so the rung most likely to work still has money when the loop reaches it. Rung four gets 0.00, because handing off should not cost anything. The dispatcher reads that column, so the file really is the policy. It also reads two things your loop should already keep: loop-contract/*.md, your project facts and house rules, and .loop/state.md, a short file rewritten every iteration that lists what was tried and rejected, each with the error that rejected it. Put the dispatcher in .loop/lib.sh next to your wrapper:

share() {   # $1 = rung; returns that rung's slice of LOOP_BUDGET_USD from the policy file
  awk -v t="${LOOP_BUDGET_USD:-5.00}" -v r="$1" '$1==r{printf "%.2f", t*$NF}' loop-contract/recovery.tsv
}
step_failed() { mkdir -p .loop/failures; echo x >> ".loop/failures/$1"; wc -l < ".loop/failures/$1" | tr -d ' '; }

recover() {
  local step="$1" reason="$2" n; n=$(step_failed "$step")
  case "$n" in
    1) claude -p --max-turns 10 --max-budget-usd "$(share 1)" "$(cat loop-contract/*.md)
The previous attempt at '$step' was refused. The verifier said: $reason
Address that specific objection. Do not restate the previous approach." ;;
    2) claude -p --max-turns 10 --max-budget-usd "$(share 2)" "$(cat loop-contract/*.md .loop/state.md)
Task: $step. Approaches already tried and rejected are in the state file above. Do not repeat any of them." ;;
    3) CLAUDE_CODE_SUBAGENT_MODEL=haiku claude -p --max-turns 6 --max-budget-usd "$(share 3)" "$(cat loop-contract/*.md .loop/state.md)
Task: $step. Two approaches have failed. Reduce this task to the smallest change that could possibly work, state it in one sentence, and implement only that." ;;
    *) printf 'ESCALATED\t%s\t%s\n' "$step" "$reason" >> .loop/terminal; exit 4 ;;
  esac
}

Call it wherever your loop handles a refusal, with $step set to the name of the step that failed, right after the verifier writes its verdict, so a halt becomes a named hand-off at once:

reason=$(jq -r '.reason' .loop/verdict.json)
if [ "$(classify "$reason")" = halt ]; then
  printf 'ESCALATED\t%s\t%s\n' "$step" "$reason" >> .loop/terminal
else
  recover "$step" "$reason"
fi

Rung one feeds the verifier’s objection back as an instruction. Rung two throws the conversation away and reloads from the state file, which breaks the conditioning problem and is the rung most of us skip. On rung three, read the prompt before the model line, because two failures usually mean the task was bigger than the agent’s grasp of it. Rung four writes ESCALATED to .loop/terminal and stops.

One trap on that third rung. ANTHROPIC_DEFAULT_HAIKU_MODEL looks like the obvious knob, but it is read everywhere the small fast model is used, including /goal’s evaluator, so setting it mid-recovery quietly swaps the judge of your stop condition. CLAUDE_CODE_SUBAGENT_MODEL changes the worker and leaves the judge alone.

We test it the way we’d test any gate. Break something the agent can’t see, like a required environment variable, and watch the climb: four rungs, four log lines, and no human involved until the last one. Building this takes a few hours once, and the same dispatcher serves every loop you own after that.

Now go build something this weekend!

John Cook

Why trust this? Every youcanbuildthings guide is pulled from a build-tested book: code that ran in production before it was written down.