QuotaLens
Claude tells you what is consuming your quota right now, and then forgets. QuotaLens remembers.
It is a local, self-hosted monitor for Claude Pro and Max subscription usage. It polls your account every minute, keeps the series in a SQLite file you own, and leads with the question you open it to ask: will this session window run out before it resets, and when. When something surprises you, the record is still there — which five-hour session, how steep the climb, the minute it started.
macOS, Linux and Windows, each tested on every push — with one caveat about Windows worth reading before you rely on it, in Platforms. Binds loopback, keeps your cookie in the OS keychain, phones nothing home. MIT.
Unofficial. It uses undocumented claude.ai endpoints and only observes: it cannot raise, extend or bypass a limit. Not affiliated with or endorsed by Anthropic, and the endpoints may change without notice. The longer version is in The Terms, stated plainly.
Status: v1.0.
Quick start
pipx install quotalens # or: uv tool install quotalens
quotalens auth # paste your claude.ai session cookie once (stored in the OS keychain)
quotalens probe # one fetch, prints raw + parsed output for debugging
quotalens start # background: pid file + rotating log in the data directory
open http://127.0.0.1:8787 # the dashboard
quotalens status # exit 0 healthy, 1 not running, 2 stalled
quotalens logs -f
quotalens stop
curl 'http://127.0.0.1:8787/api/quota/current'
Then, as you need them:
curl 'http://127.0.0.1:8787/api/budget' # the weekly limit, in session windows
curl 'http://127.0.0.1:8787/metrics' # Prometheus, hand rolled
curl 'http://127.0.0.1:8787/api/export.csv?table=quota' # or export.json
curl 'http://127.0.0.1:8787/api/events' # threshold crossings, anomalies
quotalens prune --dry-run # what retention would remove
quotalens forget # session windows, and their ids
Set QUOTALENS_WEBHOOK_URL to get one POST when the burn rate crosses
QUOTALENS_BURN_ALERT points per hour (default 20), and one when it falls back.
It feeds ntfy, Discord, Slack, Pushover or Home Assistant. The body:
{
"event": "burn_alert",
"profile": "default",
"ts": 1788456405,
"rate_pts_per_hour": 42.5,
"threshold_pts_per_hour": 20.0,
"headroom_pct": 37.0,
"session_resets_at": "2026-09-03T18:00:00+00:00",
"text": "Burn rate 42.5 pts/hr crossed the 20 pts/hr threshold, 37% of the session left.",
"url": "http://127.0.0.1:8787/"
}
profile is the local label you chose, so a receiver watching two of them can
tell them apart. There is no organisation id, no account identifier and no
cookie in it, by design.
Settings that survive a restart
Flags and QUOTALENS_* variables still work and still win. What is new is a
place to put a choice so you do not retype it:
quotalens config set port 9123 # then `start` binds there with no flag
quotalens config get port
quotalens config unset port
quotalens config list # every setting, and where its value came from
config list is the one worth knowing. It prints the merged result and names
the layer each value came from, because "why is it not the port I set" is a
precedence question:
file: ~/Library/Application Support/quotalens/config.json
port 9900 flag
interval 60 default
poll_enabled True default
Precedence, highest first: CLI flag → QUOTALENS_* → config.json →
built-in default. The file lives beside the database and is profile-suffixed
the same way (config-work.json). It is plain JSON, written atomically, and
it never contains your cookie — that stays in the OS keyring, so the config
file is safe to paste into an issue.
Two settings are deliberately CLI-only. The port is the address of the page the settings live on, so changing it from that page means the response never arrives. The cookie is a secret and belongs in the keyring. Everything else can be set either way.
Settings, from the dashboard
The gear in the header opens /settings: poll interval, burn thresholds,
retention, webhook, notifications and the vendor status row. It is a plain form
that works with JavaScript switched off, validates on the server with the
same rules the CLI uses, and shows the real error beside the field rather than
quietly clamping a bad value. Each field says whether it takes effect on the
next poll or needs a restart.
The port, the database path and the cookie are shown read-only, with the command that changes each. The port is the address of the page you are looking at.
Desktop notifications are there and are off by default. Turn them on in the
panel and pick up to three usage levels; a banner fires once when a window
crosses each. Off by default because it is three OS code paths — osascript or
terminal-notifier on macOS, powershell on Windows, notify-send on Linux —
and it is dead under a systemd user unit with no session bus, which is how this
is meant to run. The panel says so, with the reason, rather than offering a
switch that does nothing; the webhook is one code path that works everywhere and
is the right default for an unattended instance.
Vendor status
A row per vendor in the side panel — Claude, Gemini, OpenAI — reading each vendor's own status page. The whole row is a link to that page, in a new tab.
This, and the webhook if you set one, is the only outbound traffic to anyone
other than your provider: one plain GET per vendor every five minutes, no
cookie, no identifier, no query string. It is on by default and quotalens config set status_row false turns it
off, which stops the requests rather than hiding the row.
Gemini is not covered. Google publishes no status API for AI Studio or the Gemini API — its status page is a JavaScript app with no JSON behind it, and Google Cloud's product feed covers Vertex surfaces, which have their own availability record. A row that could only ever say "unable to check" earns nothing, so there is no Gemini row. It becomes four lines the day Google ships a feed.
The marks beside each vendor's name are that vendor's own brand asset, shipped unmodified to identify their service. They belong to their owners, and QuotaLens is not affiliated with, endorsed by, or associated with any of them — Anthropic included, which is the whole reason for saying so here.
Two accounts
A profile is a second account. It gets its own keyring entry, its own database, its own port and its own pid file, so the two never see each other:
quotalens --profile work auth
quotalens --profile work start # a derived port, printed by `start`
quotalens --profile personal auth
quotalens --profile personal start
quotalens --profile work stop # leaves the personal one running
Two accounts is two processes and two bookmarks, not an account picker inside
one process. The port is derived from the name and is the same on every run, so
start, serve and status all print the URL they landed on — you should
never have to work out which port a profile got. If something else already holds
it, the error names the port, the profile and the --port flag that settles it.
QUOTALENS_PROFILE works too, for a service unit.
Running it as a service
One command makes it start on its own every time you log in, and one undoes it:
quotalens service install # start at login, from now on
quotalens service status # is it set up, and is it running?
quotalens service uninstall # stop doing that, and remove what was written
What that installs depends on the OS:
| Platform | What is registered | Restarts if it crashes |
|---|---|---|
| macOS | LaunchAgent, RunAtLoad and KeepAlive |
yes, launchd |
| Linux | systemd user unit, Restart=on-failure |
yes, systemd |
| Windows | Task Scheduler task QuotaLens, trigger "at log on" |
no, next login |
Every file written and command run is printed, so it can be undone by hand.
The installed command carries an explicit --data-dir, so the service collects
into the same place your shell does. serve stays the foreground command the
service manager execs, and start/stop/status/logs still work alongside.
On Linux the unit only runs while you are logged in unless you enable lingering;
the installer prints the exact loginctl enable-linger command. On Windows the
task starts at login, not at boot, and only for your account.
A background agent reading the OS keychain may prompt on first run or be
refused; quotalens status then says "keyring" specifically rather than "no
data".
The weekly limit, in windows you can plan with
"Weekly is at 93%" is not a number you can act on. The dashboard puts the same fact under the weekly meters in the unit the work actually arrives in:
Limit Left Full sessions left At your typical session Each full session costs
Weekly — all models 6% 0.5 0.5 at 89% used 12 pts (10–15, from 6 sessions)
Weekly — Fable 0% none left none left
There is time for 13.6 more sessions before this resets Mon 09:30, and budget
for 0.5 — the budget is what runs out.
Weekly — Fable is spent, so none of the 6% left on Weekly — all models can be
used on it.
The note under the table says which of the two constraints binds, because that is the finding: half a session of budget against thirteen sessions of wall clock means rationing, not racing.
The cost of a window is measured, not assumed. Every complete session window in your history carries both its own consumption and what it cost each weekly limit, so the ratio is an observation about how you use models. The median is the estimate and the range beside it is the spread, because the model mix moves it — in one real history the same 100% window cost between 9.6 and 14.8 points.
Windows that would poison the ratio are excluded, strictly: one still running, one only partially observed, one the weekly limit reset inside, one too small to divide, and one where the limit was already at its cap and so could not move. Below five usable sessions it prints the reason in the cell instead of a number: "Needs 5 complete session windows to estimate the cost of one; 3 so far." An em dash cannot be told apart from "the answer is nothing", and those are opposite facts to plan against. It does not lower the threshold to make a number appear — a confident "3.2 sessions left" drawn from two observations is worse than none.
It is on /api/budget and in /metrics as quotalens_weekly_windows_remaining,
quotalens_weekly_window_cost_points and
quotalens_weekly_clock_windows_remaining.
When a limit is raised
Anthropic can raise a limit mid-window: the level falls without the window resetting. QuotaLens records it, because a tool that only shows the current value loses the fact by the next day.
08:09 — Weekly — all models fell 98% -> 0% with no reset. Limit raised.
The event is the record; the chart marks the moment, the affected meter says "boosted 08:09" while that window is still running, and the History row it fell in is badged. A boost is information, not a state, so it takes no colour of its own.
If your database predates this feature, quotalens rescan finds boosts in the
readings already stored and records them. It is safe to run twice: a boost
already recorded is skipped, never rewritten.
It also has to be kept out of the arithmetic. A boost inside a session window
makes that window's weekly change consumption minus the raise, and that number
is what the cost estimate behind "full sessions left" is built from — so a boost
would quietly make the weekly budget optimistic. The payload carries no ceiling
anywhere, only utilization, so the size of a raise cannot be recovered: an
observed fall is consumption and boost together with nothing to separate them.
The window is therefore excluded from the estimate and the History row says why.
A window that cannot be costed is honest; one costed wrong is not.
When a window is not running
A five-hour window expires and, until the next message, no window is open. The reading from the window that just closed is not a current reading of anything, so nothing shows it: the hero says so, the meter's value is removed, its footer says ended 14:00 rather than "resets 14:00", and the ring and the favicon go to the empty track. The chart shades the gap as "no session" instead of running the trace flat to the right edge.
The rule is one line — never present a session percentage as current once its
window's reset time has passed — and it is implemented in window_has_lapsed
and in compute_runway. Two places, not one: the meters, /api/quota/current
and /metrics reach it through the first, the hero through the second.
Consolidating them is open work, and until it is done a change to either has to
be made to both.
Staleness is also tracked per window, not only per collector. A block can stop arriving inside an otherwise healthy payload, and a healthy collector is not evidence that every meter on the page is current; a window whose own reading has gone unrefreshed for three poll intervals is withheld like any other unknown.
/api/quota/current says all of this in fields rather than leaving a consumer to
infer it. pct is null unless the reading is current, the value moves to
last_pct, and current, lapsed, stale and window_open say which reason
applies. /metrics reports NaN for the same cases, never a stale number
somebody would alert on.
The dashboard
The figure it leads with is how much of the session window is left. Below it: one meter per quota window with the API's own severity, a 24-hour chart of every window with resets drawn as gaps, and the extra-usage spend computed from minor units with the payload's exponent, never clamped at 100%.
States are honest by construction. If the collector has not succeeded in three poll intervals, the cookie was rejected, or the response could not be parsed, every value is replaced by an em dash and the frame changes, so a stale page never looks like a healthy one showing low usage. If the browser loses the server, the same treatment applies from CSS alone.
The dashboard is interrogable and every view is a URL: range presets from 15 minutes to all, drag on the chart to zoom to any window (double-click resets), click a series' end label to hide it, pick the burn-rate lookback, set auto-refresh, and force a poll (one per 10 seconds). All of it works with JavaScript disabled as plain links and forms.
The top of the page answers the one question the dashboard exists for: will the session window run out before it resets? Beside the burn rate sit a ticking countdown to the reset, the headroom left, and the sustainable rate, the points per hour you could burn from now to the reset without exhausting it. The verdict sentence changes with the situation: "Exhausted at 21:04, 2h 09m before reset", "At this rate you finish with 37% unused", or "Flat for 4m. 37% left, resets in 2h 14m". Once five complete windows exist it adds how the projected finish compares with your median window. Five bars beneath show the points consumed in each hour of the window. The chart's default range is the current window from start to reset, with now inside it, hourly separators, and a dashed projection at the current rate that turns critical where it crosses 100%.
Below the chart, the history table lists your session windows, derived from the
API's own resets_at values: when it jumps forward a new session started.
Where a session starts is an inference. Anthropic documents that the
session limit resets every five hours and has never published how the window is
anchored, so QuotaLens infers the start as the reset time minus five hours. The
evidence for it is that the server recomputes resets_at on every call and only
the sub-second part moves, which is what a fixed anchor looks like. Rather than
hedge, the collector checks the inference on every poll: if a reset time ever
moves forward by less than five hours without the percentage dropping, the model
is wrong, and QuotaLens records it and says so on the page. Anything derived from
session history — the table, the hour strip, the auto range, the coverage badge —
rests on that inference.
Each row shows the peak utilisation reached in the window, how far each weekly limit moved during it, a sparkline of its shape, and how much of it was observed. Sort by consumption to find the expensive session, click it, and the chart shows exactly that window. On the chart, session starts are vertical rules and spans with no session running are shaded flat, which is a different mark from the hatched spans where the collector was not running.
How it reaches claude.ai
claude.ai sits behind Cloudflare bot protection that fingerprints the TLS
handshake. A plain Python HTTP client is served a challenge page even with a
valid session cookie, so QuotaLens talks to claude.ai through
curl_cffi, which impersonates a
browser's TLS and HTTP/2 fingerprint. If you ever see a blocked state on
/api/health, try QUOTALENS_IMPERSONATE=safari (default chrome).
Security note
The session cookie is equivalent to your claude.ai password. QuotaLens stores
it only in the OS keychain (via keyring), never in a file, the database, or a
log line, and redacts it from error output. The server binds loopback only.
Treat quotalens probe output as sensitive and redact it before sharing.
Storage, and what gets pruned
Two things accumulate, and only one of them is pruned.
All the figures below were measured on a real database on 2026-09-06: four days of one-minute polling, 5,807 usage samples, 1,697 overage samples and 16,066 readings in a 26.3 MB file. Yours will differ — the payload size is the endpoint's, and the row counts are your poll interval's.
The readings are the product and are never pruned. One row per window per poll, about 100 bytes each. Measured: 3,949 rows a day, 0.39 MB a day, about 144 MB a year. If you want less, poll less often.
The raw payloads are debugging material and are bounded. Every response is
stored verbatim so that when the endpoint shape changes there is a record of it.
Measured: a usage payload averages 2,028 bytes (range 1,897–2,073 over 5,807
samples), so at a minute a poll the usage half of the sample table grows
2.9 MB a day, about 1.05 GB a year if nothing prunes it. Something does:
quotalens prune --dry-run # what it would remove
quotalens prune --keep 50000 # or set QUOTALENS_SAMPLE_KEEP
How long data is kept
quotalens config set retention 3months # 1week, 1month, 3months, 6months, 1year
quotalens prune --dry-run # what that would delete, before it does
There is no "keep forever". The period governs the detail tables — quota,
overage and the raw sample cap. session_window and event are kept for
two years regardless, because they are what the weekly budget table and the
typical-session baseline read, and a year of them is under 2,000 rows. Choosing
"1 week" will not shorten your history list.
Two things worth knowing:
- The first run after upgrading deletes nothing. A database that predates
this setting is written as
1year, the longest option, and the dashboard says so. A destructive default you did not ask for is not a default. - Deleting is not shrinking. SQLite keeps the freed pages, so pruning runs
VACUUMand reports the real before and after. That needs up to twice the file size in free space and takes an exclusive lock, so it runs on a schedule and on a worker thread, never inside a poll.
The size shown against each option in settings is measured from your own database — your poll interval, your window count — not a figure shipped with the tool. Under two days of history it shows an em dash, because it is not knowable yet.
The default keeps the newest 20,000 samples, roughly 39 MB, plus the first sample of every distinct payload shape, forever — that set is the endpoint-drift record and pruning it would defeat the point of keeping payloads at all. The poller prunes on the same rule every six hours, so the default applies whether or not you ever run the command.
How many days 20,000 buys depends on what else is being sampled. Usage alone at a minute a poll is 14 days. The database measured above also stores an overage sample roughly every three and a half minutes — 419 a day against usage's 1,427 — and those count against the same cap, so it is about 11 days there. The panel under Data retention estimates from your own database rather than from either number.
Rows another collector wrote
If a second instance ever pointed at this database, its samples are in here too,
and the history shows session windows that were never yours. That cause is
fixed: a scratch --data-dir now implies a scratch database. It cannot fix
databases that already have the rows.
quotalens forget # every session window, with its id
quotalens forget <id> [<id>] --dry-run # what removing them would take
quotalens forget <id> [<id>] # take it, then rebuild the history
The listing marks windows where only minutes of a five-hour span were ever observed, which is what a collector that ran for two minutes leaves behind. It is a reason to look, not a verdict: a window where you genuinely only had the collector up for two minutes looks identical, and only you know which it was.
Removal is by expiry, not by time range. Two collectors writing to one database interleave their samples second by second, so deleting a time range takes real readings with it. The five-hour expiry is what separates one collector's window from another's, and it is the same key the history is built from. Stop the server first, so the startup rebuild runs on what is left:
quotalens stop && quotalens forget <id> && quotalens start
What this doesn't do
Each of these is a decision, and in most cases something else already does it better. Pointing at the better tool is a feature.
- Per-project attribution. Use the vendor's own usage command —
claude /usageinside Claude Code — for attribution to skills, subagents, plugins, MCP servers and scheduled tasks. Quota is pooled across claude.ai, Claude Code and Claude Desktop, so local logs can only ever show correlation with a number they cannot see. - Anything but loopback. There is no
--host. The dashboard is account data with no authentication. If you want it elsewhere, put it behind a proxy you already trust; if enough people ask, the answer will be a token, not a flag. - A container image.
pipxoruvxonly, for now. Docker needs a credential path that is not the OS keychain, which forks the security story, and that deserves its own decision rather than a Dockerfile. - Reading Claude Code's OAuth credentials. The pasted cookie stays the only auth path in this release.
- Any provider but Claude, API-key cost tracking, a menu bar app, a proxy, or a hosted service.
Linux servers: not yet
QuotaLens keeps your cookie in the OS keyring and has no file-based credential
store. On a Linux box with no desktop session there is usually no D-Bus session
and no keyring daemon, and then python-keyring has no backend at all:
$ quotalens auth
cannot use the keyring: this system has no usable keyring, so the session
cookie cannot be stored or read. On a Linux server that normally means there is
no D-Bus session and no keyring daemon; QuotaLens has no file-based credential
store, so a headless Linux box is not supported yet...
That check runs before you are asked for a cookie, so you find out in a
second rather than after pasting one and waiting for a network round trip. A
desktop Linux session with gnome-keyring or kwallet unlocked works normally,
and so does the systemd user unit under that session.
Supporting a real server means a credential path that is not the OS keyring, and that forks the security story, so it is a deliberate post-1.0 decision rather than something to bolt on. It is the first issue on the list.
Platforms
macOS, Linux and Windows are in the CI matrix on Python 3.11 and 3.13, eight
jobs (three platforms × two versions, plus lint and render),
green on every push. Each one builds the wheel, installs that rather than the
source tree, runs the whole suite against it, and then runs a smoke test that
starts a real server against a fake upstream, polls it, reads the series back
through the API, and drives start, status, logs and stop on the pid
file. The Windows job additionally registers the logon task for real, queries
it, and removes it, so the task definition is checked by Task Scheduler rather
than by me.
Be precise about what that proves. It proves the wheel installs and the app
runs on all three. It does not prove the credential path on any of them: the
smoke test injects an in-memory store, so Windows Credential Manager and the
macOS Keychain are exercised by hand, not by CI. Two known Windows limits:
stop terminates the server rather than signalling it, because Windows has no
SIGTERM; and log rotation can fail to roll the 2 MB file while the server holds
it open, in which case it keeps appending rather than losing lines.
The Terms, stated plainly
Anthropic's Consumer Terms, section 3 ("Use of our Services"), prohibit:
- Except when you are accessing our Services via an Anthropic API Key or where we otherwise explicitly permit it, to access the Services through automated or non-human means, whether through a bot, script, or otherwise.
QuotaLens is automated access, and a subscription session cookie is not an API Key. There is no carve-out in the Terms for reading your own usage. I looked.
Two facts that bear on the risk, neither of which changes the clause:
- I know of no case of Anthropic acting against a read-only usage monitor. The one confirmed enforcement in this space was against third-party harnesses that spent subscription quota by running completions, and those were reinstated in May 2026 with metered credits.
- QuotaLens never sends a prompt and never spends a token. It reads two endpoints your own browser reads, at most once a minute.
You are the one accepting that risk, not me. Decide with the clause in front of you.
Unofficial, and not affiliated with or endorsed by Anthropic. It uses undocumented endpoints that may change without notice, and it only observes: it cannot raise, extend or bypass a limit.
License
MIT.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file quotalens-1.0.tar.gz.
File metadata
- Download URL: quotalens-1.0.tar.gz
- Upload date:
- Size: 441.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0577a94f0ed0abc4c03f4f10a91c74231fbf287529b5d8dd48afe29fb117e256
|
|
| MD5 |
10199a69a15ca0785c937fa6a5e6abf4
|
|
| BLAKE2b-256 |
5a57d2559ec840005b24b0ef18e50f60749f72c3bc26b50424db994b2637767c
|
Provenance
The following attestation bundles were made for quotalens-1.0.tar.gz:
Publisher:
release.yml on amitshcc/quotalens
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
quotalens-1.0.tar.gz -
Subject digest:
0577a94f0ed0abc4c03f4f10a91c74231fbf287529b5d8dd48afe29fb117e256 - Sigstore transparency entry: 2742982572
- Sigstore integration time:
-
Permalink:
amitshcc/quotalens@5eeee54f8d6e7743320801281d69bdd6b6e1a06d -
Branch / Tag:
refs/tags/v1.0 - Owner: https://github.com/amitshcc
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@5eeee54f8d6e7743320801281d69bdd6b6e1a06d -
Trigger Event:
push
-
Statement type:
File details
Details for the file quotalens-1.0-py3-none-any.whl.
File metadata
- Download URL: quotalens-1.0-py3-none-any.whl
- Upload date:
- Size: 203.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
370407a3592a940a991e57ddef14f050d77dfc0e368d08b4f1f0b8338cb6875e
|
|
| MD5 |
09d217e747b00d94046ae4da9203f0b3
|
|
| BLAKE2b-256 |
1e7ad4c4227ac384e952de5f6a9dc68f03d97f240740fb04f2fe05df3768419a
|
Provenance
The following attestation bundles were made for quotalens-1.0-py3-none-any.whl:
Publisher:
release.yml on amitshcc/quotalens
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
quotalens-1.0-py3-none-any.whl -
Subject digest:
370407a3592a940a991e57ddef14f050d77dfc0e368d08b4f1f0b8338cb6875e - Sigstore transparency entry: 2742982579
- Sigstore integration time:
-
Permalink:
amitshcc/quotalens@5eeee54f8d6e7743320801281d69bdd6b6e1a06d -
Branch / Tag:
refs/tags/v1.0 - Owner: https://github.com/amitshcc
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@5eeee54f8d6e7743320801281d69bdd6b6e1a06d -
Trigger Event:
push
-
Statement type: