Research · Published October 21, 2026

Where Claude's usage limits went: from SSE to protobuf frames

claude.ai's newer interface moved replies from server-sent events to Connect frames with protobuf payloads. Here is where the 5-hour and weekly limits ended up, and how to read them.

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:

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:

  1. A quick test on the raw bytes: does the payload contain 0x02 '5' 'h' or 0x02 '7' 'd', a length-2 field holding a label? If not, the frame is ignored.
  2. 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".
  3. 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

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

If you parse these numbers yourself

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.