---
name: windows-tray-status-widget
description: Build a small always-visible Windows system-tray status widget (pystray + Pillow) that polls a remote API and renders a live number onto its icon — spend counters, queue depths, uptime, any metric the user wants in their eyeline. Trigger when the user points at an existing tray indicator (e.g. the Claude session/weekly-usage icon) and asks "can we do the same", or asks for a glanceable always-on-top counter on a Windows machine. Covers the two failure modes that each cost a debugging round-trip (the 128-char tooltip cap and the silent background-thread icon death), the token-from-file discipline, and the delivery-via-Syncthing pattern for remote Windows machines.
---

# Windows Tray Status Widget (pystray + Pillow)

A tiny tray icon that draws a **live number directly onto the icon glyph** and polls a remote API on a timer. The reference build is the Hermes spend counter on Connie (icon shows today's $, hover shows today/week, right-click menu shows the by-model breakdown + Refresh/Open/Quit), but the pattern is metric-agnostic — swap the fetch + label functions for any number.

Canonical hardened template: `templates/tray_widget_template.py`. Do not regenerate from scratch — copy it and edit the CONFIG block, `fetch_*`, and the label/tooltip text.

## When this fires

- "See that tray icon that shows my X usage? Can we do the same?"
- "I want a little always-on counter for <metric> on my laptop."
- Any "glanceable number without opening a dashboard" request on a Windows machine.

Do NOT fire for server-side dashboards (build a web card instead) or for one-off number checks (just answer the question).

## Design decisions to make with the user FIRST

The data feed is almost never the hard part — **where the number renders** is. Present the placement options before writing code (dashboard card vs tray widget vs in-app chip vs threshold alert), with the honest trade-offs. For this user, when they reference an existing tray icon as the model, the tray widget IS the answer — don't re-litigate placement after that.

Tech choice on Windows, in order of preference for this user:
1. **Python + pystray + Pillow** — user's pick; lightest to iterate (agent can patch the script and re-sync), runs from the machine's existing Python.
2. Self-contained .exe — survives Python env changes but needs a rebuild per tweak.
3. PowerShell + NotifyIcon — zero install but cruder text-on-icon rendering.

## The two failure modes that each cost a round-trip (build around both)

### 1. Tray tooltip is HARD-capped at 128 chars

Windows `Shell_NotifyIcon` rejects tooltips longer than 128 characters with `ValueError: string too long (131, maximum length 128)` — raised from `pystray/_win32.py` `_message()`. The natural instinct to put a multi-line breakdown (today / week / by-model) in the tooltip blows straight past it.

**Fix:** keep `icon.title` to the two headline numbers + a timestamp (the reference build's tooltip is ~48 chars), truncate defensively with `[:127]`, and move any breakdown into the right-click **menu** (menu item text has no such cap — render detail as a greyed-out `enabled=False` menu line). Verify tooltip length against the cap before shipping.

### 2. "Icon was there for a minute, now gone" = background thread died silently

The widget polls from a `threading.Thread`. If ANY exception escapes that thread — a font load failure in the icon redraw, a pystray call that dislikes being poked off the main thread (`icon.update_menu()` from a worker is a known offender), a network error mid-repaint — the thread dies, the icon detaches, and Windows sweeps it. **No traceback reaches the console**, because it's not the main thread. The user sees a widget that appeared and then vanished ~30s later (the first poll).

**Fix, all three layers (the template has all three — don't strip them):**
- Wrap the poll loop body in try/except so no exception can kill the thread; on error, set an error state and repaint the icon to show it instead of dying.
- Wrap the repaint itself in try/except — never let a paint error propagate into the poll thread.
- Wrap `main()` to write `traceback.format_exc()` to a log file (e.g. `~/.hermes_spend_tray.log`) so a fatal exit leaves evidence. When the user reports "it disappeared", the FIRST move is `Get-Content` that log — not guessing.

Also rule out the lookalike: if the icon landed in the `^` overflow panel rather than the main tray bar, Windows hides overflow icons on update, which reads as "disappeared" but isn't a crash. Ask the user WHERE the icon appeared before deep-diving the crash path.

## Other build rules (learned on the Connie spend-widget build)

- **Token from a file, never in the script and never pasted in chat.** The script reads the API token from `~/.<widget>_token`; the user creates it with `Set-Content ... -NoNewline`. Same discipline as the gateway skill's Bearer-token rule. State the exposure plainly if a token does transit chat (read-only scope + pinned-in-systemd = low risk for the dashboard analytics token; offer rotation anyway).
- **Deliver to a remote Windows machine via the Syncthing vault**, not a paste: write the script under `/root/.hermes/vault/`, `chmod 644` it (agent writes land `-rw-------` and can fail to materialize on the Windows side — this is the FIRST check if the user says "don't see it"), and it syncs to `C:\Users\Rob\Documents\Obsidian Vault\`. See `syncthing-folder-sync` for the perms pitfall and the full sync topology.
- **Test the numbers path headlessly first.** The data-fetch/aggregation logic is pure Python — run it on the VPS against the live API and confirm the numbers before the user ever launches the GUI half. The tray/paint half can only be tested on the Windows machine.
- **The running PowerShell window is the process lifetime.** Foreground `python widget.py` dies when the window closes. For persistence, register a Scheduled Task at logon (needs an ADMIN PowerShell for `Register-ScheduledTask` — same constraint as the Syncthing auto-start). Only set that up after the user confirms the widget shows the right number; "lock it" = add auto-start.
- **Poll cadence vs API cost:** 30s against a local/LAN or cheap REST endpoint is fine; don't poll a paid-per-call upstream that fast. Cache the auth token (the Hermes dashboard token is pinned in systemd and survives restarts — see `operating-hermes-gateway`).
- **Icon rendering:** draw a 64×64 rounded-rect with Pillow, tint by threshold (green/amber/red), center the label with `ImageFont.truetype` falling back through arialbd/arial/segoeui/DejaVuSans-Bold then `load_default()` — and keep the whole draw inside the repaint try/except so a font hiccup can't kill the widget (failure mode 2).

## Reference deployment (Connie, 2026-08-09)

- Script: `/root/.hermes/vault/hermes_spend_tray.py` → syncs to `C:\Users\Rob\Documents\Obsidian Vault\hermes_spend_tray.py`
- Token file: `C:\Users\Rob\.hermes_spend_tray_token` (dashboard session token)
- Crash log: `C:\Users\Rob\.hermes_spend_tray.log`
- Data feed: `https://robblake.cloud/api/analytics/usage?days=N` (bearer auth) — see `operating-hermes-gateway` → `references/cost-analytics-api.md`
- Thresholds: green < $5, amber < $20, red ≥ $20 (Rob can retune)
- Status: built + hardened against both failure modes above; auto-start Scheduled Task deferred until Rob confirms the icon is stable.

## Related

- `operating-hermes-gateway` — the `/api/analytics/usage` feed, token-scrape auth, and the spend-estimate caveats (DB costs are estimates; provider-side image/web/TTS billing isn't included).
- `syncthing-folder-sync` — the vault delivery path and the chmod-644-after-agent-write pitfall.
- `windows-dev-environment` — verifying Python/pip on the Windows machines.
