Throttle — Technical Reference
Throttle is a Chrome extension paired with a private backend. The extension observes the quota indicators already rendered by Claude and Lovable, converts them into a continuous time series, and syncs them to your account when paired. This page documents the full technical surface: architecture decisions, the data model, the pairing flow, and the API reference.
If you are looking for privacy disclosures, see /privacy. If you are looking for research methodology, see /research.
Architecture
Throttle is designed around one constraint: the extension should be useful without sending anything anywhere.
The default state is local-only. Snapshots are written to the extension's IndexedDB store in the browser. No account is required, no network call is made, and the dashboard at tokenmaxx.me is irrelevant until the user explicitly pairs.
Cloud sync is a second layer that activates only after explicit pairing. Once paired, the extension batches numeric snapshots and uploads them to the tokenmaxx backend over HTTPS. The transport carries numeric usage data only — no prompt content, no chat content, no DOM text outside the quota surface.
Aggregate research contribution is a third, separate toggle, off by default, revocable at any time. See /research for what that dataset includes and excludes.
Why Shadow DOM for the speedometer overlay. The speedometer UI element that attaches to Claude and Lovable pages is rendered inside a Shadow DOM root. This isolates it completely from the host page's stylesheets and scripts — the overlay cannot be styled or queried by the host, and it cannot read or mutate the host page in return. It is an instrument bolted onto the outside of the window, not wired into the interior.
Why a tolerant parser. Claude and Lovable change their UI without notice. The extension's quota reader is written as a tolerant parser: it targets the numeric surface (percentages, counters, reset timestamps) using selectors that degrade gracefully rather than fail hard when markup changes. When the parser cannot find a quota indicator, it records a gap rather than a malformed value. Gaps are visible in the dashboard and in /stats.
Why IndexedDB for local storage. chrome.storage.local has a default quota of around 10 MB and is synchronous in its API design. IndexedDB is async-first, quota is negotiated with the browser (typically several hundred MB), and it supports structured queries needed for the local graph rendering. Snapshots are keyed by (provider_id, t) and deduplicated on insert.
Multi-provider design. The data model is provider-agnostic from the first write. provider is a string field ("claude", "lovable", "other"). The same data JSONB schema accepts any quota surface. OpenAI and Gemini are on the roadmap; adding them requires a new parser module and no schema migration.
Data model
A snapshot is a single numeric read of a quota surface at a point in time. It contains no content — no prompt, no response, no code, no filename. The data field is a JSONB object whose shape is determined by the provider parser.
snapshot {
provider: string — "claude" | "lovable" | "other"
provider_id: string — stable upstream account identifier
label: string? — user-assigned name ("Personal", "Work")
plan: string? — plan label when present in the UI
t: integer — Unix timestamp in milliseconds
data: object — numeric quota reads (provider-specific)
}The data object for Claude captures the percentage of the active quota window consumed, the reset timestamp when visible, and the plan identifier when present. It does not capture message content, model name, or any session metadata. The equivalent for Lovable captures the credit counter displayed in the UI.
What provider_id must be. The provider_id is the uniqueness key per upstream account. It must be derived from the upstream account, not from the browser or the extension install. A random UUID stored in chrome.storage is wrong — the same Claude account in two browsers would produce two unrelated account rows. A constant string like "default" is wrong — two different Claude accounts would collapse into one row, and collisions are silently deduplicated (data is dropped without an error). The correct value is the upstream account UUID, org ID, or — as a last resort — a SHA-256 hash of the lowercased account email.
Deduplication. Re-uploads with the same (account_id, t) are silently dropped. This makes retry safe: the extension can re-upload a batch after a network error without creating duplicate rows.
Retention. Cloud snapshots are retained indefinitely unless the user deletes them from Data Controls. Per-account retention can be configured; retention_days: null means no automatic expiry.
Pairing flow
Pairing links one browser installation to one tokenmaxx account. It is a one-time exchange: a short-lived code becomes a long-lived API key stored in chrome.storage.local.
One code per browser, one key per browser. A pairing code is single-use and expires after 15 minutes. Each browser or profile that should sync independently must go through its own pairing. Multiple browsers can pair to the same account — each gets its own API key, visible and revocable from the Access tab in the dashboard.
Three ways to complete the pairing:
- Manual paste. User generates a code in the dashboard, copies it, and pastes it into the extension popup.
- Deep link. User clicks
https://tokenmaxx.me/pair#code=…(also accepts?code=). The page broadcasts the code to the extension viawindow.postMessageon the same origin. - DOM element. The same page exposes the code on
[data-throttle-pair-code]for extensions that prefer attribute scraping over message events.
Redeeming the code
POST https://tokenmaxx.me/api/public/pair
content-type: application/json
{
"code": "PASTE_FROM_DASHBOARD",
"label": "Chrome desktop"
}{
"ok": true,
"api_key": "tk_live_…",
"prefix": "tk_live_…",
"user_id": "uuid"
}Failure modes: 404 invalid_code, 410 expired, 410 already_redeemed.
Content script: auto-capture from the deep link
// content_script.js — runs on https://tokenmaxx.me/pair*
window.addEventListener("message", (e) => {
if (e.origin !== "https://tokenmaxx.me") return;
if (e.data?.type !== "throttle:pair") return;
chrome.runtime.sendMessage({ type: "PAIR", code: e.data.code });
});
const el = document.querySelector("[data-throttle-pair-code]");
if (el) chrome.runtime.sendMessage({ type: "PAIR", code: el.dataset.throttlePairCode });Background service worker: redeem and persist
// background.js
chrome.runtime.onMessage.addListener(async (msg) => {
if (msg.type !== "PAIR") return;
const res = await fetch("https://tokenmaxx.me/api/public/pair", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ code: msg.code, label: "Chrome desktop" }),
});
const json = await res.json();
if (!json.ok) throw new Error(json.error);
await chrome.storage.local.set({
throttle_api_key: json.api_key,
throttle_user_id: json.user_id,
});
});Required manifest.json entries: "host_permissions": ["https://tokenmaxx.me/*"], "permissions": ["storage"], and a content script matching "https://tokenmaxx.me/pair*".
After pairing, call GET /api/public/whoami to confirm the key is valid before the first upload.
API reference
All endpoints live under https://tokenmaxx.me/api/public. Every endpoint accepts CORS preflight — the extension can call them directly from a content or background script without a proxy.
Authentication
Every endpoint except /pair requires a bearer token:
Authorization: Bearer tk_live_…
Keys are hashed at rest (SHA-256) and scoped to the issuing user. Revoking a key from the Access tab in the dashboard invalidates it immediately — the next request with that key returns 401 unauthorized.
Whoami
Cheap auth probe. Call after pairing, or whenever a saved key may have been revoked.
GET https://tokenmaxx.me/api/public/whoami authorization: Bearer tk_live_…
{ "ok": true, "user_id": "uuid", "key_id": "uuid" }Upload snapshots
Snapshots are append-only. Throttle upserts the account record on first upload, keyed on (user_id, provider_id).
POST https://tokenmaxx.me/api/public/snapshots
authorization: Bearer tk_live_…
content-type: application/json
{
"provider": "claude" | "lovable" | "other",
"provider_id": "user@example.com",
"label": "Personal",
"plan": "pro",
"snapshots": [
{ "t": 1738305600000, "data": { } }
]
}{
"ok": true,
"accepted": 2,
"duplicates": 0,
"account_id": "uuid"
}Constraints. 1–2,000 snapshots per request. Body capped at 1 MB (413 payload_too_large otherwise). Each data object is stored verbatim as JSONB. provider_id must be stable — see the data model section above.
Recommended client behaviour. Batch on a timer (every N seconds). Retry with exponential backoff on 5xx and network errors. Drop the batch on 400 — malformed payloads will not succeed on retry. Prompt the user to re-pair on 401.
async function upload(snapshots) {
const { throttle_api_key } = await chrome.storage.local.get("throttle_api_key");
const res = await fetch("https://tokenmaxx.me/api/public/snapshots", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${throttle_api_key}`,
},
body: JSON.stringify({
provider: "claude",
provider_id: await providerIdForClaude(), // see provider_id guidance above
snapshots,
}),
});
const json = await res.json();
if (!json.ok) throw new Error(`${res.status} ${json.error}`);
return json;
}Read snapshots
GET https://tokenmaxx.me/api/public/snapshots authorization: Bearer tk_live_…
Query params (all optional): account_id, provider, provider_id, since (ms, inclusive), until (ms, inclusive), cursor (ms, exclusive — for pagination), limit (1–1000, default 100). Results are sorted by t descending.
{
"ok": true,
"snapshots": [
{ "id": 42, "account_id": "uuid", "t": 1738305660000, "data": {}, "uploaded_at": "ISO" }
],
"next_cursor": "1738305660000"
}Batch read — multiple accounts
Pull snapshots for up to 50 accounts in a single round-trip. Use this for incremental sync instead of looping GET /snapshots?account_id=…. Results are sorted ascending by t (opposite of single-account GET).
POST https://tokenmaxx.me/api/public/snapshots/multi
authorization: Bearer tk_live_…
content-type: application/json
{
"accounts": [
{ "account_id": "uuid-a", "since": 1738305600000 },
{ "account_id": "uuid-b", "since": 1738305600000 },
{ "account_id": "uuid-c", "since": 0 }
],
"limit_per_account": 2000,
"until": 1738400000000
}{
"ok": true,
"until": 1738400000000,
"results": [
{
"account_id": "uuid-a",
"snapshots": [
{ "id": 42, "t": 1738305660000, "data": {} }
],
"truncated": false,
"next_since": null
},
{
"account_id": "uuid-b",
"snapshots": [],
"truncated": true,
"next_since": 1738350000000
}
]
}since is exclusive (ms); 0 returns full history within retention. Per account: t > since AND t <= until, ordered ASC, capped at limit_per_account (1–5000, default 2000). The top-level until is echoed back — use it as the next since floor on subsequent calls so writes that arrive mid-request are not missed. truncated: true means the account has more data; resume with since = next_since for that account. Unowned or unknown account IDs are silently dropped (no 403). Body capped at 64 KB.
List accounts
GET https://tokenmaxx.me/api/public/accounts authorization: Bearer tk_live_…
{
"ok": true,
"accounts": [
{
"id": "uuid",
"provider": "claude",
"provider_id": "user@example.com",
"label": "Personal",
"plan": "pro",
"retention_days": null,
"created_at": "ISO",
"snapshot_count": 1234,
"latest_t": 1738305660000
}
]
}Export (NDJSON stream)
GET https://tokenmaxx.me/api/public/snapshots/export?account_id=uuid authorization: Bearer tk_live_…
Returns application/x-ndjson. Streamed in pages of 1,000, newest-first. Suitable for piping into jq, DuckDB, or any NDJSON-aware tool.
curl -s -H "authorization: Bearer tk_live_…" \ "https://tokenmaxx.me/api/public/snapshots/export?account_id=uuid" \ | jq '.data'
Multiple accounts and browsers
One Throttle user can own many upstream accounts (e.g. two Claude logins) and run the extension in many browsers simultaneously.
Two browsers, same upstream account. Each browser pairs once (two API keys, both valid). Each upload uses the same provider_id derived from that upstream account. Throttle records one account row, and both browsers contribute snapshots to the same stream. Users can rename the account from the Sync tab.
Two browsers, two upstream accounts. Each browser pairs once. Each upload uses a different provider_id. Throttle records two account rows under the same provider. Both are visible in the dashboard and have independent snapshot streams.
Deriving provider_id correctly. Use the most stable identifier the upstream surface exposes, in this order:
- Account or user UUID from an authenticated API endpoint.
- Org ID + member ID.
- SHA-256 of the lowercased email — only as a last resort, and only if the email will not change.
async function providerIdForClaude() {
const me = await fetch("https://claude.ai/api/account").then(r => r.json());
return me.account_uuid;
}Never use crypto.randomUUID() stored in chrome.storage, the literal string "default", or any browser-local or install-local value. The failure modes are silent and hard to recover from: browser-local IDs produce duplicate account rows; constant IDs cause data to be dropped without error.
End-to-end flow
- User opens the dashboard, generates a pairing code.
- Extension picks up the code via
postMessage, DOM element, or manual paste. - Background worker
POSTs to/api/public/pair, storesapi_keyinchrome.storage.local. - Extension calls
GET /api/public/whoamito confirm. - On a timer, extension batches snapshots and
POSTs to/api/public/snapshots. - To rehydrate local graphs across many accounts, extension calls
POST /api/public/snapshots/multionce per sync tick (ascending order, single round-trip). - Dashboard and scripts read with
GET /api/public/snapshotsor stream via/snapshots/export.
Error reference
All endpoints return a uniform envelope. ok: true on success; ok: false + error code on failure. Treat unknown error codes as fatal.
{ "ok": false, "error": "unauthorized", "detail": "optional human-readable string" }| Status | Error | When |
|---|---|---|
| 400 | invalid_body | Body failed schema validation |
| 401 | unauthorized | Missing, invalid, or revoked bearer key — re-pair |
| 404 | invalid_code | Pairing code not found |
| 410 | expired | Pairing code older than 15 minutes |
| 410 | already_redeemed | Pairing code already used |
| 413 | payload_too_large | Body exceeds 1 MB |
| 500 | query_failed / insert_failed | Server-side DB error — retry with exponential backoff |
Auditing this extension
Throttle is open source. The repository is at github.com/lucioamorim/throttle. The build is reproducible. You can inspect what the extension reads before installing it, build it locally against the same backend once paired, and verify that no content surface is reachable from the parser code.
The API schema above is the complete contract. There is no undocumented endpoint, no analytics pixel, and no third-party SDK in the extension bundle. If the schema here and the code in the repository diverge, that is a bug — file an issue.
Contributing
Parser maintenance is the most frequent contributor path. When Claude or Lovable updates their UI and breaks the quota reader, a fix usually means updating selectors or tolerant fallbacks in the parser module. The repository README has instructions for running the extension locally against a development backend.
Bug reports, parser fixes, and new provider implementations are welcome via pull request. Feature proposals that require storing content — any content — will be declined. That constraint is not a roadmap item; it is the architecture.