I maintain Claude Monitor, an open-source browser extension that shows claude.ai's usage limits: the rolling 5-hour session and the weekly limit, with their reset times. It gets those numbers from two places, and in October one of them changed shape. This post covers what changed, how the extension reads the new format, what it leaves alone, and two quirks of the older source that matter if you build anything on these numbers.
None of this is a documented API. Everything below comes from my own accounts and from the extension's source, and it describes claude.ai as of mid-October 2026.
Two sources for the same numbers
The first source is the JSON endpoint behind claude.ai's usage settings page:
GET https://claude.ai/api/organizations/{org_id}/usage
It returns one object per limit (five_hour, seven_day and the per-model weekly caps), each with a utilization and a resets_at. Since around July 2026 it also returns a limits array, which is what the settings page renders. On paid plans this endpoint is all you need. On Free accounts it answers with every field set to null (I first saw that in August 2026), so for Free the limits have to come from somewhere else.
The second source is the reply itself. When a reply finishes, claude.ai sends the current limit windows along with it. On Free this is the only place the numbers show up. On paid plans the extension uses it mostly as a signal that a reply just finished, so it knows when to read /usage again.
Two quirks of /usage
utilization is a whole number. It comes back as an integer from 0 to 100. Light use reads 0 for a long stretch, and a chart of it moves in steps. Anything that estimates a rate from it needs several points of movement before the estimate means much.
resets_at jitters. Each read returns a slightly different reset time for the same window, sometimes earlier and sometimes later, on every bucket at once. These are the deltas I measured between consecutive polls on August 1, 2026:
+0.051 s -0.274 s -0.018 s +0.909 s -1.088 s
The window itself doesn't move; this is noise. But code that reads "the reset time changed" as "a new window started" fires on every poll. I shipped exactly that bug: the extension's history sampler compared reset times for inequality, saw a new window on every poll, and wrote a sample every 5 minutes instead of at most one every 10 (fixed in v1.12.2).
The fix is to define a rollover as the previous window having ended:
// prev and next are stored samples; reset times are epoch ms and next.t is "now".
function resetWindowChanged(prev, next) {
return HISTORY_BUCKETS.some((key) => {
const before = Number(prev?.buckets?.[key]?.reset ?? NaN);
const after = Number(next.buckets[key].reset ?? NaN);
if (!Number.isFinite(before) || !Number.isFinite(after)) return false;
return after > before && before <= next.t;
});
}
For alerts that should fire once per window, the extension keeps the first reset time it saw as the window's id and treats any reset within 60 seconds of it as the same window. Keeping the stored id, instead of overwriting it on each poll, stops the jitter from adding up.
Before: server-sent events
In the classic chat interface, each reply is a POST to
/api/organizations/{org_id}/chat_conversations/{chat_id}/completion
answered with text/event-stream. Near the end of the stream there is one data: line with the limits. Its shape, trimmed:
data: {"type":"message_limit","message_limit":{..., "windows":{"5h":{"status":..., "resets_at":<unix seconds>, "utilization":<0 to 1>}, "7d":{...}}}}
The units differ from /usage: here utilization is a fraction from 0 to 1, and resets_at is a Unix timestamp in seconds instead of an ISO string. One more detail: a window that has run out reports "status": "exceeded_limit" while its utilization can still be below 1, so the extension shows 100% for that status instead of trusting the number.
The extension finds that line without decoding the rest of the stream. It splits the bytes on newlines, and only a line that starts with data: and contains the bytes "message_limit" gets turned into text and parsed.
After: Connect frames on StreamTimeline
In claude.ai's newer interface, which my Max account had by October 14, there is no /completion request. Sending a message is a POST to
/claudeai-rpc/anthropic.bard.api.v1alpha.ConversationService/PerformAction
and the reply arrives on a separate stream:
/claudeai-rpc/anthropic.bard.api.v1alpha.ConversationService/StreamTimeline
content-type: application/connect+proto
The first public report of this that I found was issue #59 on she-llac/claude-counter, opened on October 11.
This is the streaming format of the Connect protocol. The body is a sequence of frames, each with a 5-byte header (one flag byte and a 4-byte big-endian length) followed by the payload. Flag 0x01 means the payload is compressed, and 0x02 marks the end-of-stream frame, which carries JSON metadata.
What I saw: the reply's content comes in compressed frames, and the limits come in one small uncompressed frame, 119 bytes including the header. Decoded without a schema, its payload looks like this:
1 {
10 { 1: "1791954046103-0" }
11: 1
4 {
1: 1
5: 0
6: "org_level_disabled"
9: "five_hour"
13 { 1: "5h" 2 { 1: 1 2 { 1: 1791969600 } 3: 0.33 } }
13 { 1: "7d" 2 { 1: 1 2 { 1: 1792404000 } 3: 0.24 } }
}
}
The limit windows are the two field-13 entries. These are the bytes of the first one:
6a 19 field 13, length-delimited, 25 bytes
0a 02 35 68 field 1: "5h"
12 13 field 2, 19 bytes
08 01 field 1: 1
12 06 field 2, 6 bytes
08 c0 92 bd d6 06 field 1: 1791969600 (2026-10-14 09:20:00 UTC)
19 1f 85 eb 51 b8 1e d5 3f field 3, double: 0.33
My reading, which is inference and not documentation: field 13 is what a protobuf map<string, Window> looks like on the wire (each entry is a small message with the key in field 1 and the value in field 2), and {1: 1791969600} is a google.protobuf.Timestamp whose nanos is zero and therefore left out. So the JSON windows object seems to have become a map with the same "5h" and "7d" keys, each holding a status, a timestamp and the utilization as a double from 0 to 1. I don't use the other fields.
A few things follow for a parser:
- proto3 does not serialize a double equal to 0. A window at 0%, such as a weekly window that has just reset, arrives without field 3, so a missing utilization next to a valid reset means 0, not "unknown".
- In this frame both utilizations are still whole percentages (0.33 and 0.24), so the double adds no resolution over
/usage. Both resets also fall on a round minute, with no sub-second noise. That's a single captured frame, so I can't say yet whether the stream's reset times jitter the way/usage's do. - The path says
v1alpha. Field numbers can change without notice.
How the extension reads it
The reader is extension/page/stream.js, about 450 lines with no dependencies. It only runs after you turn on the extension's on-page bar. The extension then registers it with chrome.scripting.registerContentScripts in the page's MAIN world at document_start, so it wraps window.fetch before claude.ai's own code runs:
const previousFetch = window.fetch; // chain onto whatever is installed now
window.fetch = function (input, init) {
const pending = previousFetch.apply(this, arguments);
const kind = classify(input); // same origin and one of three path patterns, else null
if (!kind) return pending;
return pending.then((response) => {
const scan = scannerFor(kind, response.headers.get('content-type') || '');
if (scan) scan(response.clone()).catch(() => {}); // read a copy in the background
return response; // the page gets the original
});
};
(The real wrapper also notes when a PerformAction request goes out, by its URL, and returns right away without touching it.)
The frame loop reads each 5-byte header and decides from the flags alone:
const flags = head[0];
const length = head[1] * 0x1000000 + (head[2] << 16) + (head[3] << 8) + head[4];
if (length > MAX_FRAME) { reader.cancel().catch(() => {}); return; } // 4 MB
if (flags & 0x02) { skip = length; continue; } // end of stream: not read
if (flags & 0x01) { sawReply = true; skip = length; continue; } // compressed: dropped unread
if (flags !== 0x00) { skip = length; continue; }
frame = { length }; // plain frame: collect it
A skipped frame is dropped from a queue of incoming chunks by its length, so a compressed frame is never decompressed or even joined into one buffer.
For plain frames the parser does not rely on field numbers, since an alpha API can renumber them. It works in three steps:
- A quick test on the raw bytes: does the payload contain
0x02 '5' 'h'or0x02 '7' 'd', a length-2 field holding a label? If not, the frame is ignored. - A schema-free walk that splits the bytes into fields by wire type and keeps only offsets. A window is any message that directly holds a 2-byte length-delimited field equal to
"5h"or"7d". - Inside a window, the utilization is the first double between 0 and 1.5, and the reset is the first integer that reads as a Unix time between one hour ago and eight days ahead.
// Inside one window (its label already taken out of `list`)
for (const f of list) {
if (f.wire === 1 && hit.utilization === null) { // 64-bit field: a double
const value = new DataView(bytes.buffer, bytes.byteOffset + f.start, 8).getFloat64(0, true);
if (Number.isFinite(value) && value >= 0 && value <= 1.5) hit.utilization = value;
} else if (f.wire === 0 && hit.resetsAt === null) { // varint: maybe a timestamp
if (f.value > nowS - 3600 && f.value < nowS + RESET_AHEAD_S) hit.resetsAt = f.value;
} else if (f.wire === 2 && depth < MAX_DEPTH) { // nested message: look inside
const sub = fields(bytes, f.start, f.end);
if (sub) collect(bytes, sub, depth + 1, hit, nowS);
}
}
One more wrinkle. A stream that reopens (for example when you reload, or reopen a chat from the sidebar) replays the timeline, limits frame included, without anything being sent. To count messages, the extension treats a limits frame as a new reply only when a PerformAction request from the same page came first and compressed reply frames followed it. It also keeps a short FNV-1a hash of each limits payload, so a replayed frame isn't reported twice.
What it does not read
- Request bodies. A send is recognized by its URL only.
- Compressed frames, which carry the reply. Only their 5-byte header is read.
- Any other byte as text. In the protobuf path, labels are compared as bytes and only numbers come out. In the SSE path, the one
data:line that contains"message_limit"is decoded as JSON. The only body parsed whole is the small JSON error claude.ai returns instead of a stream when a send is refused at the limit; it carries the same limit payload and no conversation content. - Anything past 8 MB per response or 4 MB per frame: reading stops there.
What leaves the script is the utilization and reset time of the two windows, plus a little bookkeeping (the frame hash, and whether a send came first). It goes by window.postMessage, same origin, to the extension's own content script on the page, then to its service worker, which keeps it in chrome.storage.local. For a messages-left estimate it stores the time, utilization and frame hash of the last 20 replies.
One caveat, to be clear about where these limits come from: they are a property of the code, not of the browser. A script in the page's main world can see whatever the page fetches, and Chrome's warning for the extension says "Read and change your data on claude.ai" because host permissions can't be narrowed to paths. The source is unminified with no build step, and each release has GPG-signed checksums, so you can check that what you installed matches the repository. SECURITY.md has the details, including a console snippet that shows what Chrome actually grants.
What I haven't verified
- I verified the new transport with a Max account only. My Free test account was still on the classic interface when I tested on October 14, and there the SSE path works. I don't know yet whether Free accounts on the new interface get the same limits frame. If you have one and the numbers look wrong, an issue with what you see would help.
- The protobuf layout comes from one captured frame plus live tests on one account.
- Field meanings beyond the labels, the timestamp and the double are guesses.
If you parse these numbers yourself
- Compare reset times with a tolerance, and detect a new window by the old reset having passed, not by the value changing.
- Expect whole percentages from both sources.
- On StreamTimeline, anchor on what you can recognize (the
"5h"and"7d"labels and plausible values) rather than on field numbers, and treat a missing double as 0. - If you only need the limits, skip compressed frames by their length. There is no need to decompress the reply.
The extension
Claude Monitor shows the 5-hour session, the weekly limit, per-model weekly caps and extra-usage credits in a toolbar popup, with reset countdowns and alerts at thresholds you choose. The on-page bar described above is optional. Version 1.21.0, which reads both transports, is out for Chrome, Firefox and Edge. It's MIT-licensed and not affiliated with Anthropic.
Source: github.com/claude-monitor/claude-monitor-browser-extension
The extension itself only talks to claude.ai and has no analytics. The one thing the project receives is an optional uninstall survey, described in the privacy policy.