Caskey Engineering

← Back to Blog

Wiring Garmin Into My Marathon Coach: A Live Data Integration Without an Official API

I built an AI marathon coach for my 2026 NYC training block. The first version ran on manual data. I exported a CSV from Garmin Connect, uploaded it, and the coach scored my training load and recovery from that snapshot. It worked, but it had one fatal flaw for a daily-use tool: it was always stale, and keeping it fresh was my job.

A coach that only knows last week's runs cannot make today's call. So I replaced the manual export with a live integration that pulls my runs and my daily recovery signals from Garmin automatically. Here is the what, the why, and the how.

What it does

Every fifteen minutes, a small service wakes up, talks to Garmin, and brings two kinds of data into the coach:

  1. Activities. Each run, with date, distance, duration, average and max heart rate, elevation, and cadence. From these the coach derives pace and training load.
  2. Daily wellness. Resting heart rate, heart rate variability, sleep duration and score, body battery, stress, and training readiness. These are the recovery signals that gate whether a hard session is a good idea.

By the time I open the dashboard, the data is already there. No export, no upload, no friction.

Why it mattered

The coach makes go or no-go recommendations, and those are only as good as the freshness of the inputs. Recovery data in particular has a short shelf life. Last night's sleep and this morning's resting heart rate are the difference between "proceed with the tempo run" and "back off and recover." If I have to manually sync before every decision, I will skip it, and a tool I skip is a tool that does not exist.

The deeper reason is trust. When the coach tells me to rest, I want to believe it acted on real, current data, not on a week-old snapshot I forgot to refresh. Automating the feed is what makes the recommendation credible enough to actually follow.

How it works

Here is the honest part: Garmin does not offer a simple personal data API for this kind of project. There is a partner program for companies, but nothing turnkey for an individual who just wants their own numbers. So the integration stands on two community libraries that do the hard part of talking to Garmin: garth for the Garmin SSO login and token refresh, and python-garminconnect for the activity and daily-wellness endpoints. Both speak to the same web endpoints the Garmin Connect site uses. It is an unofficial path, and I designed around that constraint deliberately.

A scheduled poller, not a webhook. Garmin does not push to me, so I pull. A scheduled job runs every fifteen minutes. It authenticates, lists recent activities, and writes any new ones to a database. A separate pass pulls the previous few finalized days of wellness data, because today's sleep and stress numbers are not complete until the day ends.

Reuse the session, do not re-login. Logging in to Garmin repeatedly is the fastest way to get rate limited or locked out. The poller authenticates once, stores the session securely, and reuses it on every subsequent run. Re-authentication only happens when the session genuinely expires. This single decision removed almost all of the flakiness from the integration.

def ensure_authenticated(self):
    # Resume the saved session instead of logging in on every poll.
    session = self.session_store.load(self.athlete_id)
    if session and not session.expired:
        return GarminConnect(session)
    # Only run the SSO login flow when the session is actually gone.
    client = GarminConnect.login(self.credentials())
    self.session_store.save(self.athlete_id, client.session)
    return client

A rolling lookback window, not a strict cursor. My instinct was to fetch only activities newer than the last successful poll. That dropped runs. A run often syncs from the watch to the Garmin cloud minutes or hours after it finished, so by the time it appears, the "last poll" watermark has already moved past it. The fix is to fetch a fixed recent window every time, at least the last several days, and deduplicate by Garmin's activity id. Slightly more work per poll, no lost runs.

def list_recent_activities(self, since, until):
    # A run can reach Garmin's cloud after the poll that follows it, so its
    # start time predates the "last sync" watermark. Filtering on
    # start_time > since would drop it forever. Fetch a fixed window and
    # dedupe by Garmin's activity id instead.
    lookback = timedelta(days=90) if since is None else timedelta(days=7)
    window_start = (until - lookback) if since is None else min(since, until - lookback)
    return client.get_activities_by_date(window_start.date(), until.date())

Never collapse a failure to zero. If a single wellness metric fails to load on a given day, that field stays empty rather than silently becoming a zero. A fake zero would poison every downstream calculation that reads it: training load, the acute to chronic workload ratio, the recommendation itself. A missing value is honest. A wrong value is dangerous.

# One failed metric leaves that field None, never 0. A real-looking zero
# would poison load, the acute:chronic ratio, and the recommendation.
try:
    hrv = client.get_hrv_data(day)["hrvSummary"]["lastNightAvg"]
except Exception:
    hrv = None  # absent, not zero

Compute load at read time, from one formula. Training load is derived, not stored as ground truth. Whether a run came from the live poller or an old CSV import, the dashboard computes its load the same way when it renders. One formula, no drift between sources. This also meant that when I later fixed how load is computed, every run updated at once, with no backfill.

The limit worth being honest about

A pulled integration can only ever surface what Garmin itself has. Recently I went looking for "missing runs" on the dashboard and braced myself for a pipeline bug. There was none. I compared two different Garmin endpoints directly, and both returned the same sparse set the dashboard was already showing. The runs I expected were not in Garmin's data at all, which points upstream: a watch that had not finished syncing to the cloud.

That is the tradeoff of building on someone else's platform without a contract. You inherit their data and their gaps, and your job is to mirror them faithfully rather than invent numbers to fill the holes. To make those gaps visible instead of mysterious, I added a run log to the dashboard with a clickable calendar, so I can see at a glance exactly which days have runs and which do not.

Where the pieces came from

If you want to build something similar, start with the two libraries that do the heavy lifting:

  • garth by Matin Tamizi: the Garmin SSO login and OAuth token handling.
  • python-garminconnect by cyberjunky: a clean wrapper over the activity and daily-wellness endpoints.

Both are community projects that reverse-engineer the same calls the Garmin Connect web app makes, so treat the surface as unofficial and subject to change. The acute to chronic workload ratio the coach computes on top of this data comes from the sports-science literature on training load and injury risk, which is the load metric most worth getting right. The snippets above are simplified for the post; the real code adds retries, audit logging, and per-metric error handling.

Takeaways

  • For a daily-use tool, fresh data is not a nice to have. It is the whole product. Automate the feed or the tool dies.
  • When there is no official API, design for the unofficial one explicitly: reuse sessions, poll on a window, and assume eventual consistency.
  • Be faithful to the source. Surface gaps, never paper over them with zeros or guesses.

The coach is now reading my real training and recovery on its own, every fifteen minutes, all the way to the start line in November.

Keep reading

Tool

/coach

Read
Demo

Coach me

See today’s training recommendation against a sample athlete.

Read
Case study

AI Marathon Coach Grounded in 90 Days of Actual Training Data

Built a coaching platform that reasons over real Garmin workout history — not generic templates — with a multi-turn AI interface grounded in the athlete's actual load data.

Read
Post

Building an AI Marathon Coach: Deterministic Rules, LLM Narratives, and the 2026 NYC Marathon

How I built a personal AI coaching system for marathon training, layering deterministic guardrails over an LLM narrative engine, ingesting Garmin FIT files, and designing for my own injury history.

Read
Post

Building a Personal Finance Reviewer: What Survived the Rewrite

A personal portfolio reviewer where the scoring is deterministic and the AI only narrates. The architecture that held up after I had to rewrite the model it was built on, and why that boundary is the whole point.

Read
Post

A Boring Design Let Me Run a Black Swan on a Tuesday

Two posts ago I bet that keeping my portfolio reviewer's engine deterministic and auditable was worth it. This is where that bet paid off: because the engine is replayable, I could run a simulated market crash through the real production code and catch a money-losing flaw on paper — before it could ever cost a real dollar.

Read
Written by Eric Caskey — I build AI tools you can actually use. Explore the Tools or see the case studies.