A hardware desk gadget that shows your live claude.ai subscription usage — the 5-hour and 7-day rolling limits — on a 20×4 character LCD driven by an ESP32.
┌──────────────────────┐
│ Claude usage │ ← header
│ 5h 82% R 2h48m │ ← live 5-hour window: 82% used, resets in 2h48m
│ 7d 46% R 5d 10h │ ← live 7-day window: 46% used, resets in 5d 10h
│ updated 4s ago │ ← freshness (climbs if the bridge goes away)
└──────────────────────┘
This is an independent, unofficial hobby project. It is not affiliated with, endorsed by, or supported by Anthropic. "Claude" and "Anthropic" are trademarks of Anthropic, used here only nominatively to describe what the tool reads. The project relies on an undocumented internal endpoint of claude.ai that can change or break at any time, and accessing it may be subject to the claude.ai Terms of Service — you are solely responsible for ensuring your use complies. It is intended for one person to monitor their own account's usage, at a polite poll rate, for personal use. Provided as-is, without warranty of any kind.
Almost every other Claude usage monitor reads a different data source than this one does — and that single choice is the main difference between them.
The popular tools (Claude-Code-Usage-Monitor, ccusage, codeburn, and the ESP32
builds on GitHub) read Claude Code's local transcript logs —
~/.claude/projects/**/*.jsonl — and add up token counts. This project instead reads
the usage meter itself, from the same internal endpoint the claude.ai web app calls
to draw its own meter: GET /api/organizations/{org}/usage.
That has three consequences that matter:
| Local-JSONL tools | This project | |
|---|---|---|
| Scope | Claude Code only. Web-app, mobile, and other-tool usage is invisible — even though the rolling limits count all of it. | Everything the limits count, because the number is the account's meter. |
| Accuracy | A token-sum estimate against a plan limit you configure by hand. Tool calls rewrite the same message repeatedly, so totals inflate ~3× unless de-duplicated by message.id. |
The exact percentage claude.ai shows you. Nothing is reconstructed. |
| 7-day window | Not available at all — the logs carry token counts, not a rate-limit meter. | Both windows, 5-hour and 7-day, with real reset times. |
The trade-off is honest: reading the meter means there is no public API to call and no key to issue. The endpoint authenticates with your claude.ai web session cookie, which can't be minted headlessly (SSO / 2FA / Cloudflare) and which an ESP32 could never hold safely or renew. So this project pays for the better data with a server component — a container running a real, logged-in browser. Everything below is the consequence of that one decision.
I wanted a physical, always-on meter for my Claude subscription usage — the exact numbers the claude.ai web app shows in its own usage meter, not an approximation. Glanceable, on the desk, no tab to open.
Concretely, what I needed it to do:
- Show all of my usage, not a slice of it. I use Claude across the web app, Claude Code, and other tools — the meter has to reflect everything the rolling limits actually count, not one client's share.
- Show the real numbers. The exact 5-hour and 7-day figures claude.ai displays in its own meter, not a locally reconstructed estimate.
- Be always-on and glanceable. A dedicated screen on the desk, not a browser tab I have to open.
- Stay self-hosted, local-first, and credential-safe. No third-party service, and my account session must never live on a $5 microcontroller.
That turned out to be much harder than "call an API," and the interesting part of this project is why:
- There is no public API for it. The usage meter is served by an internal,
undocumented endpoint:
GET /api/organizations/{ORG_ID}/usage. There's no API key — it authenticates with your claude.ai web session cookie. - That session can't be minted headlessly. Logging into claude.ai is a browser + human flow (SSO / magic-link / 2FA), often behind a Cloudflare bot challenge. Nothing headless can complete it unattended, and the cookie expires and rotates.
- An ESP32 can't hold that. A WROOM-32 can't realistically maintain TLS-to-Cloudflare plus a rotating session cookie, and it can never re-login.
- Existing projects read the wrong data. The ESP32 usage-meter projects on GitHub read the local Claude Code JSONL logs. That misses everything you do in the web app and other tools — most of the usage the rolling limits actually count.
A Docker container runs a real, logged-in browser that you authenticate once via a web UI. It reuses that live session to poll the usage endpoint and re-serves clean JSON on your LAN. The ESP32 just reads that JSON and prints it.
Re-auth is "open a page and log in like normal." The session persists across restarts, the credential never leaves the container, and the real browser handles Cloudflare/TLS natively. The ESP32 stays dumb — it only ever talks plain HTTP to a credential-free endpoint on your own network.
Those facts add up to one conclusion: the ESP32 is boxed out of talking to claude.ai directly. There's no key to give it; it can't obtain the session cookie (no headless login); it couldn't safely hold that full-account secret in plaintext flash even if it could; and the cookie expires and rotates behind Cloudflare, which a WROOM-32 can't renew and can never re-login to fix.
So something has to sit between claude.ai and the device: an always-on component that owns a logged-in browser, satisfies Cloudflare natively, and re-serves the result as plain JSON. That's the server component. The ESP32 is then free to stay dumb — plain HTTP, no credentials, no TLS, no Cloudflare, and nothing on it to leak.
The server isn't a script — it's a whole stack that has to come up identically every time: real Google Chrome + a virtual display (Xvfb) + a VNC bridge (x11vnc / noVNC, so you log in once) + patchright + two HTTP servers. Docker is how that ships reliably:
- Pins the entire browser stack so a clone builds the same months later — Cloudflare is sensitive to exact Chrome/patchright versions (see Why a real browser and not a script, below).
- Isolates the full-account session inside one unit whose only outward doors are the two ports you publish.
- Survives restarts — the session lives on a mounted volume, so the container comes
back
LOGGED_INwith no re-login. - Runs headless on a homelab/server (Raspberry Pi, k3s, Proxmox), not just the desktop you happen to be sitting at.
| Approach | Verdict | Why |
|---|---|---|
| ESP32 reads local Claude Code JSONL via a PC bridge | ❌ | Only captures Claude Code usage — misses web app + other tools. |
| ESP32 calls the endpoint directly with a hardcoded cookie | ❌ | Cloudflare/TLS flaky on WROOM-32; full-account cookie in plaintext flash; expiry forces a re-flash; device can't re-login. |
| PC bridge holding a manually pasted cookie | ❌ | Manual cookie paste on every expiry; no visibility of expiry. |
| Container + real browser, human logs in via web UI, session persists | ✅ | Re-auth is a normal login; session persists; credential stays in the container; browser handles Cloudflare natively. |
The first row is worth spelling out, because it's the design most existing ESP32
"Claude usage" projects on GitHub actually ship — including this project's own first
draft. The device reads Claude Code's local transcript logs
(~/.claude/projects/**/*.jsonl) through a small PC bridge. For my requirements it
fails three ways:
- It only sees Claude Code, so all my web-app and other-tool usage is invisible — which is most of what the rolling limits actually count.
- Those logs carry token counts, not a rate-limit meter. The 5-hour figure is a token-sum estimate against a configured plan limit, and there is no 7-day number at all.
- The same message is rewritten several times during tool calls, so without
de-duplicating by
message.idthe totals inflate roughly 3×.
It's a clever local hack, but it can't show the actual meter — which was the whole point.
┌──────────────────────── Docker bridge (your PC / homelab) ─────────────────────┐
│ │
│ Control UI ◀── noVNC ──▶ real Google Chrome (patchright, persistent profile)│
│ :8080 (you log in once) │ reuses live session │
│ │ ▼ │
│ │ GET /api/organizations/{org}/usage │
│ │ │ │
│ poll loop + cache + state ◀───────┘ (in-page same-origin fetch) │
│ │ │
│ Device JSON API :8787 → { five_hour, seven_day, data_age_s, ... } │
└──────────────────────────────────────┬──────────────────────────────────────────┘
│ LAN, plain HTTP
▼
┌────────────────────────────┐
│ ESP32-WROOM-32 + QC2004A LCD│ polls :8787, prints 20×4
└────────────────────────────┘
Two components, each independently versioned against the frozen :8787 JSON contract:
bridge/— the Docker container: a real logged-in Chrome + a poll loop + two HTTP servers (control UI and device API). See the as-built bridge README for the full picture.firmware/— the ESP32 MicroPython firmware + LCD drivers. See the firmware README.
The authoritative design/spec is plan.md.
claude.ai is behind Cloudflare, and a normal automated browser (Playwright +
Chromium) is detected and blocked — the challenge loops forever even for a human. The
bridge uses patchright (a
patched Playwright that removes the Runtime.enable CDP leak and automation
fingerprints) driving real Google Chrome (not Chromium, which fails Cloudflare's
codec/branding checks). This is what makes a one-time human login actually succeed.
The stable shape the firmware depends on (frozen; schema bumps only on a breaking
change):
{
"schema": 1,
"ok": true,
"stale": false,
"state": "LOGGED_IN",
"five_hour": { "pct": 82, "resets_at": "2026-07-07T15:09:59Z", "resets_in": "2h48m" },
"seven_day": { "pct": 46, "resets_at": "2026-07-12T22:59:59Z", "resets_in": "5d 10h" },
"fetched_at": "2026-07-07T12:01:37Z",
"data_age_s": 4
}data_age_s is seconds since the bridge last successfully read from claude.ai
(not since the device polled). It resets on a fresh read and climbs whenever the
session is dead or the device loses the bridge — so a frozen display can never look
fresh. Full contract in bridge/README.md.
| Part | Notes |
|---|---|
| ESP32-WROOM-32 dev board (30-pin DevKit V1) | Any common devkit. |
| QC2004A 20×4 LCD | HD44780 on a PCF8574 I²C backpack (0x27, sometimes 0x3F). |
| Jumper wires | SDA→GPIO25, SCL→GPIO26, VCC→5V, GND→GND. |
⚠️ Contrast / power: this unit runs the LCD at 5 V because 3.3 V left the contrast too faint to read on this panel. At 5 V the backpack's I²C pull-ups idle above the ESP32's 3.6 V GPIO limit, so a bidirectional I²C level shifter on SDA/SCL is the correct permanent fix (or move the pull-ups to 3.3 V). And set the contrast trimpot first — a fresh 2004A almost always ships blank, and the pot is often multi-turn (needs many rotations). Full wiring + the exact as-built state: docs/AS-BUILT.md and firmware/README.md.
cd bridge
cp .env.example .env # set UI_PASSWORD (required)
docker compose up -d --build # first build installs Chrome (a few minutes)Open http://localhost:8080, log in, click “Open login (noVNC)”, and sign in to claude.ai in the embedded browser. If your account has multiple organizations, pick the one with your subscription. Then:
curl http://localhost:8787/ # → LOGGED_IN with your real percentagescd firmware
# Flash MicroPython v1.22.2 (ESP32_GENERIC) with esptool, then:
cp config.example.py config.py # set WiFi + the bridge's LAN IP
mpremote connect COM5 fs cp lcd_api.py esp8266_i2c_lcd.py config.py boot.py main.py :
mpremote connect COM5 resetFull flashing/wiring instructions: firmware/README.md.
claude-usage-lcd/
├── README.md ← you are here (project overview)
├── plan.md the detailed design spec / build brief
├── bridge/ Component A — Docker container
│ ├── README.md as-built reference (architecture, security, troubleshooting)
│ ├── Dockerfile · docker-compose.yml · entrypoint.sh · requirements.txt
│ └── app/ one asyncio app: browser, poller, device API, control UI
├── firmware/ Component B — ESP32 MicroPython
│ ├── README.md wiring, flashing, hardware matrix
│ ├── main.py · boot.py · config.example.py
│ └── lcd_api.py · esp8266_i2c_lcd.py (third-party dhylands drivers, unmodified)
└── docs/ wiring / diagrams
├── AS-BUILT.md as-built hardware record (this unit's exact config)
└── esp32-pinout.html pinout card
This is a local-first, self-hosted tool. Usage data flows only
claude.ai → your container → your LAN → your device. No telemetry, no third party,
and the account session never leaves the container.
The container holds a full-account claude.ai session, so the sensitive surface is treated carefully:
:8080(control UI + noVNC) is the sensitive door. It is fail-closed (won't start unauthenticated on a non-loopback bind), uses a session-cookie login, and gates the noVNC websocket behind that same login. Bind it LAN/localhost only — never expose it to the internet.:8787(device API) is intentionally unauthenticated plain HTTP and carries only non-sensitive usage percentages + reset times — never the cookie or any credential. That's what makes plain-HTTP-on-the-LAN acceptable for the ESP32.- Secrets (
config.py,.env, the browser profile) are gitignored and live only on your machine / a mounted volume.
Please be a good citizen against the upstream endpoint: keep POLL_INTERVAL
conservative (default 30 s), and don't build anything that fans out across many
accounts or hosts a shared instance. This is one human watching one account.
Both halves are implemented and verified end-to-end:
- ✅ Bridge: real Chrome login via noVNC, live
LOGGED_INdata, session + org persistence acrossrestart/--force-recreate, multi-org picker, fail-closed auth, Cloudflare handled via patchright + Chrome. - ✅ Firmware: WiFi + poll loop + 20×4 rendering + data-age view + watchdog + the degraded-state screens.
The one step that can't be automated is a human clicking through claude.ai's login.
- Per-model (
opus/sonnet) breakdown on a larger display /healthendpoint for uptime monitoring- Optional buzzer/LED when the 5h window crosses a threshold
- Kubernetes manifests (Deployment + PVC) as an alternative to compose
Issues and PRs welcome. Because failure modes are hardware-specific, bug reports should include: ESP32 board, MicroPython version, LCD model + PCF8574 address, and bridge version/logs.
Never commit a live session. The Playwright/Chrome profile, config.py, and
.env must stay out of git — only the *.example.* templates are tracked. Running a
secret scanner (e.g. gitleaks) before pushing is strongly recommended.
- This project's own code: MIT (see
LICENSE). firmware/lcd_api.pyandfirmware/esp8266_i2c_lcd.pyare unmodified files from Dave Hylands'dhylands/python_lcd, used under the MIT License — keep their headers and attribution intact.- Runtime stack credit: patchright, Playwright, noVNC, Chromium/Chrome, aiohttp.
"Claude" and "Anthropic" are trademarks of Anthropic, used nominatively only. This project is not affiliated with Anthropic.
Built as an exercise in getting a genuinely awkward data source — an undocumented, Cloudflare-protected, human-auth-only endpoint — onto a $5 microcontroller's screen, without ever putting account credentials on the device.