TrainBud — your own baselines on the wrist, with a companion server you run (Widget)

TrainBud is a Connect IQ widget backed by an open-source server you run yourself. I built it because I wanted the watch to tell me what had changed about me, rather than give me a score out of 100.

How it is put together, since that is the interesting part here:

The widget holds no credentials and talks to nothing but one host you configure. The companion server runs on your own machine, signs into Connect with your own credentials, keeps a local SQLite history, and serves the widget a single summary payload from GET /api/watch. The same server is also an MCP server, so the same data answers questions in Claude or Cursor on the desktop.

Findings are computed by detectors in code against your own 28-day baselines — "resting heart rate 4 bpm above baseline, three days running", "this week's load is 1.6x your four-week average". When there is not enough history to compare against, it says so rather than printing a number that means nothing. A night you did not wear the watch reads as unknown, not as a zero.

Three things I got wrong that may save somebody else the time:

1. A baked-in default ServerUrl is a security bug, not a convenience. Early versions shipped my own tunnel hostname as the property default. Every store install therefore found a non-empty URL, skipped the setup screen, and POSTed a pairing request at a host that was usually offline — so nobody who installed from the store could ever pair. Worse when it was online: a stranger's watch could mint a pairing code against my health server. The default is empty now, and an empty value is what routes a fresh install to the "Setup required" screen. Sideloads get the URL from a resources-dev layer instead.

2. The glance never drew anything, on any device, from the commit that added it. Connect IQ builds the app class into the glance scope in order to ask it for getGlanceView(), so every field initializer on the app class runs in that scope — and mine called a helper that only existed in the widget scope. Illegal Access (Out of Bounds) before onUpdate drew a pixel. A module-level const folds at compile time and is safe in that position; a call does not. Nothing caught it: the build was green, the store approved the package, and the widget was unaffected.

3. A tunnel that answers is not a server that answers. When the server died and the tunnel did not, the tunnel served its own HTML error page under a 200. Connect IQ asked for JSON, got HTML, and reported HTTP -400 — which sent me looking at the AI integration for two days. Connect IQ also sends Mozilla/5.0 and will not let you override it, so ngrok's free-tier browser interstitial hits the watch and never your curl.

Store listing: apps.garmin.com/apps/303bda81-2851-44b3-8550-a6fa5923f427
Source (MIT): github.com/Zsadigzade/trainbud

Tested on fr55, fr70 and fenix 8. No MFA support — the underlying Connect library cannot do it, and that is the most common reason setup fails.

Unofficial community project; not affiliated with, endorsed by, or sponsored by Garmin Ltd.

Happy to answer anything about the glance-scope trap in particular — it cost me a release and I have not seen it written down anywhere.