I kept hitting my Claude usage limit mid-task with zero warning. So I built a tiny macOS menu-bar app — Claude Meter — that shows my session and weekly usage as a Dynamic Island–style pill, with live reset countdowns.
It's open source and you can grab it at claude.sanchitkd.com.
The finished thing is small. Getting there was not. This post is the messy middle — the dead ends, the wrong abstraction I committed to for too long, and the three macOS gotchas that nearly shipped a broken build. If you're building a native menu-bar utility that reads from a web service, you'll probably hit the same walls.
The first design was wrong: scraping a TUI
Claude has no public usage API. My first instinct was to read what I could already see: claude /usage prints a nice little usage screen in the terminal.
So I built — and I want you to appreciate the commitment here — a hand-rolled PTY runner and a VT100 screen emulator to capture that TUI's output. Spawn the command in a pseudo-terminal, interpret the escape codes, reconstruct the rendered screen, parse the numbers off it.
It worked. It was also the wrong abstraction, and it taught me the usual lesson: scraping a presentation layer is a promise that the presentation layer will never change. When the command's output shifted, my parser produced garbage. I was maintaining a tiny terminal emulator to read two percentages. That's a smell.
The deeper problem: I was reading the rendering of the data instead of the data.
The pivot: read the same endpoint the website uses
The claude.ai web app obviously gets these numbers from somewhere. A few minutes in the browser's network tab and there it was:
GET https://claude.ai/api/organizations/{org}/usage
Returns clean JSON:
{
"five_hour": { "utilization": 67.0, "resets_at": "2026-…+00:00" },
"seven_day": { "utilization": 5.0, "resets_at": "2026-…+00:00" }
}
five_hour is the session window, seven_day is the weekly. The {org} is resolved at runtime from GET /api/organizations (first org's UUID). Authentication is just your existing sessionKey cookie. Delete the PTY emulator, read the source of truth. Much better.
Except.
Gotcha #1: Cloudflare won't let URLSession in
The obvious implementation is URLSession with the cookie. It fails. The endpoint sits behind Cloudflare's bot protection, and a plain programmatic request gets challenged — you don't get JSON, you get a challenge page.
The fix that worked: don't impersonate a browser, be one. I host a headless WKWebView, load claude.ai once so it carries the real cookies and clears any challenge, then run the request from inside the page's own origin using callAsyncJavaScript to invoke fetch():
let js = """
const r = await fetch('/api/organizations/\\(org)/usage', { credentials: 'include' });
return await r.text();
"""
let result = try await webView.callAsyncJavaScript(
js, arguments: [:], in: nil, contentWorld: .page
)
Because the call originates from the actual page context with the actual session, Cloudflare sees a legitimate same-origin request. No reverse-engineering, no header spoofing — just letting the webview be the thing it already is.
A side benefit: login is just a real claude.ai login window. I poll the cookie store for sessionKey, and when it appears, close the window and refresh. (Google SSO can't work here — Google blocks OAuth inside embedded webviews by policy — so it's email sign-in only. Worth knowing before you build a login flow around a webview.)
Gotcha #2: color interpolation made everything grey
I wanted the pill to shift color with usage — green when you're fine, red when you're close. My first version interpolated smoothly across a hue gradient. It looked terrible. Around 68%, blending blue into yellow produced a muddy grey that read as "broken," not "busy."
The fix was to stop being clever: discrete buckets, not interpolation.
<20 green · 20–40 cyan · 40–60 blue · 60–75 yellow · 75–85 orange · 85–95 red · 95–100 dark red · 100 black
Plus a luminance check to flip the text black/white for contrast on each background. A glance now tells you the state without you reading the number. Sometimes the legible design is the un-clever one.
Gotcha #3: swift run lied to me about crashes
Late in the build, the app started segfaulting on launch. I lost real time to this before realizing: it only crashed under swift run. The actual .app bundle never crashed — it was a GUI/WebKit artifact of launching through the SwiftPM runner.
Lesson, now taped to my monitor: for a GUI app, always test the bundle, never swift run. Two other crashes were real and more instructive:
- A login-window crash (a Core Animation commit) — fixed by simplifying the window to a plain
WKWebViewas thecontentViewinstead of a hand-laid-out container. - A SwiftUI crash from clamping a value inside a
@Publishedproperty'sdidSet. Moving the clamp out ofdidSetfixed it. If you mutate published state from within its own observer, you can re-enter the update loop.
The part everyone downloading it hit: "damaged and can't be opened"
The app ran perfectly on my machine and then told every downloader it was "damaged and can't be opened."
Here's the chain. On Apple Silicon, a binary must be at least ad-hoc signed to run. SwiftPM does sign the executable. But my build script assembled the .app and wrote Info.plist after, so while the inner binary was signed, the bundle was never sealed. An unsigned, unsealed bundle + the download quarantine flag = Gatekeeper can't evaluate it = "damaged" (the scariest of its messages).
Two-part fix in the build script, in this order:
xattr -cr "$APP_PATH" # strip extended attrs (or zip carries ._ AppleDouble cruft that itself breaks the seal)
codesign --force --sign - "$APP_PATH" # ad-hoc sign the WHOLE bundle, last
codesign must be the final mutation — anything you change afterward breaks the seal. After sealing, the message downgrades from "damaged" to the normal "Apple could not verify…" gate, which users can clear via System Settings → Privacy & Security → Open Anyway (or one Terminal command, xattr -dr com.apple.quarantine). Proper Developer ID notarization removes the friction entirely — that's the next step once it earns it.
One more zipping gotcha: test the archive with ditto -x, not unzip. unzip reconstitutes extended attributes as visible ._ files inside the bundle, which re-breaks the signature and reproduces "damaged" — a fake failure that sent me chasing the wrong thing for a while.
What I'd tell someone starting this
- Read the data, not its rendering. If you're scraping a UI, look one layer down for the request that populates it.
- Don't fight the platform's auth — inhabit it. A webview that carries the real session beats any amount of header spoofing.
- The un-clever design is often the legible one (discrete colors over smooth gradients).
-
Ship the bundle, sign the bundle, test the bundle. Most of my "it works on my machine" pain was the gap between a locally-run binary and a downloaded, quarantined, unsealed
.app.
Claude Meter is on GitHub (Swift / SwiftUI, MIT). Windows and Linux versions are next. If you try it, I'd love the feedback — there's a link right on the site.

Top comments (0)