An always-on usage strip for a Corsair XENEON EDGE (2560x720) driven by a Mac.
It shows the rate-limit windows of every Claude account managed by
cswap and of the local
Codex login: 5-hour session, weekly, and
model-scoped limits, each with reset time, countdown, and a pace tick. The
Codex card also charts tokens per day for the last two weeks and shows each
"Full reset" credit as a ticket stub with its expiry. A Claude card shows the
same stubs for promotional usage-limit resets (what Claude Code offers as
/limit-reset) when cswap reports them: that needs a cswap with the
usage.resetGrants setting (see the fork note under Requirements).
The dashboard never touches a token. cswap owns the Claude credentials and
paces the upstream requests; the codex CLI owns the ChatGPT credential and
answers over its local JSON-RPC app-server. The collector only reads their
output.
cswap list --json ─┐
├─▶ collector (Node, 127.0.0.1:8790) ─▶ /api/usage.json ─▶ web/ (Chrome kiosk on the EDGE)
codex app-server ──┘
Hammerspoon watches the display set: it opens the kiosk when the EDGE appears and closes it when the EDGE is unplugged.
- macOS with System Settings → Desktop & Dock → Mission Control → "Displays have separate Spaces" turned ON (log out and in after changing it). Without it, Chrome's kiosk fullscreen blanks the other displays.
- Node 22 or newer.
- cswap with your Claude accounts added
(
cswap list --jsonmust work). Claude's promotional usage-limit resets need a cswap that reportsresetGrantsin its JSON. Until claude-swap#391 lands, that is thefeat/reset-grantsbranch of the hSATAC fork, withcswap config set usage.resetGrants true. Without it the Claude cards simply show no reset stubs. Run the same cswap everywhere (menu bar,watch, this collector): the usage cache is shared, and a poll by a cswap without the setting drops the grants until the next poll. - Codex CLI logged in (
codex app-servermust work). - Google Chrome.
- Hammerspoon to open and close the kiosk with the display (optional;
scripts/start-kiosk.shalso works by hand).
cp config.example.json config.json # edit labels and plan text
node collector/server.js # http://127.0.0.1:8790/ (stays in the foreground)
scripts/start-kiosk.sh # in another terminal: opens Chrome kiosk on the 2560x720 displayIf the kiosk shows a "site can't be reached" page, the collector is not running: the page is served by the collector itself.
config.json keys:
| Key | Meaning |
|---|---|
host, port |
Where the collector listens. Keep 127.0.0.1 unless you want LAN access. |
refreshSeconds |
Collector poll interval. cswap still paces upstream calls to at most one per 180 s per account. |
claude.command |
Path to cswap. |
claude.accounts.<alias or number> |
Optional label and plan text shown per account. Accounts not listed use their cswap alias. |
codex.command |
Path to codex. |
codex.label |
Account label shown in the Codex card. |
display.width, display.height |
Size of the target display; the kiosk script finds it by size. |
display.chrome |
Path to the Chrome binary. |
Collector: a LaunchAgent keeps it running.
scripts/install-launchd.sh # installs com.edge-usage-dashboard.collector
scripts/install-launchd.sh --uninstallLogs go to ~/Library/Logs/edge-usage-dashboard/collector.log.
Kiosk: one line in ~/.hammerspoon/init.lua (adjust the path to where you cloned the repo).
edgeKiosk = dofile(os.getenv("HOME") .. "/projects/edge-usage-dashboard/hammerspoon/edge-kiosk.lua").start()hammerspoon/edge-kiosk.lua runs scripts/start-kiosk.sh when a display named
XENEON EDGE (or of the configured size) is present and no kiosk Chrome is
running, and kills the kiosk Chrome when the display disappears, so a
fullscreen window never lands on your main display. Events are debounced by
2 s. Chrome is started detached from Hammerspoon, so reloading the Hammerspoon
config does not close the kiosk. Override any field through start({ ... }), for example
start({ screenName = "My Panel" }). From a shell, hs -c "edgeKiosk.reconcile()"
re-checks, hs -c "edgeKiosk.restartKiosk()" reloads the kiosk (for example after
editing web/), and hs -c "edgeKiosk.stopKiosk()" closes it.
GET /api/usage.json
{
"generatedAt": "2026-09-14T10:09:13.685Z",
"refreshSeconds": 60,
"claude": {
"accounts": [
{
"id": "claude:1", "label": "company", "plan": "Team · Max 5x", "active": true, "status": "ok",
"session": { "usedPercent": 75, "resetsAt": "…", "windowMinutes": 300, "pace": null },
"weekly": { "usedPercent": 20, "resetsAt": "…", "windowMinutes": 10080,
"pace": { "expectedPercent": 93.5, "aheadOfPace": false, "projectedExhaustionAt": "…", "willLastToReset": true } },
"scoped": [ { "name": "Fable", "usedPercent": 34, "resetsAt": "…", "windowMinutes": 10080, "pace": null } ],
"resetCredits": { "available": 1, "credits": [ { "expiresAt": "…" } ] },
"fetchedAt": "…"
}
],
"error": null, "updatedAt": "…"
},
"codex": {
"label": "personal",
"data": {
"plan": "prolite",
"primary": { "usedPercent": 13, "resetsAt": "…", "windowMinutes": 10080, "pace": null },
"secondary": null,
"limits": [ { "id": "codex_<model>", "name": "<model display name>", "primary": { "…": "…" }, "secondary": { "…": "…" } } ],
"resetCredits": { "available": 3, "credits": [ { "expiresAt": "…" } ] },
"fetchedAt": "…"
},
"usage": { "daily": [ { "date": "2026-09-17", "tokens": 6801234 } ], "streakDays": 4, "fetchedAt": "…" },
"error": null, "updatedAt": "…"
}
}claude.accounts[].resetCredits has the same shape as the Codex one, with one
credit per remaining promotional reset; it is null when the account holds
none or cswap does not report them. codex.data.limits lists the model-scoped
limits the backend returns besides the main one (empty when there are none). codex.usage comes from
account/usage/read, polled every 10 minutes: one bucket per day with usage,
covering the last 30 days, and the backend rolls up whole days, so the newest
bucket is normally yesterday.
Errors from either source are reported next to the last good data, never in place of it. The page shows an amber "updated … · cswap error" marker when a source fails or the collector has not produced data for three refresh intervals, and a "data 23m old" note on a Claude card when cswap has been serving the same reading for more than 15 minutes (it backs off after a 429).
The page is authored at 2560x720 (1x; the EDGE has no usable HiDPI mode on
macOS) and scales to fit any other viewport, so it also works in a normal
browser window. mockups/ holds the design artboards it was built from; they
are Claude Design canvas files, and the ./support.js they reference is that
editor's runtime, so a browser logs a missing-script error but still renders
them.
account/rateLimits/updatednotifications fromcodex app-serverare applied immediately when they arrive; the 60 s poll is the baseline.- Touch on the EDGE under macOS needs a third-party single-touch driver (for example edgewise); this project has no touch controls yet.
MIT. See LICENSE.
