I run garmlink.ai, a free service that lets an AI assistant read your Garmin Connect data. It is a hosted MCP server: you paste one address into your assistant, sign in, and ask questions about your sleep, runs or training load. I wanted to ask an AI assistant about my own Garmin data without exporting files, so I built it.
This is what I learned getting there. Nothing here is a benchmark; it is the list of things that cost me time.
The shape of the system
Three services and a database. A Next.js web app serves the pages and dashboard. A Hono API owns user auth, the MCP endpoint at /mcp, all reads and the cron jobs. A Python worker is the only thing that talks to Garmin. Postgres holds the data, and raw FIT files go to an S3-compatible bucket. The Garmin tokens are encrypted in the worker only, and the Garmin password is used once and never persisted.
That split turned out to matter. The part of the system that is fragile (signing in to Garmin) is isolated from the part that has to be boringly reliable (answering an assistant in under a second).
Lesson 1: sync ahead, do not read Garmin live
The first design question was whether a tool call should fetch from Garmin at request time. I decided no, for three reasons.
First, questions are rarely about one day. "Compare my last three months" or "did my easy sessions stay easy" touch dozens of activities. Live, that is dozens of upstream requests inside one tool call, from a service that is not an official partner and should not hammer Garmin.
Second, latency and failure. An assistant waits for a tool result; if Garmin is slow or asks for MFA, the whole answer stalls. Reading from Postgres makes the tool path independent from Garmin's mood.
Third, it makes the tools simple. They are queries against tables I control, with dates and aggregates I can validate.
The price is staleness. The worker keeps the copy current on its own schedule and walks back through the past year in chunks after sign-up, and there is one tool, garmlink_sync_refresh, that asks it to fetch the latest data now. It is the only tool that is not read-only and the only one marked as touching the outside world, and it is limited to once every 15 minutes. A test in the repo asserts that exactly one tool is open-world.
Lesson 2: one endpoint, four assistants, and the OAuth discovery chain
An assistant that adds a "custom connector" needs only a URL. Everything else has to be discoverable. For a remote MCP server that means:
- The endpoint answers an unauthenticated request with
401and aWWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource"header. - That document (RFC 9728) names the authorization server.
- The authorization server publishes its metadata (RFC 8414) so the client can find the authorize, token and registration endpoints.
- The client registers itself through dynamic client registration (RFC 7591), because you cannot pre-register every assistant. I enabled unauthenticated registration for this reason.
The detail that bit me: the authorization server lives under the auth library's base path, /api/auth, so its issuer is <origin>/api/auth, not the bare origin. By RFC 8414, metadata for an issuer with a path is discovered at /.well-known/oauth-authorization-server/api/auth (path insertion), so I serve it there and at the root as a fallback. I also serve the OpenID configuration at both locations. Clients probe different paths; being liberal in what you serve is cheap.
The second detail: ChatGPT enforces RFC 9207, which says the iss in the authorization response must equal the issuer in the metadata. Claude, in my experience at the time, did not check it. An earlier version advertised a bare-origin issuer while the tokens carried the /api/auth issuer. Claude worked; ChatGPT failed with a generic connector error. The fix was to stop hand-writing metadata and serve the authorization-server library's own documents, so the advertised issuer and the real one cannot drift apart. The source comment in the repo records exactly this.
I would add a test that fetches the discovery documents and checks that every URL in them resolves. The repo has a test list for the well-known paths and CORS for them, which caught one missing route.
Lesson 3: stateless MCP is easier to operate
The /mcp route uses the Streamable HTTP transport and builds a fresh server per request. No session store, no sticky routing, and a deploy never strands a session. The cost is that you cannot keep per-session state, which I have not needed: every tool takes its arguments, reads for the signed-in user and returns.
Lesson 4: design tools for a model, not for an API
There are 48 tools, 6 prompts and 4 resources, and I think the count is a risk, not a feature. Every tool description is context the assistant carries on every turn, so each one has to earn its place.
What helped, as design rules: name tools consistently (garmlink_sleep_get, garmlink_hrv_get), take date ranges rather than single days, return compact summaries before raw series (there is a separate time-series tool for one activity), and return an explicit "not hydrated yet, try sync" note instead of an empty array, so the assistant can recover by itself. Prompts such as week review or race readiness give users a starting point; a resource describes how to use the server.
The questions I test against are ordinary ones: "Did I actually recover from my heaviest week, or did I just stop training?", "Does the heat change my heart rate at equal effort?" If a question needs the assistant to stitch five tool calls with guessed dates, the tools are wrong, not the assistant.
Lesson 5: read-only is a design constraint that pays off
Nothing in garmlink writes to Garmin. That is partly caution about a stranger's account, and partly that it simplifies trust: "your assistant cannot change anything" is a sentence a user can believe, and a test can enforce. Annotations on each tool (read-only, open-world) tell the client what to expect. The cost is real: if you want an assistant to push a workout to your watch, this is the wrong tool. Tredict, an official Garmin partner, does that.
Lesson 6: each assistant has its own gate
The same address works everywhere, but who can use it differs: Claude accepts custom connectors on every plan, including Free (one connector). ChatGPT needs developer mode, which exists only on paid plans and on the web. Perplexity needs Pro or above. Le Chat works in the browser. Grok works on grok.com. Gemini works through custom apps in the Gemini app, US only for now, or the Gemini CLI. Code tools (Cursor, Codex, Claude Code, VS Code) take the URL in a config line or one command. I keep a per-assistant page with these plan limits because they change, and because "it does not work" is usually a plan issue, not a bug.
Limits, honestly
- It is not a Garmin partner. It signs in to Garmin Connect as the user, like the self-hosted servers do. When Garmin changes how sign-in works, the service has to adapt: the worker already has to present itself like a browser (it uses
garminconnectwith a TLS-impersonating transport) to get through. Official partners such as Tredict and Fitness AI Connector do not carry that risk. - The code is closed. A self-hosted open-source server keeps your credentials on your machine; garmlink asks you to trust a hosted service, and the privacy page lists what is stored and who receives it.
- Read-only: no workout push.
- ChatGPT needs paid-plan developer mode on the web. The listings in the Claude and ChatGPT connector directories are submitted and in review, not approved.
If you want to try it, it is free: garmlink.ai. I would like to hear which tools your assistant misuses.
Top comments (0)