EN | 简 | 繁
# Token Monitor Hub — Cloudflare Worker
> Part of **[Token Monitor](https://github.com/Javis603/token-monitor)**. This directory is just the Cloudflare Worker hub; the desktop widget, headless agent, and full docs live in the main repo. A one-click deploy creates a standalone copy that won't auto-update, so check the main repo for new versions.
Drop-in replacement for the self-hosted Node hub, deployed as a Cloudflare
Worker with a Durable Object holding device state. Speaks the same HTTP
protocol (`/api/ingest`, `/api/stats`, `/api/stats/stream`), so the widget and
agent work unchanged — only the Hub URL differs.
Why use this instead of the Node hub:
- No always-on machine. Cloudflare runs it.
- Public HTTPS by default. Reachable across networks and from iOS widgets
(Widgy, Scriptable).
- Free tier covers small-team usage easily.
## Prerequisites
- Cloudflare account (free).
- Node.js 22+ (Wrangler v4 requires `>=22.0.0`).
## Deploy
```bash
cd worker
npm install
npx wrangler login # one-time browser auth
npx wrangler secret put TOKEN_MONITOR_SECRET # paste a long random string
npx wrangler deploy
```
Wrangler prints the deployed URL, for example:
```
https://token-monitor-hub..workers.dev
```
Point each agent and widget at that URL.
### Troubleshooting the one-click deploy
Cloudflare's **Deploy to Cloudflare** button is convenient but has been
intermittently unreliable, in two ways:
- **The deploy page errors that it "can't parse the Wrangler configuration
file"** — a hiccup reading the config from the `worker/` subdirectory.
- **The deployed Worker only responds with plain `Hello world`** — Cloudflare
hit a known import failure and created a repo *without* the Worker source
(only `README.md` + `wrangler.toml`). It reports success, but there's no code
behind it. Reconnect Workers Builds to a repo that contains the full `worker/`
directory, or just deploy manually.
Both are CF-side. The failure tends to be sticky within a browser session, so
first retry the deploy link in a private/incognito window (or a fresh browser).
If it persists, skip the button — the manual `cd worker && npx wrangler deploy`
above always works: same code, without CF's flaky import step.
## Local development
```bash
npm run dev # wrangler dev — local Worker with a real Durable Object
```
Endpoints work the same as in production. Use a separate dev secret with
`wrangler secret put TOKEN_MONITOR_SECRET --env dev` if needed.
## Configure the widget
Settings → Multi-device Sync:
- Hub URL: `https://token-monitor-hub..workers.dev`
- Secret: the value you set with `wrangler secret put`
Save. The status pill should switch from `Local` to `Live` once the SSE stream
connects.
## Configure the agent
Either via `.env` at the project root (copy from `.env.example`):
```env
TOKEN_MONITOR_HUB_URL=https://token-monitor-hub..workers.dev
TOKEN_MONITOR_SECRET=
TOKEN_MONITOR_DEVICE_ID= # optional — defaults to hostname
```
Or by exporting them inline when launching:
```bash
TOKEN_MONITOR_HUB_URL=https://token-monitor-hub..workers.dev \
TOKEN_MONITOR_SECRET= \
npm run agent
```
## iPhone via Widgy or Scriptable
The Worker exposes `GET /api/stats` as plain JSON with CORS open, so iOS
widget runtimes can call it directly. It can also expose `GET /api/public/stats`
without auth for public dashboards when `PUBLIC_STATS_ENABLED=1`; that response
omits per-device records and account identifiers.
To enable the public endpoint:
```bash
npx wrangler secret put PUBLIC_STATS_ENABLED # enter 1
```
Leave it unset to keep `/api/public/stats` disabled.
### Widgy
Pick the **async / no main()** template. Use the `?secret=` query-string
auth — Widgy's invisible WKWebView can trip over the CORS preflight that
`Authorization: Bearer` triggers, and the URL stays on-device in the Widgy
config, so the secret never hits an external log.
Minimum version — just one number:
```js
const HUB = 'https://token-monitor-hub..workers.dev';
const SECRET = '';
const url = HUB + '/api/stats?secret=' + SECRET;
fetch(url)
.then(r => r.json())
.then(s => sendToWidgy(
Number(s.periods.today.totalTokens).toLocaleString('en-US')
))
.catch(e => sendToWidgy('err:' + e.message));
```
Pick any field by changing the final `sendToWidgy(...)` line:
| Want to show | Replace the inner expression with |
|--------------------|------------------------------------------------------------|
| today tokens | `Number(s.periods.today.totalTokens).toLocaleString('en-US')` |
| today cost | `'$' + s.periods.today.costUsd.toFixed(2)` |
| month tokens | `Number(s.periods.month.totalTokens).toLocaleString('en-US')` |
| month cost | `'$' + s.periods.month.costUsd.toFixed(2)` |
| all-time tokens | `Number(s.periods.allTime.totalTokens).toLocaleString('en-US')` |
| all-time cost | `'$' + s.periods.allTime.costUsd.toFixed(2)` |
Combined version with a config block and compact `K / M / B` token
formatting for tight widget layouts:
```js
const HUB = 'https://token-monitor-hub..workers.dev';
const SECRET = '';
const PERIOD = 'today'; // 'today' | 'month' | 'allTime'
const SHOW = 'tokens+cost'; // 'tokens' | 'cost' | 'tokens+cost'
function fmtTokens(n) {
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
return String(Math.round(n));
}
function fmtCost(n) {
return '$' + (n >= 10 ? n.toFixed(2) : n.toFixed(4));
}
fetch(HUB + '/api/stats?secret=' + SECRET)
.then(r => r.json())
.then(stats => {
const p = stats.periods[PERIOD] || { totalTokens: 0, costUsd: 0 };
const t = fmtTokens(p.totalTokens || 0);
const c = fmtCost(p.costUsd || 0);
if (SHOW === 'tokens') sendToWidgy(t);
else if (SHOW === 'cost') sendToWidgy(c);
else sendToWidgy(t + ' · ' + c);
})
.catch(e => sendToWidgy('err:' + e.message));
```
Each Widgy text element gets its own script — duplicate it and change
`PERIOD` / `SHOW` to drive different fields.
### Scriptable
```js
const req = new Request('https://token-monitor-hub..workers.dev/api/stats');
req.headers = { authorization: 'Bearer ' };
const stats = await req.loadJSON();
const todayTokens = stats.periods.today.totalTokens;
```
There is no battery-friendly push channel for iOS widgets, so the runtime
re-fetches every few minutes on its own.
## Stats response shape
`GET /api/stats` returns the aggregated snapshot. Pick whatever fields you
need for your widget:
```jsonc
{
"updatedAt": "2026-05-18T18:02:19.459Z",
"periods": {
"today": { /* see PeriodSummary below */ },
"month": { /* ... */ },
"allTime": { /* ... */ }
},
"limits": {
"updatedAt": "2026-05-18T18:02:19.459Z",
"providers": [
{
"provider": "claude",
"accountKey": "sha256:...",
"sourceDeviceId": "macbook",
"stale": false,
"status": "ok",
"windows": [
{ "kind": "session", "usedPercent": 42, "remainingPercent": 58, "resetsAt": "2026-05-18T21:00:00.000Z" },
{ "kind": "weekly", "usedPercent": 20, "remainingPercent": 80, "resetsAt": "2026-05-25T00:00:00.000Z" }
]
}
]
},
"devices": [
{
"deviceId": "macbook",
"hostname": "macbook.local",
"platform": "darwin-arm64",
"updatedAt": "2026-05-18T18:01:50.000Z",
"receivedAt": "2026-05-18T18:01:51.012Z",
"ageMs": 28447,
"stale": false,
"periods": { "today": {...}, "month": {...}, "allTime": {...} }
}
]
}
```
`PeriodSummary`:
```jsonc
{
"totalTokens": 1234567, // sum across all devices for this period
"costUsd": 12.345678,
"clients": { "claude": 800000, "codex": 400000, "hermes": 34567 },
"clientCosts": { "claude": 8.12, "codex": 4.10, "hermes": 0.12 },
"models": { "claude-opus-4-7": 600000, "gpt-5-thinking-medium": 400000, ... },
"modelCosts": { "claude-opus-4-7": 7.50, "gpt-5-thinking-medium": 4.00, ... }
}
```
Each device also carries its own per-period numbers under
`devices[i].periods[period]` if you want per-device widgets. A device is
marked `stale: true` once its `receivedAt` is older than `STALE_AFTER_MS`
(default 10 min) — handy if you want to show an "offline" state.
`limits.providers` is aggregated by provider account. The authenticated stats
endpoint includes account hashes for de-duplication. When enabled,
`/api/public/stats` strips those hashes, labels, source device ids, and the full
`devices` list.
## Endpoints
| Method | Path | Auth | Description |
|--------|----------------------------|--------|--------------------------------------------|
| GET | `/api/health` | none | Liveness probe + device count |
| GET | `/api/public/stats` | none | Public aggregate stats without devices/account ids when `PUBLIC_STATS_ENABLED=1` |
| GET | `/api/stats` | secret | Aggregated stats (today / month / allTime) |
| GET | `/api/stats/stream` | secret | SSE stream, push on every ingest |
| GET | `/api/devices` | secret | Raw per-device records |
| POST | `/api/ingest` | secret | Upsert a device's usage summary |
| DELETE | `/api/devices/{deviceId}` | secret | Remove a device record |
The secret is accepted three ways (any one works):
1. `Authorization: Bearer ` — preferred for agents, widget, and any
server / desktop client.
2. `x-token-monitor-secret: ` — fallback for clients that cannot set
`Authorization`.
3. `?secret=` query string — workaround for iOS widget runtimes
(Widgy, Scriptable) whose WKWebView struggles with CORS preflight for the
`Authorization` header. Only use this from clients where the URL stays
local to the device.
The secret is required. When `TOKEN_MONITOR_SECRET` is unset, every data route
returns `503 secret_required` — only `/api/health` and the opt-in
`/api/public/stats` respond. Set it before (or during) deploy.
## Storage and cost
Device records live in the Durable Object's SQLite storage, keyed by
`dev:`. Each ingest writes one row; reads aggregate all rows in
memory. For a small team (≤ 10 devices, one ingest per minute per device),
this stays well inside the free plan limits:
- Worker requests: ≤ 15k/day vs free quota of 100k/day
- DO SQL reads/writes: ≤ 30k/day vs free quota of millions
- DO storage: a few KB total
## Tail logs
```bash
npm run tail
```
Streams Worker + Durable Object logs in real time.