All projects

Garmin → Claude Health Data Bridge

Personal project · Self-built MCP server

A small server I built and run on Cloudflare that lets Claude read my Garmin sleep, recovery and training data. It syncs twice a day, keeps the full history, and sits behind OAuth so only I can connect to it.

The problem

I train for running and CrossFit with a coach I built on Claude. It plans my weeks and is meant to adjust them to how well I’ve recovered. Sleep, HRV and Body Battery are the best signals for that, but they live in Garmin, and Claude had no way to read them.

So every morning I took a screenshot of my sleep screen and pasted it in. It was manual and easy to skip. The coach only ever saw one night at a time, with no trends and no history, so it couldn’t plan on recovery.

I wanted the coach to read the data itself: every night, with the full history, without me in the loop.

How it works

A Cloudflare Worker pulls sleep, HRV, daily wellness and activities from Garmin’s official Personal API twice a day and writes them to a D1 database. A one-off script on my Mac loaded four years of history through the same code. The same Worker is also a remote MCP server: claude.ai connects to it as a custom connector, signs in through OAuth, and the coach reads the data through six tools.

Daily data sync

  1. Garmin API

    Official Personal API

  2. Cron sync

    08:30 and 21:30

  3. D1

    SQLite database

History import, one-off

  1. Backfill script

    Runs on my Mac

  2. D1

    SQLite database

The coach asks a question

  1. claude.ai

    Coach Project

  2. OAuth gate

    Tokens kept in KV

  3. MCP server

    6 tools

  4. D1

    SQLite database

Runs on Cloudflare’s free tier (Workers, D1, KV)
  • TypeScript
  • Cloudflare Workers
  • D1 (SQLite)
  • KV
  • Cron
  • MCP
  • OAuth 2.1

Data model

sleep_nights
One row per night, keyed by the day I wake up.
hrv_nights
Overnight HRV, its 7-day average and status.
daily_wellness
One row per day: resting heart rate, Body Battery, stress and steps, merged from two Garmin endpoints.
activities
One row per activity. Heart-rate zones, splits and exercise sets are kept alongside as JSON.
sync_runs
A log of every sync: what triggered it, which dates, how many rows, and any error.

Sleep, HRV and wellness are three tables because Garmin serves them from three separate services. If one of them changes, only one table and one mapper change. They are joined on the date when Claude asks, so a night with missing HRV still comes back, with HRV as null.

Every write is an upsert on the table’s key, so re-running a sync never creates duplicates. I checked the imported history against the Garmin app and a log I had kept by hand.

Design decisions

  • Measured the rate limit first. Garmin doesn’t document one. I measured about 100 requests a minute and sized the sync to stay well under it.
  • JSON instead of FIT files. Decoding one run’s FIT file takes 50–70 ms of CPU; the free plan allows 10 ms. The JSON activity summaries already carry averages, heart-rate zones, splits and sets.
  • History import outside the Worker. Four years is about 1,000 days across several endpoints, far beyond the 50 outbound requests a Worker gets per run on the free plan. The script on my Mac reuses the Worker’s own fetch and mapping code.
  • Null, never zero. Garmin leaves out fields it doesn’t have. A made-up 0 would read as “zero REM sleep” to the coach, so missing values stay null, and each tool tells the model which days are missing and why.
  • Nights dated by the wake-up day. That’s how Garmin dates them and how the question is asked: “last night” is today’s date.
  • OAuth for health data. claude.ai connectors need OAuth 2.1 with PKCE and dynamic client registration. The connector URL alone is never enough to read the data, and tokens are stored hashed.

The result

The morning screenshot is gone. The coach reads last night’s sleep, HRV and Body Battery itself, looks back over weeks or years when it needs to, and plans the next sessions on that.

If I ask “how did I sleep last night?” before the morning sync has run, the server fetches that night from Garmin on the spot and stores it.

SQL under the hood

D1 is SQLite, so the data layer is plain SQL:

  • Idempotent upserts with INSERT … ON CONFLICT DO UPDATE
  • LEFT JOINs across three source tables on the date
  • Schema changes as versioned migration files
  • An index on activity start time, with date functions for range queries
  • A recursive CTE to find days with no data
  • AUTOINCREMENT on the sync log, so ids are never reused after deletes