How src/usage.ts reads your own Claude Code usage limits: the same data the interactive
/usage panel shows. Verified working 2026-08-06 against Claude Code v2.1.223.
There is no CLI flag and no public documentation for this. The official answer is that
usage is visible only through the interactive /usage panel, your account settings on
claude.ai, or — for API-key customers — the Analytics API in the Console. The endpoint
below is what Claude Code itself calls.
GET https://api.anthropic.com/api/oauth/usage
Authorization: Bearer <oauth access token>
anthropic-beta: oauth-2025-04-20
Both the /api/oauth/ path prefix and the beta header are required. Do not guess the
path — /v1/usage and /account/usage both return 404, which makes it look like no such
endpoint exists.
Claude Code stores an OAuth token that this endpoint accepts.
- macOS: Keychain, service
Claude Code-credentials. Read withsecurity find-generic-password -s "Claude Code-credentials" -w. - Elsewhere:
~/.claude/.credentials.json(or$CLAUDE_CONFIG_DIR).
The token lives at claudeAiOauth.accessToken, with expiry at claudeAiOauth.expiresAt.
Three things make naive lookups fail, all learned from ai-token-monitor (a sibling
local project, not part of this repo) — its src-tauri/src/oauth_usage.rs is the
reference implementation this port follows:
- One service can hold several items. Some carry only
mcpOAuthand no usable token. Try multiple account candidates and accept only an item that actually yieldsclaudeAiOauth— otherwise the lookup lands on a tokenless item and reports failure. - Claude Code v2.1.52+ uses a hashed service name,
Claude Code-credentials-{hash}. Discover it rather than assuming the legacy name. Discovery here meanssecurity dump-keychain— a metadata walk of the entire keychain (slow on large ones, and broader than one service lookup), so run it only after the fixed legacy name yields nothing, and remember the item that answered for the process lifetime (src/usage.ts does both). - The Keychain payload may have a leading non-JSON byte. Strip before parsing.
Refresh is best delegated to the CLI — run claude auth status --json and re-read the
stored credentials — rather than reimplementing the OAuth exchange with private client
details.
It is a live credential. Never print it, never write it to disk, and never pass it as a
command-line argument, where ps would expose it to every user on the machine. Read it,
use it, keep it in memory.
Granularity is 1%. utilization and percent are integers — every observed value
(2026-08-06/07, dozens of samples) has been whole, and no field carries a finer-grained
alternative: the per-window limit_dollars/used_dollars/remaining_dollars fields and
extra_usage.utilization are null on a subscription plan, and spend only moves once
paid extra-usage credits are being consumed. One percent of the 5-hour window is ±3
minutes of time-budget; finer than that requires counting tokens locally (OTEL metrics or
the transcript JSONL), which yields tokens but not percent-of-limit — the denominators
are not disclosed. There is also no history: this endpoint is current-state only, and
Claude Code's own "last N days" graphs are built from local transcript data — see
USAGE-GRAPH.md. Windows also carry group and severity fields, and scope has a
surface key (null so far).
Parsing notes, each of which has broken a real implementation:
resets_atis nullable. A window at 0% utilisation reportsnull. A non-optional type here fails the entire parse, which empties the cache and surfaces a misleading "usage unavailable" rather than a parse error.- Per-model limits moved. They now arrive as
weekly_scopedentries in thelimitsarray carryingscope.model.display_name; the dedicatedseven_day_<model>keys returnnull. is_activeis not a render filter. It marks whichever single limit currently binds — observed across two days flipping from the Fable entry (at 9%, the highest utilization) to the session entry (tied at 10%, session wins ties) while all three entries stayed present. Filtering model windows onis_active: truemakes them vanish whenever another limit overtakes them, which is exactly how it broke here. A window that stops applying disappears from the array instead.limitsmay be absent on older payloads or some accounts. Default it to empty rather than failing.- Ignore unknown keys. The response carries several null-valued codename placeholders
(
tangelo,iguana_necktie,nimbus_quill,cinder_cove,amber_ladder,omelette_promotional, …) that come and go. - Money is in minor units.
monthly_limit: 17000withdecimal_places: 2is €170.00. - There is no window-start field. The monitor derives the 5-hour window's start as
resets_at − 5h(the window opens at the session's first activity, so this holds by construction) — but if the API ever reports a shorter effective window, a pace marker derived this way drifts optimistic. Unverified against a real session boundary.
The endpoint returns 429 with a Retry-After header, and it is easy to hit while
iterating — repeated manual refreshes during testing triggered a 60-second back-off. Honour
Retry-After in full rather than retrying on your normal interval, and gate any
user-triggered refresh behind the same window so a button press cannot provoke it again.
Do not assume you are the endpoint's only caller when picking a cadence. The rate budget appears to be per-account, not per-client: a 5-minute poll — the cadence ai-token-monitor uses, and this monitor's original default — drew regular 429s in day-to-day use (observed 2026-08-09), plausibly because active Claude Code sessions hit the same endpoint. The monitor now polls every 10 minutes and doubles its wait on consecutive 429s (capped at 4× the interval), which is a better fit for a shared budget than retrying at full cadence the moment one Retry-After expires.
In-process, every consumer must share one deduplicated fetcher: dedupedFetchUsage()
(src/usage.ts, wired in src/mbar.ts with a 30 s TTL). Two modules polling
independently double the load on a per-account budget for identical data — and the
deduper caches errors too, so a sibling module cannot immediately re-provoke a 429
the first one just backed off from. A new usage-reading module goes through it, not
through fetchUsage() directly.
Minimal read, token never leaving memory:
import { fetchUsage } from './src/usage';
const usage = await fetchUsage();
console.log(usage.fiveHour?.utilization, usage.fiveHour?.resetsAt);
for (const model of usage.models) console.log(model.model, model.utilization);fetchUsage() throws NoCredentialsError when Claude Code is not signed in and
RateLimitError (carrying retryAfterSeconds) on a 429.
{ "five_hour": { "utilization": 7, "resets_at": "2026-08-07T00:50:00Z" }, "seven_day": { "utilization": 6, "resets_at": "2026-08-10T11:00:00Z" }, "seven_day_sonnet": null, // legacy per-model keys are null now "seven_day_opus": null, "limits": [ { "kind": "session", "percent": 4, "is_active": false, "scope": null }, { "kind": "weekly_all", "percent": 6, "is_active": false, "scope": null }, { "kind": "weekly_scoped", "percent": 9, "is_active": true, "resets_at": "2026-08-10T10:59:59Z", "scope": { "model": { "display_name": "Fable" } } } ], "extra_usage": { "is_enabled": true, "monthly_limit": 17000, "used_credits": 0, "currency": "EUR", "decimal_places": 2 }, "spend": { "used": { "amount_minor": 0, "currency": "EUR", "exponent": 2 }, "…": "…" } }