A few weeks ago I was in the middle of a refactor with Claude when the reply just stopped: "You've reached your session limit. Resets at 5:00 PM."
I had no idea I was close.
That's what bugged me. Claude's paid plans have two limits, a 5-hour session window and a 7-day weekly one, and claude.ai and Claude Code draw from the same pool. The numbers are sitting right there under Settings β Usage. But nothing ever says "at this rate you're done in 40 minutes." You find out when you hit it.
So I built a browser extension that tells you. It's called Claude Usage Companion. It's free, it's open source, and nothing leaves your machine. This post is about the parts that turned out more interesting than I expected, most of which come down to building on an API that isn't documented and doesn't sit still.
A percentage on its own isn't that useful. 62% an hour into the window and 62% four and a half hours in are completely different situations. One means slow down, the other means you're fine.
What I wanted was a single line:
At this pace, session runs out ~3:50 PM, before it resets.
If I see that, I can do something about it. Switch to a lighter model, finish the risky part of the task first, or go get lunch. Most of the extension exists to make that one line trustworthy.
There's no public API for this, so I opened DevTools on claude.ai and just used it for a while. Later I had Claude in Chrome drive a real session and capture the responses, so I'd have clean fixtures to build schemas from. Three sources turned up.
The first is GET /api/organizations/{org_id}/usage. It's what the Settings β Usage page calls: a five_hour and a seven_day window, each with a utilization percent and a reset time, with plain cookie auth. The catch is that claude.ai's frontend only calls it from that settings page. If you never open it, your browser never sees the data.
The second is hiding in the chat stream. When you send a message, the reply streams back as server-sent events, and near the end, between message_delta and message_stop, there's a message_limit event with the same two windows as fractions:
{
"type": "within_limit",
"representativeClaim": "five_hour",
"windows": {
"5h": {
"status": "within_limit",
"resets_at": 1788007200,
"utilization": 0.29
},
"7d": {
"status": "within_limit",
"resets_at": 1788008400,
"utilization": 0.55
}
}
}
The third is on claude.ai/code, where Claude Code sessions show up on the web. The session event log has rate_limit_event entries mixed in with everything else.
So there's a snapshot I can request whenever I want, and live updates that arrive with every reply while you're actually working. I use both.
A lot of usage trackers read numbers out of the page's HTML. That works until the frontend gets redesigned, which happens often. The JSON behind the page changes less, so I capture that instead.
The awkward part is Chrome's content script model. By default a content script runs in an "isolated world": it can see the DOM but not the page's JavaScript, so it can't see the page's fetch calls. To wrap fetch, a script has to run in the page's main world, and main-world scripts can't talk to the extension. So there are two scripts: one in the main world that captures, and one in the isolated world that just passes messages along.
claude.ai tab
ββββββββββββββββββββββββββββββββββββββββββββββββ
β MAIN world: hook β wraps window.fetch,
β /usage, completion SSE, code events β reads a *clone*
β β window.postMessage β
β ISOLATED world: relay ββββββββββββββββββββββββΌββ> runtime message
ββββββββββββββββββββββββββββββββββββββββββββββββ β
βΌ
background service worker
validate β normalize β IndexedDB
forecast, alerts, badge
The hook is short. The one rule I cared about is that it can never break claude.ai:
const originalFetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const response = await originalFetch(...args);
try {
// Read a clone. The page gets the original, untouched.
void handleResponse(response.clone(), getRequestUrl(args[0]));
} catch {
// A bug here can lose a data point.
// It must never affect the page.
}
return response;
};
The chat stream isn't an EventSource. claude.ai calls fetch() and reads the body as a ReadableStream, so if you go looking for EventSource you'll find nothing. You have to read the cloned body yourself and split it into SSE frames, keeping a buffer because a frame can arrive split across two chunks:
const reader = response.body.getReader();
const sse = new SseFrameBuffer();
for (;;) {
const { done, value } = await reader.read();
if (value) {
for (const frame of sse.push(value)) handleFrame(frame);
}
if (done) {
for (const frame of sse.flush()) handleFrame(frame);
return;
}
}
// handleFrame ignores everything except 'message_limit'
The Claude Code event log needed more care, because most of what's in it is your actual conversation: user and assistant messages. The hook throws away everything except rate_limit_event before anything leaves the page, so conversation text never reaches the extension's storage at all.
Then there's the settings-page problem. Once the extension has seen your org ID in a usage or chat request, the background worker calls /usage itself every 5 minutes, using the session cookie you already have. That way the bars stay fresh even if you never open Settings again.
I captured the same account's /usage response twice, three days apart. In between, a locked_reason field appeared on each window that hadn't been there before, not even as null. A new top-level key showed up too, always null so far. And some sub-objects, like the spend limit, flipped between a full object and null depending on whether usage credits were turned on.
If I'd written a strict schema from the first capture, it would have accepted the second one fine. If I'd written it from the second, it would have rejected the first. So I settled on three rules:
zod's safeParse. If a payload doesn't match, it gets logged and skipped. Nothing throws.
This protects against frontend redesigns, not against the API itself changing. If the response format changes enough, the extension stops updating until I ship a fix. I'd rather it go quiet than show wrong numbers.
It's least squares through recent snapshots. The only real decision is which snapshots to use.
A window resets: the percentage drops back toward zero. Fit a line across that drop and the result is garbage. So I only use the current run. Walk backward from the newest point and stop at the first place the percentage went down:
// history sorted oldest β newest
let runStart = sorted.length - 1;
for (let i = sorted.length - 1; i > 0; i--) {
// a drop means the window reset
if (sorted[i].percent < sorted[i - 1].percent) break;
runStart = i - 1;
}
const run = sorted.slice(runStart);
if (run.length < 2) return null; // can't fit a line to one point
After that it's ordinary least squares on hours-since-run-start against percent, solved for where the line reaches 100. Confidence is just sample size: fewer than 3 points is low, 3 or more is medium, and 5 or more spread over at least an hour is high. It's not a real confidence interval, and the code comments say so.
I didn't try anything smarter because usage is bursty. You prompt hard for 20 minutes, then read code for 40. Nothing I could build would predict when you'll open the next chat. A straight line answers the question you actually have ("at the pace I've been going, do I make it?"), and when it's wrong you can see why.
One edge case: a tiny but positive rate, where usage is barely moving, projects the "full" time thousands of years out, past what a JavaScript Date can hold. new Date() doesn't throw on that; it quietly gives you an Invalid Date, and then toISOString() does throw. That would crash the dashboard, so there's a check for it now.
This turned out to matter more than the math. A warning that fires all the time gets ignored.
The popup only shows a headline when something needs attention: a limit you've already hit, or a forecast that says you'll run out early. For the forecast to count, three things have to be true. It has at least medium confidence. At least 10% of the window is used, because 2% in the first five minutes technically projects to "full in four hours," which is useless. And the projected run-out lands at least 30 minutes before the reset; if it only beats the reset by a few minutes, it's not worth interrupting you. Otherwise the popup just shows the bars.
There's also an optional notification for the same forecast, off by default in Settings. It fires once per limit window. Deduplicating that was fiddlier than I expected: /usage and the chat stream report the same reset time with slightly different precision, so the dedupe key rounds the reset time to the hour. Otherwise the same warning would fire twice. Separately, plain threshold notifications at 80% and 95% are on by default.
A browser can't see your terminal. But Claude Code keeps session transcripts in ~/.claude/projects/**/*.jsonl, with token usage and the model on each response and the working directory on each line. So there's an optional local daemon, a small Node + Hono server, that reads them. It uses ccusage (in --offline mode, so it never fetches pricing data) for token totals by project and model, and reads the transcripts directly for full-text search across all your sessions, with a copy-paste claude --resume <id> command. It also warns you before Claude Code's default 30-day cleanup deletes an old session, with a one-click markdown export.
The part I was most curious about was how much of my weekly limit was the CLI versus chat. Nothing tells you. My estimate is crude:
tokens per 1% of weekly β CLI tokens in the window
/ how much the weekly % went up
With that ratio you can turn a project's token count into something like "~15% of this week." It has a real flaw: if you also chat on claude.ai during the same period, that usage gets counted as CLI, which inflates the ratio. There's no way to separate the two with this data, so the UI calls it a rough estimate right next to the number.
There's also a statusline script for Claude Code's statusLine setting. Claude Code already passes rate_limits.five_hour and rate_limits.seven_day to statusline scripts on stdin (on Pro/Max, after your first message), so the session and weekly percentages work without the daemon at all. If the daemon is running, it adds today's token count.
It's easy to assume a server on 127.0.0.1 is safe. It isn't, automatically, because any website you visit can fetch('http://127.0.0.1:4317/...'). So the daemon only binds to 127.0.0.1, rejects any request whose Origin isn't a browser extension, and wants a bearer token on everything except /health and the one-time /pair endpoint. Requests with no Origin at all, like the statusline script, get through the Origin check but still need the token.
Pairing used to mean copying a token out of the terminal and pasting it into the options page, which is a lot to ask on a first run. Now claude-usage-daemon install generates a fresh random token, stores it in a 0600 file, and opens /pair, which hands the token out exactly once. The extension checks it every minute, grabs the token, and from then on /pair returns 403 until you run install again. Anyone on your machine right after install could technically grab it first, but that was already true of copy-paste.
console.debug, and Chrome hides "Verbose" messages by default. For a while I thought my hook wasn't running at all.storage permission and nothing used it. Everything is in IndexedDB, which doesn't need a permission. It was a leftover from the project template.host_permissions, even though Chrome accepts one when you load the extension unpacked. So the daemon's port, 4317, is hardcoded.EventSource can't set an Authorization header, so that one route also accepts the token as a query parameter.
It can't see the Claude desktop app, though that usage still shows up in the shared session and weekly totals. API console usage is out of scope. Per-model limits aren't shown, because none of the responses I've captured have included them. And everything depends on endpoints Anthropic never promised to keep stable. That's the real risk, and careful parsing doesn't remove it.
npm install -g @rehberodhano/claude-usage-companion-daemon
claude-usage-daemon install
After installing the extension, open claude.ai once (Settings β Usage, or just send a message) so it can find your account. After that it updates on its own.
It's a side project, not affiliated with Anthropic. If the first-run setup confuses you, or the forecast is way off for how you work, I'd like to hear about it, in the comments here or as a GitHub issue.