DEV Community

Cover image for How I Predict Claude Usage Limits Without Scraping the Page
Rehber Odhano
Rehber Odhano

Posted on Edited on AI-assisted

How I Predict Claude Usage Limits Without Scraping the Page

A usage percentage tells you where you are. It doesn't tell you whether you're in trouble.

What I actually wanted to know was this:

At my current pace, am I going to hit the limit before it resets?

62% an hour into a window and 62% shortly before the reset are very different situations, and Claude only shows you the number. So I built Claude Usage Companion, an open-source, local-first browser extension that adds the missing context.

It's at v0.3.2 now, but the engineering problem hasn't changed: how do you turn usage snapshots into something you can act on, without scraping Claude's UI or sending your data to yet another backend? This post is about how I approached that.


The percentage wasn't enough

Claude's paid plans have two limits: a 5-hour session window and a 7-day weekly window. Both show up as percentages under Settings → Usage.

On its own, a percentage doesn't say much. Compare these two:

Situation A                          Situation B
Session usage:    62%                Session usage:    62%
Time into window: 1 hour             Time into window: 4 hours 30 minutes
Time until reset: 4 hours            Time until reset: 30 minutes
Enter fullscreen mode Exit fullscreen mode

In A you've burned a large share of the allowance very early, and if that pace holds you'll hit the limit long before the reset. In B the number is identical, but you're almost at the reset and probably fine.

So instead of just "Session usage: 62%", I wanted the extension to say something like this, and only when it's true:

At this pace, session runs out ~Thu 3:40 PM — before it resets.
Enter fullscreen mode Exit fullscreen mode

The percentage tells you where you are. The pace tells you whether to care.


Why I didn't scrape the page

The obvious way to build this is to open the usage page, find the percentage in the DOM, parse the text, and save it. It works until a CSS class changes or a component gets redesigned, and then the extension quietly breaks.

The network responses behind the page are a steadier thing to build on, so the extension watches the responses claude.ai already receives. Three of them matter:

  • /usage, from a background poll or from the page's own request
  • the message_limit event at the end of a chat reply's stream
  • rate_limit_event entries from Claude Code on the web

Here's the whole path, from those three sources to what you see:

  GET /usage ──────────────────┐
  poll, or the page's own call │
                               │     ┌───────────────────────────────┐
  message_limit ───────────────┼───► │ background service worker     │
  end of a chat reply          │     │                               │
                               │     │  1. validate    (zod)         │
  rate_limit_event ────────────┘     │  2. normalize                 │
  Claude Code on the web             │  3. store       (IndexedDB)   │
                                     │  4. forecast                  │
                                     │  5. alert, badge, popup       │
                                     └───────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The page hook only observes. Claude gets its original response, untouched. The background poll is separate: every 5 minutes an alarm requests /usage using your existing claude.ai session. claude.ai's own frontend only asks for that endpoint when you open Settings → Usage, and I didn't want the bars going stale in between.


Chrome's isolated world makes this awkward

Chrome content scripts run in an isolated JavaScript environment. That's good for security, but it means a normal content script can't replace the page's window.fetch. To watch Claude's network calls I need two small scripts:

 claude.ai tab
┌───────────────────────────────────────────────────────┐
│                                                       │
│  MAIN world (the page's own realm)                    │
│  ┌─────────────────────────────────────────────────┐  │
│  │ hook                                            │  │
│  │  · wraps window.fetch                           │  │
│  │  · reads a clone, the page gets the original    │  │
│  │  · drops everything except usage events         │  │
│  └───────────────────────┬─────────────────────────┘  │
│                          │ window.postMessage         │
│  ISOLATED world          ▼                            │
│  ┌─────────────────────────────────────────────────┐  │
│  │ relay                                           │  │
│  │  · has browser.runtime access                   │  │
│  │  · passes payloads along, no parsing            │  │
│  └───────────────────────┬─────────────────────────┘  │
│                          │                            │
└──────────────────────────┼────────────────────────────┘
                           │ runtime message
                           ▼
                background service worker
Enter fullscreen mode Exit fullscreen mode

The hook is small on purpose, and it has one rule: a bug in my extension must never break claude.ai. Simplified, it looks like this:

const originalFetch = window.fetch.bind(window);

window.fetch = async (...args) => {
  const response = await originalFetch(...args);

  try {
    void handleResponse(
      response.clone(),
      getRequestUrl(args[0])
    );
  } catch {
    // Losing a usage sample is acceptable.
    // Breaking Claude is not.
  }

  return response;
};
Enter fullscreen mode Exit fullscreen mode

Note the response.clone(). The page gets the original, and the extension reads the copy.


Some of the data arrives inside a stream

Chat replies are streamed, and the message_limit event I want is buried near the end of that stream. So it isn't a simple await response.json(). The body is a ReadableStream, and the extension has to read chunks and rebuild complete SSE frames from them:

const reader = response.body?.getReader();

if (!reader) {
  return;
}

const frames = new SseFrameBuffer();

for (;;) {
  const { done, value } = await reader.read();

  if (value) {
    for (const frame of frames.push(value)) {
      handleFrame(frame);
    }
  }

  if (done) {
    for (const frame of frames.flush()) {
      handleFrame(frame);
    }

    break;
  }
}
Enter fullscreen mode Exit fullscreen mode

The buffer matters because network chunks don't respect message boundaries. One SSE event can be split across two chunks, or several can arrive in one, so parsing line by line straight off each chunk isn't reliable.


Throwing away what I don't need

Some of these responses carry far more than usage data, and I decided early that being able to see something isn't a reason to keep it.

Claude Code on the web is the clearest case. Its events endpoint returns user and assistant entries alongside the rate_limit_event entries I'm after. The filter runs inside the page's own context, before postMessage fires, so conversation text never reaches the extension at all, not even briefly. The chat stream gets the same treatment: only message_limit frames are forwarded, and the rest is ignored.

I don't want this to become a conversation recorder. Keeping that boundary narrow also makes the privacy story easy to explain.


Treating undocumented data as unstable

None of this was designed as a public API, so I assume it can change under me. Fields appear and disappear, objects turn into null, new keys show up. Every payload goes through runtime validation before anything is stored:

const result = UsageSchema.safeParse(payload);

if (!result.success) {
  logValidationFailure(result.error);
  return;
}

const normalized = normalizeUsage(result.data);

store(normalized);
Enter fullscreen mode Exit fullscreen mode

(Those names are illustrative, not the real identifiers.) A few rules came out of this:

  • Unknown fields don't invalidate a payload. A new key in Claude's response isn't a reason to throw it away.
  • Optional means optional. If I haven't seen a field in every capture, nothing depends on it being there.
  • Bad input gets skipped, not guessed at. I'd rather show nothing than a confident wrong number.

Defensive parsing makes the integration more resilient. It doesn't turn an undocumented interface into a stable one, and if the format changes enough, the extension will go quiet until I ship a fix.


The forecast is boring math

Once I had a history of snapshots, I needed to estimate where the current run was heading. I thought about something fancier and decided against it. The forecast fits a straight line (ordinary least squares) through recent samples and solves for where it reaches 100%:

 100% │                                 ·
      │                              ·
  80% │                           ·
      │                     ·  ·
  60% │                  ·
      │               ·
  40% │         ●  ●
      │      ●
  20% │   ●
      │●
   0% │
      └──────────────────────────────────────▶ time
Enter fullscreen mode Exit fullscreen mode

The filled dots are real samples, and the small dots are the line projected forward until it hits the top.


Resets break a naive fit

Windows reset, and when they do the percentage falls back toward zero. A history might look like 72%, 79%, 86%, then 4%, 10%, 18%. A single line fitted across the whole history would be nonsense, because the reset breaks it in the middle. The fit has to start after the drop, and the projection should come only from the new run:

 100% │        ┊                                          ·
      │      ● ┊                                       ·
  80% │   ●    ┊                                    ·
      │●       ┊                              ·  ·
  60% │        ┊                           ·
      │        ┊                     ·  ·
  40% │        ┊                  ·
      │        ┊               ·
  20% │        ┊         ●  ·
      │        ┊      ●
   0% │        ┊   ●
      └────────────────────────────────────────────────────────▶ time
               reset
Enter fullscreen mode Exit fullscreen mode

So only the current run counts. Starting from the newest sample, walk backward until the percentage drops:

let runStart = sorted.length - 1;

for (let i = sorted.length - 1; i > 0; i--) {
  if (sorted[i].percent < sorted[i - 1].percent) {
    break;
  }

  runStart = i - 1;
}

const currentRun = sorted.slice(runStart);

if (currentRun.length < 2) {
  return null;
}
Enter fullscreen mode Exit fullscreen mode

That way the fit only sees samples from the current window. (In the real code this splitting rule lives in one shared helper, because anomaly detection needs the same reset boundaries.)


Why not something smarter?

Because real usage is lumpy. You prompt hard for twenty minutes, read code for forty, sit in a meeting, then start a big session. Nothing inside the extension knows what you'll do thirty minutes from now, and a fancier model would mostly add the look of precision without knowing any more.

That's why the wording is always "at your recent pace". The forecast doesn't predict what you'll do, it extrapolates what you've already been doing.


Confidence over precision

A forecast built on two samples shouldn't look as trustworthy as one built on a longer history, so every forecast carries a deliberately cheap confidence label:

  • low: fewer than 3 samples in the current run
  • medium: 3 or more samples
  • high: 5 or more samples spanning at least an hour

It's a rough proxy for how much to trust the line, not a statistical confidence interval. It's also why I never show "You WILL run out at exactly 3:47 PM". Something like "session runs out ~Thu 3:40 PM — before it resets" gives you what you need to make a call without pretending to know the future.


Knowing when to stay quiet

This ended up mattering as much as the math. If an extension warns you all the time, you stop reading the warnings, so a forecast shouldn't automatically become an alert. The popup headline and the opt-in pace notification only fire when all three of these hold:

  • the bar is at least 10% used (a forecast of "limit in 141 hours" at 3% used is noise)
  • the forecast has medium or high confidence
  • the projected run-out lands at least 30 minutes before the window resets

So a projection of 4:58 PM against a 5:00 PM reset gets no drama, since that's effectively fine. A projection of 2:40 PM against the same reset does.


Sustainable pace

Forecasting asks when you'll hit the limit at your recent pace. There's a second useful question: how fast can you use what's left and still make it to the reset?

Say you have 66% remaining and 5 hours until reset. This one isn't a forecast, it's plain arithmetic on two numbers Claude reports:

(100 − percent used) ÷ hours until reset
Enter fullscreen mode Exit fullscreen mode

Here that's 66 ÷ 5 ≈ 13% per hour, the rate at which you'd use up the remaining allowance exactly as the window resets. The extension compares it with your recent pace, but only when the forecast is medium or high confidence:

Recent pace:       high
Sustainable pace:  lower
Enter fullscreen mode Exit fullscreen mode

You don't need to read a chart to see that you're burning faster than you can afford. This is part of the current version as well.


Account usage and local Claude Code activity aren't the same data

The browser can see account-level usage. People also asked what they were doing in Claude Code that was contributing to it, and a browser extension can't look inside your terminal. Claude Code does keep session data locally, though, so Claude Usage Companion has an optional local daemon that reads it:

 your browser                             your machine
┌──────────────────────────┐            ┌─────────────────────────────┐
│ extension                │  HTTP to   │ daemon (Node + Hono)        │
│  · usage history         │  127.0.0.1 │  · reads Claude Code's      │
│  · forecasts, dashboard  │ ─────────► │    session logs             │
│                          │  + token   │  · shells out to            │
│                          │ ◄───────── │    ccusage --offline        │
└────────────▲─────────────┘            └──────────────┬──────────────┘
             │ network capture                         │ read-only
             │                                         ▼
         claude.ai                      ~/.claude/projects/**/*.jsonl
Enter fullscreen mode Exit fullscreen mode

The daemon can derive projects, models, sessions, token activity and subagent activity. What it can't do is tell you how that activity relates to your account totals, and I don't want the UI to imply otherwise.

Correlation isn't causation

Suppose weekly usage jumps, and around the same time the daemon sees a big local Claude Code session. It's tempting to blame the session. But you may also have used claude.ai in the browser, used Claude somewhere else, switched models, or generated more output in that window. The data supports "this local activity happened then", not "this caused the jump".

So the UI treats three kinds of values differently:

Reported by Claude    session usage, weekly usage, reset times
Observed locally      Claude Code projects, models, sessions, subagents, token activity
Derived               forecast, sustainable pace, anomaly detection, contribution estimates
Enter fullscreen mode Exit fullscreen mode

They don't have the same provenance, so they shouldn't look like they do.


Localhost isn't automatically private

It's easy to assume that anything on 127.0.0.1 is private, but any website you open can try to send requests to services listening on localhost. So the daemon doesn't lean on the loopback address alone. Every request has to get past a few checks, in this order:

 request                                             result
 ─────────────────────────────────────────────────   ───────────────────────
 a website (http/https origin), even with a token    403 forbidden_origin
 anyone, no token                                    401 unauthorized
 your extension + token                              ok
 curl or the statusline script (no Origin) + token   ok
Enter fullscreen mode Exit fullscreen mode

The full list of protections:

  • binds to 127.0.0.1 only
  • a bearer token on every route, compared in constant time, with the token file readable only by you
  • any http(s) page origin rejected outright, even with a valid token
  • no unauthenticated routes except /health and the one-time /pair
  • no conversation content in logs

Pairing means you never copy a token by hand. The extension asks the daemon for its token once, right after install, and the daemon then locks that endpoint, so every later request is refused. (If you reinstall the extension, run claude-usage-daemon install again to open a fresh pairing window.)

I kept the daemon narrow. It's stateless: your history lives in the extension's local storage, and the daemon only writes its own token file, a pairing marker, and its background-service registration. It never writes into your projects, and I don't want it to grow into a general-purpose local API server. Features like session search and export do read session content, but only locally and on demand, and nothing is sent anywhere.


The privacy model is boring on purpose

There's no Headroom backend between you and Claude, no telemetry, no product account, and no analytics service receiving your history. The browser side works with Claude data your signed-in session can already see, and the optional daemon works with files on your own machine. Everything it stores stays local. For a tool that watches your usage, I think boring infrastructure is a feature.


What it has become

Early versions already showed the limit bars and a burn-rate forecast. The current version tries to answer more questions:

  • Where am I now?
  • Am I likely to hit the limit before reset?
  • Is my current pace sustainable?
  • Is this usage pattern unusual?
  • What changed recently?
  • Which Claude Code projects and models were active?
  • Which sessions and subagents ran?
  • Can I find and resume an older Claude Code session?

That deserves its own article, so I'm not turning this one into a v0.3.2 changelog. This post is about the foundation those features sit on.


The stack

It's a TypeScript monorepo:

packages/
├── shared/      schemas and shared usage models
├── extension/   WXT + React + IndexedDB
└── daemon/      Node.js + Hono
Enter fullscreen mode Exit fullscreen mode

The extension handles capture, local history, forecasting, alerts and the dashboard. The daemon is optional and only handles local Claude Code visibility. Both live in the same open-source repo.


Try it

Chrome / Edge: Claude Usage Companion is on the Chrome Web Store (Edge can install the same listing):

https://chromewebstore.google.com/detail/chjbjdabpficejgogljohhlobfaehepl

Firefox isn't on a store yet, so it has to be built from source (the repo explains how). After installing, open claude.ai's Settings → Usage page once, or just send a message, so the extension can detect your account. From then on it keeps itself up to date in the background. You'll need an active Claude plan with usage limits, because free accounts have no usage page to read.

Source code (MIT): https://github.com/RehberOdhano/headroom

Optional Claude Code daemon (needs Node.js 20 or later):

npm install -g @rehberodhano/claude-usage-companion-daemon
claude-usage-daemon install
Enter fullscreen mode Exit fullscreen mode

The extension works fine without the daemon. Install it only if you want the local Claude Code features.


What I'd like to hear

This started because I wanted a better answer than "62% used". I wanted to know whether I was actually in danger of hitting the limit before the reset. That question led to forecasting, sustainable pace, anomaly detection, Claude Code attribution, session tools, and a fair number of rabbit holes.

I don't want it to turn into a dashboard full of numbers just because they can be calculated. So if you use Claude regularly:

What's the one piece of usage information you wish Claude showed you directly?

Feedback, bug reports and feature ideas are all welcome on GitHub.


Top comments (1)