Metadata-Version: 2.5
Name: quotalens
Version: 2.0.0
Summary: QuotaLens: local, self-hosted monitor for Claude subscription quota, with burn rate and history.
Project-URL: Homepage, https://quotalens.com
Project-URL: Documentation, https://quotalens.com/install
Project-URL: Repository, https://github.com/amitshcc/quotalens
Project-URL: Issues, https://github.com/amitshcc/quotalens/issues
Project-URL: Changelog, https://github.com/amitshcc/quotalens/releases
Author: Amit Sharma
License-Expression: MIT
License-File: LICENSE
Keywords: claude,monitoring,quota,usage
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Web Environment
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: System :: Monitoring
Requires-Python: >=3.11
Requires-Dist: curl-cffi>=0.10
Requires-Dist: fastapi>=0.115
Requires-Dist: keyring>=25
Requires-Dist: uvicorn>=0.30
Provides-Extra: browser
Requires-Dist: playwright>=1.40; extra == 'browser'
Provides-Extra: dev
Requires-Dist: httpx>=0.27; extra == 'dev'
Requires-Dist: playwright>=1.40; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff==0.16.*; extra == 'dev'
Provides-Extra: test
Requires-Dist: httpx>=0.27; extra == 'test'
Requires-Dist: pytest>=8; extra == 'test'
Description-Content-Type: text/markdown

# QuotaLens

[![CI](https://github.com/amitshcc/quotalens/actions/workflows/ci.yml/badge.svg)](https://github.com/amitshcc/quotalens/actions/workflows/ci.yml)

Claude tells you what is consuming your quota right now, and then forgets.
QuotaLens remembers. The short version, with the dashboard, is at
[quotalens.com](https://quotalens.com); the install page is
[quotalens.com/install](https://quotalens.com/install).

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](#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](#the-terms-stated-plainly).

Status: **v2.0.**

## Quick start

```sh
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:

```sh
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
curl 'http://127.0.0.1:8787/api/weeks'                     # one row per weekly reset
curl 'http://127.0.0.1:8787/api/pace'                      # where this week is heading, with its spread
quotalens profiles list --json                             # every profile, its port, running or not
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:

```json
{
  "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:

```sh
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, the daily update check, and the webhook if you set one, are 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. The update check
is one GET to pypi.org a day, described under
[How it reaches claude.ai](#how-it-reaches-claudeai). 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:

```sh
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
```

`quotalens profiles list` shows every profile, its port and whether it is 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.

Every profile must be an account that is yours. Anthropic's Consumer Terms
(section 2) forbid sharing your login or credentials with anyone else or
making your account available to anyone else, so a profile holding someone
else's cookie breaks their terms, not just yours. And the
[Usage Policy](https://www.anthropic.com/legal/aup) forbids using several
accounts to get around a product's limits. QuotaLens shows each account's
own state; it does not suggest which account to switch to, and it is not
meant for rotating work across accounts.

## Running it as a service

One command makes it start on its own every time you log in, and one undoes it:

```sh
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`.

## Weeks, and what a session cost in each

The budget table answers "how many sessions are left this week". It cannot
answer the next question: **is a session costing me more of the week than it
used to?** For that you need last week's answer, and the week before's, and the
vendor's page forgets them at every reset.

So QuotaLens records each weekly reset as it happens (Monday 01:00 UTC, give or
take a second) and keeps a ledger under History, one row per week:

```
Week              Closed at      One full session used          Used   Left unused   Full sessions at reset   Mostly
21 Sep – 28 Sep   28 Sep 06:30   9% of week · usually 8–10%     99%    1%            8.5                      Claude Code 64%
14 Sep – 21 Sep   21 Sep 06:30   9% of week · usually 8–11%     100%   0%            8.0                      —
7 Sep – 14 Sep    14 Sep 06:30   13% of week · usually 10–15%   99%    1%            8.2                      —
```

**One full session used** is how much of the week a full five-hour session cost
that week: the median over the week's usable sessions, with its usual range, the
same estimate the budget table makes, frozen when the week closed. **Full
sessions at reset** is what the budget table said in the minute before the
reset. **Mostly** is the surface that used the most of that week (see
[Where the quota went](#where-the-quota-went)); a dash for weeks before that
split was collected.

Under the table, one sentence compares the two newest complete weeks: *"A full
session used 9% of the week, against 9% the week before. The usual ranges
overlap, so no change can be called."* When the ranges do not overlap it says
which way the cost moved. It never says a limit was raised: a session getting
cheaper relative to the week is either the weekly pool growing or the session
pool being re-weighted, and the ratio cannot tell those apart.

If your database predates this, the ledger is backfilled from the readings
already stored on the next start. `GET /api/weeks` returns the same rows,
`table=weeks` exports them, and every reset is also a `week_reset` event.

## The Fable meter is half the pool

On plans with a Fable meter, Anthropic's help centre says Fable models may use
up to 50% of your weekly limit. So that meter's 100% is half the weekly pool,
not an empty account, and the meter says **half of weekly pool** beside it. It
does not turn the page critical on its own.

That is the vendor's rule, not a measurement, so QuotaLens checks it on every
poll against two things that must hold if it is true: Fable can never be more
than twice the all-models figure at any reading, and over a session window it
can never climb more than twice what the all-models meter did (each with a
point of slack for whole-percent rounding). If either fails, a
`subcap_violation` event is recorded and the note under the budget table says
the rule may not hold for your account.

## Where the week is heading

Under the budget table, one line projects where **Weekly — all models** lands at
the reset: *"At this pace the week ends near 93% (81–100%)"*, or *"At this pace
the week runs out around Thu 14:00 (Wed 22:00 – not before the reset)"*. It
always carries its range, and it is labelled an estimate.

The projection is the week so far plus what your recent complete weeks went on
to use from the same hour to their reset. That was chosen by back-testing three
methods against real weeks; the straight line from the week so far missed by
three times as much, because most weeks start slowly and finish hard. It stays hidden for the
first 24 hours of a week and until there is one complete week to learn from.

On the **this week** range the chart also draws last week's all-models line,
dashed and muted, under this week's, so "am I ahead of last week" is a glance.
The **vs last week** chip turns it off.

After Weeks, **When you use it** is a 7 × 24 grid: weekday by local hour, each
cell the average all-models points gained in that hour over the last four
complete weeks. It is grey on purpose; nothing about using your quota at 3 am is
an alarm. An hour the collector missed is left out of the average rather than
counted as zero. It needs two complete weeks and says so until then.

`GET /api/pace` and `GET /api/heatmap` return both.

## Where the quota went

Since late September 2026 the usage payload carries Anthropic's own split of the
week's usage by surface: Claude Code, Chats, Cowork, Other. QuotaLens stores it
(a row only when a share changes) and shows it as one stacked bar and a table:

```
Surface       Share of this week   ≈ of the week's limit
Claude Code   71%                  ≈ 44%
Chats         22%                  ≈ 14%
Cowork         7%                  ≈ 4%
```

The shares are the vendor's and add up to the week's usage, not to the limit.
The second column multiplies each share by the all-models meter to say roughly
how much of the limit it took; that is an estimate and is labelled as one. For
which skill, subagent or MCP server inside Claude Code, `claude /usage` still
does it better, and the section says so in one line.

`GET /api/breakdown` returns the split, `table=surfaces` exports its history,
and the Weeks ledger's **Mostly** column is its top surface at each week's close.

## The cloud session credit

Some accounts carry a promotional dollar credit for Claude Code cloud sessions
($250 on Max, $100 on Pro in September 2026), with an expiry date. It arrives in
the same payload as the quota windows, but it is not one: it never resets and is
not part of any weekly limit. QuotaLens keeps it out of the chart, the budget,
Weeks and the boost detector, and shows it on its own under Usage credits:

```
Cloud session credit    $21.29 of $250
$228.71 left · expires 4 Nov
```

Seven days and again one day before the expiry, while money is left on it, it
records an event and, if desktop notifications are on, sends one. After the
expiry it reads "expired" for a week, then leaves the page.
`GET /api/credits` returns it in dollars, `table=credits` exports it, and the
events are `credit_grant_seen` and `credit_grant_expiring`.

## Your plan

The header shows the plan as a muted label after the name, **QuotaLens** Max 5x
(or Max 20x, Pro, Team, Enterprise, Free), and About has a Plan row. The plan is not in the usage payload; it comes
from `/api/bootstrap`, asked on the first good poll after start and then once a
day. Only three fields of that response are kept: the organisation's
capabilities, its rate-limit tier and its billing type. No names, no emails, no
ids. A failed fetch keeps the last plan it knew and never fails a poll.

It is informational only. Nothing on the page changes with the plan: the meters
are whatever the readings contain, because a reading cannot be out of date and
a plan label can. A Pro account, which has no Fable meter, simply shows none.
`/api/health` carries it as `plan`.

## 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.

Above the chart a row of chips picks the series: **All**, **Session**,
**Weekly all**, and one per model meter your plan has. The choice is remembered
in the browser. The **this week** and **last week** ranges run Monday 01:00 UTC
to Monday 01:00 UTC, the vendor's own weekly reset, not your calendar week.

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`](https://github.com/lexiforest/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`).

Every poll reads your organisation's usage. The spend-limit endpoint is read
once at start, and again only on a poll whose usage payload lacks the spend
figures. Once a day it also reads `/api/bootstrap` for [your plan](#your-plan).

Once a day it asks pypi.org for the latest version (`User-Agent:
quotalens/<version>`, nothing else). Turn it off in Settings or with
`QUOTALENS_NO_UPDATE_CHECK=1`. It never updates itself.

## About and updates

`/about` (the footer's version number links to it) shows the installed version,
the latest one PyPI reported and when it was last asked, the Python and install
method, the data directory, database, profile and port, and the links. **Check
for updates** asks now; it skips the daily schedule but not a one-minute limit
between asks. When a newer release exists the footer adds "X available" and the
settings gear gets a small neutral dot. No banner, no colour.

The page shows the command to run for how you installed it (`pipx upgrade
quotalens`, `uv tool upgrade quotalens` or `pip install -U quotalens`); QuotaLens
never runs it. A failed check (offline, PyPI down) is recorded, shown as
"Couldn't reach PyPI at 14:05", and retried at the next daily slot; it is never
an alert. Only plain release numbers are compared (`2.0.1` over `2.0.0`);
pre-releases are ignored.

`GET /api/version` returns `{current, latest, checked_ts, error,
update_available, upgrade_command}` from what was last stored, and
`POST /api/version/check` asks now. `/api/health` also carries `latest_version`
and `update_checked_ts`.

## 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:

```sh
quotalens prune --dry-run    # what it would remove
quotalens prune --keep 50000 # or set QUOTALENS_SAMPLE_KEEP
```

### How long data is kept

```sh
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
  `VACUUM` and 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.

```sh
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:

```sh
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
  /usage` inside 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.** `pipx` or `uvx` only, 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](https://www.anthropic.com/legal/consumer-terms),
section 3 ("Use of our Services"), prohibit:

> 7. 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.

Two more clauses matter if you run more than one profile: section 2 of the
same Terms (your credentials are yours alone) and the Usage Policy's rule
against using multiple accounts to circumvent product restrictions. See
[Two accounts](#two-accounts).

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.
