Skip to article
← COOKD ENGINEERING

Measuring a limit nobody publishes

We fitted three constants to a rate limit Anthropic does not expose. Then we deleted the constant, and the code learned something its own decision record had ruled out.

SEP 28, 2026 · 11 MIN · COOKD ENGINEERING TEAM · AS OF CLAUDE CODE, JUNE–SEPTEMBER 2026
IN THIS PIECE

On 10 June, Claude Code's /usage command said 51%. The limit constant in our code implied about 15%.

The product was a percentage, and the percentage was off by a factor of three.

cookd tells you how much of your Claude Code usage window you have burned. The stats, the roast and the share card all hang off that one number. A percentage needs a denominator, and ours did not exist anywhere we could read it.

Claude Code enforces a rolling five-hour limit on token usage. When you hit it you get an error and a reset time. The limit itself is not in a config file, not behind an API, and not written anywhere on disk. It varies by plan. It varies by account, because extra credits move it. Anthropic can change it whenever they like, and nothing on your machine will notice.

So the first real engineering problem in this product was measurement: how do you learn a number that the system enforcing it will not tell you? We got it wrong three times in about ninety minutes, and then spent two more days deciding that the right answer was sometimes no number at all.

What is on disk, and what is not

Claude Code writes a JSONL transcript for every session under ~/.claude/projects/. Each assistant turn carries a usage object:

{
  "input_tokens": 3,
  "output_tokens": 850,
  "cache_creation_input_tokens": 30000,
  "cache_read_input_tokens": 1400000
}

Four counters. Newer entries split cache creation into two ephemeral tiers, five-minute and one-hour, when the breakdown is present.

(Illustrative values.) Those numbers are ours to read. What is missing is any statement of what they are counted against. The spike document from 10 June records the search:

No native rate-limit/reset-time file anywhere under ~/.claude. No reliably-persisted 429 event in current transcripts (grep matches were conversation content, not real events).

If the ceiling is not written down, it has to be inferred from the moments the floor hit it. Keep that second sentence in mind. It turned out to be wrong, and nobody went back to fix the document that relied on it.

Constant one: the busiest window we had ever seen

The first formula added the four counters and called the result weighted tokens:

weighted = input + output + cacheCreation + cacheRead

Over the full transcript history on a development machine, the spike found the busiest rolling five-hour window it had ever seen, about 120 million tokens. That window became the limit. The constant went into the code as DEFAULT_LIMIT, labelled a placeholder.

The spike also recorded, in the same paragraph, why the formula was wrong:

cache_read_input_tokens dominates the sum by ~1–2 orders of magnitude and is far cheaper in Anthropic's real unified-limit accounting. So this v0 formula over-weights cached reads and will trip "cookd" later than a real limit.

That is a known defect, shipped on purpose, with the correction named in advance. Adding four integers is the only thing you can do before you have any ground truth.

Constant two: a human reading a screen

Ground truth came 23 minutes after the engine was committed, from the least sophisticated instrument available. /usage shows a percentage. One of us ran it and wrote down what it said: 51%, with a reset time.

At that moment, 95% of the sum-of-four over the current window was cache reads.

Two things fell out of that reading.

The first was a validation we had not expected. Our reconstructed reset time was four minutes later than the real one. The rolling-window model was roughly right, so only the denominator was left to find.

The second was arithmetic. Divide the window's total by 0.51 and the limit comes out at about 36 million, not 120 million. But 36 million contradicted the rest of the data, and the spike says so in the next line:

A historical window of ~120M sum-of-4 tokens (see above) is inconsistent with a 36M limit, confirming sum-of-4 is not a stable metric.

The same machine had logged a 120 million window without being rate-limited, and now implied a 36 million limit. Under a formula where every token counts the same, both cannot be true. The formula was measuring something that was not conserved.

The spike puts the situation in one line:

Two unknowns (cache_read weight + real limit), one equation so far.

Constant three: more equations, same broken unit

Two more readings arrived within the hour. /usage at 86% implied a limit near 38.4 million. And a sentinel we had written to watch for a 429 recorded usage climbing past 38 million while the session was still running, which is a floor under the real limit rather than an estimate of it.

Three points agreed: two percentage readings and a live plateau. Best estimate, 38.4 million. The commit recording it landed an hour and a half after the first engine.

It was a much better constant than 120 million. It was still fitted to one machine, on one plan, over one session, in a unit we had just shown was not conserved.

Changing the unit instead of the number

If the units do not hold, change the units.

Cache reads are cheap and output tokens are expensive. A limit that treats them identically will disagree with any system that does not. If Anthropic's rate limit tracks cost, then weighting each counter by its relative price should produce a quantity that stays the same across sessions of very different shapes.

That is ADR-001, dated the next day, and it is still the formula:

export const TOKEN_WEIGHTS = {
  input:           1,
  output:          4,
  cacheCreation5m: 1.25,
  cacheCreation1h: 2.0,
  cacheRead:       0.1,
} as const;

The weights match Anthropic's relative pricing for each token type, including both cache tiers. An entry that carries only the flat cache-creation field is treated as five-minute tier.

A unit passes if it stays put, not if it produces a nicer number. ADR-001 records that test across multiple confirmed rate-limit events on development machines:

CP values clustering within ~8% of each other at the moment of hitting the limit. S4 values for the same events varied by over 100% (session-composition dependent), making S4 unsuitable as a standalone limit metric.

Under sum-of-four, the reading at which you hit the wall depended on what kind of session you had. Cache-heavy work hit at a completely different number than generation-heavy work. Under cost-proportional weighting, the readings cluster. The spread fell from over 100% to about 8%.

We do not know that these are Anthropic's actual weights, and the repository does not claim they are. They are relative prices that produce a conserved quantity, and the 8% residual is the error bar on that assumption.

Deleting the constant

With a working unit, we had a limit of roughly 4.5 million cost-weighted tokens, measured on a Pro-plan development machine. The obvious move was to ship it as the default and refine it later.

ADR-003 rejected that on 12 June:

Plan contamination — a user on Max plan (5× or 20× higher limit) would immediately get false "near limit" warnings because the bootstrap constant is far below their actual ceiling.

A constant measured on one plan is not roughly right on another. It is wrong by a factor of five or twenty, in the direction that makes the product lie at the moment the user is deciding whether to start another task. Every Max user's first screen would have been a red gauge that meant nothing. The ADR adds a second objection that holds on any plan: Anthropic can change the limits, and a number compiled into a CLI has no way to find out it is stale.

So the companion ships no constant. The limit comes entirely from the user's own transcript history, and when there is not enough history, the companion says it does not know:

adapter.estimatedLimit() returns null until at least one rate-limit event is detected in history. Callers must handle null explicitly.

The status line reads calibrating… instead of a percentage. A new install shows no number until it has earned one.

The options on the table, and why each lost:

  • Keep the 38.4M sum-of-four constant. Measured in a unit that is not conserved.
  • Ship the 4.5M cost-weighted constant as a default. Correct for one plan, wrong by 5× to 20× on the others, and silently stale if Anthropic moves the limit.
  • Ask the user for their plan. Not recorded as considered, but ADR-003's rationale answers it: "Each user's limit is individual (plan + any extra credits)."
  • Show nothing until calibrated. Chosen. It costs the new user a number.

The product is a percentage, and we shipped a state where the percentage is absent. A plausible wrong number would have demoed better and been wrong for every user not on the plan we measured.

Finding a ceiling without knowing where it is

Deriving the limit from history sounds circular. To know which pauses were rate limits, you need the limit. To know the limit, you need to know which pauses were rate limits.

ADR-005 breaks the circle by using no threshold. It uses the shape of the distribution.

A rate-limit stop and a human stop look different. A rate limit happens at the same usage every time, because that is what a ceiling is. Lunch happens at whatever usage you were at. Record the rolling-window value at the start of every pause and sort them, and rate limits pile up at the top while human pauses scatter below it.

The implementation walks every event once with a two-pointer sliding window:

// Evict events outside the 5h window looking back from prev.ts
const cutoff = prev.ts.getTime() - WINDOW_MS;
while (left < i && events[left].ts.getTime() < cutoff) {
  windowCp -= computeWeightedTokens(events[left]);
  windowS4 -= sumS4(events[left]);
  left++;
}

const gapMs = curr.ts.getTime() - prev.ts.getTime();

// Include gaps 10min–5h; cold starts (>= 5h) are excluded
if (gapMs >= MIN_GAP_MS && gapMs < WINDOW_MS) {
  gaps.push({ ts: prev.ts, gapMs, cp: Math.max(0, windowCp), s4: Math.max(0, windowS4) });
}

Recomputing the window at each gap would be O(N²) over a transcript corpus that can run to hundreds of megabytes. This is one pass.

The two boundaries do real work. A gap under ten minutes is thinking, not stopping. A gap of five hours or more tells you nothing, because the window has emptied on its own. The useful band is a pause long enough to matter and too short to be a night's sleep.

Classification is by cluster:

const sorted = [...gaps].sort((a, b) => b.cp - a.cp);
const topCp = sorted[0].cp;

// Gaps within 25% of the maximum form the RL hit cluster
const clusterFloor = topCp * 0.75;
const cluster = sorted.filter(g => g.cp >= clusterFloor);

The calibrated limit is the median of the top 25% band. Median rather than max, so one odd reading cannot move the answer. No number in this path came from anyone else's machine. A Max 20× user's cluster sits wherever their ceiling is, and the algorithm finds it the same way.

The code learned something the decision record did not

A tidier version of this article would stop there.

ADR-005 specifies confidence tiers: one clustered gap gives a low limit at the observed maximum plus 2%, two give medium, three or more give high. The shipped code refuses to answer below three:

// Require 3+ tightly-clustered gap observations before trusting gap calibration.
// A single gap or two gaps can easily be lunch breaks or natural pauses — not
// rate-limit hits. Without sufficient evidence, report null rather than a
// dangerously low number.
if (cluster.length >= 3) {
  const cpLimit = median(cluster.map(g => g.cp));
  return { cpLimit, confidence: 'high', rlHits: cluster };
}

return { cpLimit: null, confidence: 'none', rlHits: [] };

The second divergence is larger. Gap analysis exists because the spike found no persisted rate-limit event in the transcripts. That finding did not hold. Transcripts do carry them, as entries flagged isApiErrorMessage whose text contains a reset time, in two formats depending on version:

// Old format: "Claude AI usage limit reached|<unix_seconds>"
// New format: "You've hit your session limit · resets H:MMpm (Timezone)"

Parse those and you have the moment a real ceiling was hit, on this machine, for this account. Sum the window before each one and you have an observation, not an inference. The shipped calibrate() tries that first and falls back to gap analysis only when the history has no such event:

export function calibrate(events: UsageEvent[]): CalibrationResult {
  const sorted = [...events].sort((a, b) => a.ts.getTime() - b.ts.getTime());

  // Rate-limit events are ground truth — use them first
  const rlResult = calibrateFromRateLimitEvents(sorted);
  if (rlResult.cpLimit !== null) return rlResult;

  // Gap analysis fallback — only trusted with 3+ clustered observations
  const gaps = analyzeGaps(sorted);
  return calibrateFromGaps(gaps);
}

Neither change was in ADR-005. Both are in the companion repository's first commit, so they happened during implementation, after the record was written. The ADR got the reasoning right. Then the code found ground truth the document said did not exist, and moved the statistics behind it.

What it cost

A new user sees nothing. Until the history holds a real rate-limit event or three clustered gaps, there is no percentage. A developer who has never hit the wall can see how many tokens they used but not what fraction that is. The cost of refusing to guess lands entirely on the people with the least data.

Plan detection is a guess with wide bands. ADR-009 infers the plan from the calibrated limit, using thresholds the document calls working estimates: confirmed for Pro, unconfirmed above it. It is a heuristic by design, written to be replaced by better data rather than defended.

The weights are an assumption. Relative prices, a conserved quantity, about 8% spread. Not Anthropic's numbers, because Anthropic has not published theirs.

That last one mattered later. The server first stored a pre-weighted total per user. When sync was rebuilt in July, usage went up as raw components instead (input, output, two cache tiers, cache read), so re-tuning the weights corrects all of history rather than needing a migration that cannot recover what was thrown away. Weeks later a paired reading showed the calibrated limit 2.15× too high, and that storage change is why the fix is a constant. That is the sequel to this article.

What we would do differently

  • Test the unit before fitting the number. Three constants in ninety minutes were all fitted in a unit that the second reading had already shown was not conserved. One question up front, "is this value the same every time we hit the wall?", would have skipped two of them.
  • Re-read the decision record when the premise changes. The whole of ADR-005 rests on "no persisted rate-limit event". When the code found one, nothing forced anyone to reopen the document. An ADR's stated premises belong next to the code that can falsify them, so that a pull request touching calibrateFromRateLimitEvents puts ADR-005 in front of its reviewer.
  • Put the null in the type on day one. estimatedLimit(): number | null is the line that made calibrating… possible. Every caller has to decide, at compile time, what to show when the answer is unknown. A default would have been reached for eventually without it.

The quantity, not the constant

Every constant we fitted was correct about the machine it came from and wrong about everyone else's. What worked was not a better number. It was finding the unit in which the limit holds still, and then letting each user's own history supply the number in that unit.

If you are measuring something the system will not tell you, ask whether your unit is conserved before you ask what the value is.

NEXTEvery number in this system is a claim →

cookd is the app you can only open when Claude has rate-limited you. What is cookd →

PUBLISHED BY THE COOKD ENGINEERING TEAM