Rehoboth Builds

Claude Code usage limits: the gauge was already in the stream, and we were not reading it

On a Wednesday evening our unattended agent hit the weekly limit. It stayed down for 46 hours, and so did every interactive session on the same account — including the human's. The part worth writing down is not that we ran out. It is that the usage reading we needed had been flowing through our own log pipeline on every single call, and we were throwing it away.

20 September 2026 · measured on our own account, on one machine.

The two windows, and why only one of them hurt

There are two limits running at once, and they fail differently.

The five-hour window is the one people notice. It rolls, it refills several times a day, and hitting it costs you an afternoon at worst. Everyone develops a feel for it.

The seven-day window is the one that actually bites, for three reasons. It resets at a fixed weekly moment tied to the account — ours is Friday 19:00 local, and it did not move when the plan changed. You cannot feel it: by the time you notice, you are already out, and there is no partial refill to carry you. And it is account-wide, so an unattended process burning it is spending the human's budget, not its own. That last one is the whole story below.

What the CLI already tells you

Run headless with structured output:

claude -p "…" --output-format stream-json --verbose

Among the system, assistant, user and result events, there is one more: rate_limit_event. It is emitted on every call, and it carries the gauge:

{
  "type": "rate_limit_event",
  "rate_limit_info": {
    "status": "allowed",
    "rateLimitType": "five_hour",
    "overageStatus": "rejected",
    "unifiedWindows": {
      "five_hour": { "utilization": 0.12, "resetsAt": 1789893600 },
      "seven_day": { "utilization": 0.26, "resetsAt": 1790334000 }
    }
  }
}

utilization is a fraction from 0 to 1, not a percentage and not a token count. resetsAt is a Unix timestamp in seconds — the actual moment the window refills, which is the number you want when you are deciding how long to sleep.

Our formatter handled four event types and silently discarded anything else it could parse. So for roughly two weeks the sentence "there is no interface any script can read the quota from" sat in our runner, in our budget script, and in our agent's standing instructions — while the reading itself went past the parser on every call. Nothing was broken. Nothing logged a warning. The gauge and no-gauge worlds looked identical from the outside, which is exactly why it lasted.

If you write a formatter for a stream you do not fully control, make the default branch print the line rather than drop it. "Unrecognised event" and "no output" have to be distinguishable, or you will lose a field you needed and never find out.

Four alerts, four different wrong causes

Here is what our monitoring said during those 46 hours, all about the same single cause:

  • "The agent starts but cannot write files." The pre-flight capability check ran, failed because it was rate-limited, and had its output redirected to /dev/null. Thirteen times. With the output thrown away, the only thing left to infer from was the exit code, and the handler's guess was a permissions problem — so it opened an escalation ticket about a capability gap that did not exist.
  • "The agent has refused N slots in a row" and "the agent has stalled, probably self-locked." Two more alerts, two more guesses.
  • "Last 24h: 0 rounds, but there was real output (7 commits)" — our dashboard, in green. All seven were mechanical bookkeeping commits by the runner itself.

Exactly one alert in the system said the true thing: an auth guard that pattern-matched the limit message and reported "hit the usage limit". It was right for two days and was outvoted by three confident wrong answers and a green banner.

The lesson generalises past quotas. A diagnostic that has to guess will guess. If a probe can fail for a reason it cannot see, it must ask the cheap question first — here, "am I rate-limited?" — before falling through to its default explanation. And never discard the output of a check whose exit code you are going to interpret; that redirect to /dev/null was the single line that turned a known state into a mystery.

The backoff slept 3.4 hours too long

The limit message says, in words:

You've hit your weekly limit · resets Sep 18, 7pm (Asia/Taipei)

Our parser only understood a different shape — a literal epoch. Finding nothing, it fell back to a fixed 240-minute sleep, and then another, and another. The window reopened at 19:00; the agent was asleep until 22:23.

The fix is a priority order, not a better regexp: reading first, message text second, epoch third, fixed interval last. The gauge gives you resetsAt directly, so once you are parsing rate_limit_event the prose path is only a fallback for the one call that gets refused before any reading arrives. Keep the fixed interval at the bottom anyway: when everything else fails you want a system that sleeps too long, not one that retries in a storm.

One more state to get right: "paused for quota" is not "stuck." Ours incremented the same barren-round counter that a genuinely unproductive round does. Fourteen of those in a row, and the escalation ladder would have responded to a quota outage by switching to a more expensive model. Planned waiting and failure need separate counters, or your recovery logic will make the problem worse at exactly the wrong moment.

Where a round's tokens actually go

Once we could see usage, the obvious next question was what to cut. We split 165 unattended rounds by token class:

  • Cache reads — 54%
  • Cache writes — 30%
  • Output — 16%

This was not what we expected, and it redirects the whole optimisation. Output tokens are what you watch while you work, and they are the smallest slice. Eighty-four per cent of a round is the context — carrying it in, and writing it down again.

So the cheapest capacity increase available to us was not a better model choice, a shorter prompt, or fewer tool calls. It was shrinking the standing file the agent reads at the start of every round. Ours had grown to 304KB, past the point where a single file read even succeeds — so every round began by failing once, then loading it in pieces, then carrying all of it in cached context for the rest of the round. A file nobody thought of as a cost centre was the largest single line item.

Caveat we will not paper over: those percentages are ours — one account, one workload, one model, 165 rounds. The shape (context dominates, output is a minority) is what we would expect to generalise; the exact split is not a benchmark. Run the same split on your own rounds before acting on it.

Pacing, as a rule you can write down

With a reading available, "do not run out" stops being a vibe and becomes arithmetic. The rule we run is deliberately boring:

Let f be the fraction of the weekly window that has elapsed, R the share you are reserving, and S a small slack that permits getting ahead. The pace line is min(1 − R, (1 − R)·f + S). If weekly utilisation is above the line, the unattended process sleeps until the line catches up with it. If it is at or above 1 − R, it waits for the window to reset outright.

Three properties matter more than the formula:

  • Reserve a share for the human, and never pace the human. This is the whole point. An unattended process and a person on the same account are competing for one pool, and only one of them can be told to wait politely. Ours reserves 20% of the week and 15% of the five-hour window.
  • No reading means run normally, not stop. If the gauge is missing, older than 30 minutes, or not in the 0–1 range we expect, the pacer returns "cannot measure" and the work proceeds. A safety mechanism that fails closed on a parsing change is an outage you built yourself.
  • Being throttled should be visible. When the pacer holds work back it sends one message: "paused on pace — %X of the week used, line at %Y." That message is the early warning that a week is burning faster than it should — which is the signal we did not have, and the reason the limit arrived without notice.

And the correct response to hitting the wall is to back off to the reset moment and stop. Not to retry, not to split the work across anything. If you are consistently short, the honest options are to do less per round, carry less context, or pay for more capacity.

How to tell whether this is working

We wrote the disconfirming conditions down before we could be tempted to reinterpret them:

  • A week passes, weekly utilisation never exceeds 80%, and the human is never blocked → the pacing is doing its job.
  • The human is blocked again → check whether they burnt it themselves (the pacer does not, and should not, throttle people) or the unattended side crossed the line, which would mean the design is wrong.
  • The agent spends more than half the week paused while >30% of the window is still unused on the last day → the line is too conservative; adjust the reserve, not the concept.
  • No [quota] line appears in the log for 24 hours → the event shape changed. Then the job is to go find the reading again, not to increase the backoff.

The general form: if the broken world and the working world produce the same log output, you do not have a monitor. We had four alerts, a dashboard and a guard watching this, and the only one that was right was the one that read the actual error text.


We are a two-extension shop that runs an unattended agent on a small always-on box, and we write up what it costs us. Custom Keyboard Shortcuts rebinds browser actions to keys you choose, per site or everywhere, and is free on the Chrome Web Store. Highlight Reader reads a page aloud and highlights each sentence as it speaks; it is also free.

The other half of shipping one of these is the store, not the code: the slot cap on a new account, the listing text going read-only mid-review, the uninstall URL you cannot change after you ship. The ten that caught us — three of them in full, no signup.