Skip to content

Repository files navigation

Claude Usage LCD

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.

status bridge firmware license affiliation

 ┌──────────────────────┐
 │ 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)
 └──────────────────────┘

⚠️ Disclaimer — read before use or publishing

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


What makes this different

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.


Why this exists

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.

The path we chose

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.

Why there has to be a server component

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.

Why that server is a Docker container

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_IN with no re-login.
  • Runs headless on a homelab/server (Raspberry Pi, k3s, Proxmox), not just the desktop you happen to be sitting at.

Alternatives considered (and why rejected)

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.id the totals inflate roughly .

It's a clever local hack, but it can't show the actual meter — which was the whole point.


How it works

┌──────────────────────── 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:

The authoritative design/spec is plan.md.

Why a real browser and not a script

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 device contract (:8787)

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.


Hardware

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.


Quickstart

1. Bridge (Docker)

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 percentages

2. Firmware (ESP32)

cd 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 reset

Full flashing/wiring instructions: firmware/README.md.


Repository structure

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

Security & privacy

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.


Project status

Both halves are implemented and verified end-to-end:

  • ✅ Bridge: real Chrome login via noVNC, live LOGGED_IN data, session + org persistence across restart / --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.

Roadmap / nice-to-haves

  • Per-model (opus / sonnet) breakdown on a larger display
  • /health endpoint for uptime monitoring
  • Optional buzzer/LED when the 5h window crosses a threshold
  • Kubernetes manifests (Deployment + PVC) as an alternative to compose

Contributing

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.


License & attributions

  • This project's own code: MIT (see LICENSE).
  • firmware/lcd_api.py and firmware/esp8266_i2c_lcd.py are 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.


Acknowledgements

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.

About

A desk gadget that shows live claude.ai subscription usage (5-hour + 7-day rolling limits) on a 20x4 LCD driven by an ESP32 — reading the real usage meter, not Claude Code log estimates.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages