A Garmin skill for AI agents — syncs your Garmin Connect health data to local JSON so Claude Code / Codex / Hermes / ChatGPT can answer your daily "how am I doing?" questions.
Natively supports both
garmin.comandgarmin.cn(China region).
English | 简体中文
garmin-sync is a small Python CLI + library that pulls each day's
health metrics from your Garmin account and writes them to disk as
structured JSON. There's no daemon, no third-party server, no cloud — just
a script and a folder of files your AI assistant can read.
# As an Agent Skill (Claude Code / Codex / Cursor / etc.)
npx skills add denki-san/garmin-sync
# Or as a plain Python package
pip install garmin-sync # core (garminconnect under the hood)
pip install 'garmin-sync[plots]' # add matplotlib trend plots
HRV (nightly) |
Sleep score |
Sample plots: 7 days from the maintainer's account (2026-05-17 → 23). Each generated by garmin-sync plot --metric <name> --days 7 --rolling 3.
garmin-sync is built as a skill an AI agent installs once to give
you a fresh, structured snapshot of your body each morning. The design
follows from that:
- Skill-shaped: ships a
SKILL.mdso any agent can drop it in and immediately know how to use it - Agent-first I/O: one JSON per day, no DB, no schema migration — any LLM can just
catit - Daily-tracking, not full-archive: ~13 requests/day, runs in seconds from a daily cron
- Health-first scope: sleep / HRV / stress / Body Battery / RHR — the "how am I doing" metrics
- Training data: planned, not promised: more activity/training fields will land as I need them; PRs welcome
We deliberately skip:
- GPS tracks, FIT file ingestion, per-second cadence / power / heart-rate streams from activities
- PMC (CTL/ATL/TSB), training load, Training Effect, recovery time
- Workout / training-plan management
activities and training_readiness are included but only at the
summary level (name + duration + distance + calories for activities;
overall score + status for readiness). If you need full training analytics,
nrvim/garmin-givemydata or
tcgoetz/GarminDB cover that
territory better.
One JSON file per day (e.g. 2026-05-28.json). Top-level keys:
| Key | What it covers |
|---|---|
🛏️ sleep |
Score, start/end, stage breakdown (deep/light/REM/awake/nap), sleep-window SpO2 + HR + respiration + stress |
👣 steps |
Total steps, distance, goal |
📊 hrv |
Weekly avg, last-night avg, 5-min high, balanced baseline + marker, status, feedback phrase |
🩸 spo2 |
Daytime SpO2 — avg, min, avg HR during readings |
🔋 body_battery |
Charged, drained, daily max, daily min |
❤️ resting_heart_rate |
RHR + min/max HR when available |
🫀 heart_rate |
Daily HR range (min / max / resting) + 7-day avg RHR |
🔥 calories |
Total / active / BMR (Garmin-computed energy expenditure) |
⬆️ floors |
Floors ascended / descended / daily goal |
⏱️ activity_seconds |
Daily time budget — sleeping / sedentary / active / highly active |
💨 vo2_max |
Running + cycling values (last reported, since updates are sparse) |
😰 stress |
Overall 0–100 + duration in rest / low / medium / high buckets |
🫁 respiration |
Lowest / highest / avg / awake respiration rates |
💪 intensity_minutes |
Moderate + vigorous + weekly goal |
🚦 training_readiness (summary only) |
Overall score + per-factor + status |
🏃 activities (summary only) |
List of {name, duration_sec, distance_km, calories} per activity |
See the full sample JSON further down.
Important
garmin-sync reads from Garmin's cloud, not from your watch. If your
watch hasn't synced to the Garmin Connect mobile app — and the app to the
cloud — for a given day, that day's JSON will be empty or missing fields.
garmin-sync reads from Garmin's web API. It does not talk to your
watch directly. The data flow is:
Watch → Garmin Connect app on your phone → Garmin Connect cloud → garmin-sync → your disk
If your watch hasn't synced to the mobile app — or the app hasn't pushed
to the cloud — for a given day, that day's JSON will be empty or missing
fields. To force a sync before running garmin-sync:
- Open the Garmin Connect app on your phone.
- Make sure the watch is in Bluetooth range and pull-to-refresh on the home screen.
- Wait for the "Last synced" timestamp to update.
This is why running garmin-sync sync --days 1 minutes after midnight
often returns half-empty data: the watch hasn't been near the phone yet.
The Garmin Connect app is fine for a quick glance, but you can't ask it "how has my HRV trended against my sleep score over the last month?" or "did my Body Battery debt correlate with the headache I had on Thursday?" — those are LLM-shaped questions, and they need the data on disk in a format an LLM can chew on.
garmin-sync is the boring plumbing. Once a day's JSON is on disk,
everything else (analysis, reports, alerts, plots) is just reading files.
If your goal is archive + retrospective analysis rather than daily agent tracking, one of these is likely a better fit:
- Full Garmin data + built-in MCP server →
nrvim/garmin-givemydata - Mature SQL tables + Jupyter notebooks for activity/FIT analysis →
tcgoetz/GarminDB - SQLite with a documented schema, designed as an AI data source →
diegoscarabelli/garmin-health-data
Full feature-by-feature comparison below.
| garmin-sync | nrvim/garmin-givemydata ⭐108 | tcgoetz/GarminDB ⭐3.1k | diegoscarabelli/garmin-health-data ⭐61 | |
|---|---|---|---|---|
| Scope | Health-only (~14 metrics) | Everything — 48 SQLite tables incl. FIT files | Everything — daily + activities + FIT files | Everything — health + activities (FIT/TCX) + training |
| Storage | One JSON file per day | SQLite + raw FIT copies | SQLite + raw FIT copies | SQLite + raw files (4-folder lifecycle) |
| AI interface | Read JSON files (any tool) | Built-in MCP server (45 tools) | Jupyter notebooks | Read SQLite (documented schema) |
| Backfill speed | seconds per day | ~30 min for 10 years (first run) | similar to givemydata | resumable, single-pass |
| Install | pip install |
pip install / brew tap |
pip install / make |
pip install |
| Auth | garminconnect (curl_cffi) | garminconnect (similar) | own garth-based stack | self-contained SSO client |
| Region | International + China (garmin.cn) |
International only | International only | International only |
| License | MIT | AGPL-3.0 | GPL-2.0 | Apache-2.0 |
TL;DR: if you want the full Garmin firehose with an MCP server, go
garmin-givemydata. If you want mature SQL tables + Jupyter notebooks,
go GarminDB. If you want lightweight per-day JSON files an AI assistant
can read directly, that's this one.
This section is written so an AI agent can install
garmin-syncfrom start to finish without further prompts. Human readers can follow the same steps directly.
Detect which AI agent skill directories exist on this machine:
| Path | Agent |
|---|---|
~/.claude |
Claude Code |
~/.openclaw |
OpenClaw |
~/.hermes |
Hermes |
~/.config/opencode |
OpenCode |
For each detected directory $AGENT_DIR, clone this repo into its
skills/ subdirectory:
mkdir -p "$AGENT_DIR/skills"
git clone https://github.com/denki-san/garmin-sync \
"$AGENT_DIR/skills/garmin-sync"If $AGENT_DIR/skills/garmin-sync already exists, run
git -C "$AGENT_DIR/skills/garmin-sync" pull --ff-only to update instead.
If none of the four directories exist, ask the user which AI tool they plan to use this skill with, then create that directory and treat it as the only target.
pip install git+https://github.com/denki-san/garmin-sync
# Or, with optional plotting support:
pip install 'git+https://github.com/denki-san/garmin-sync#egg=garmin-sync[plots]'This installs the garmin-sync CLI on $PATH. All AI agents on this
machine share the same CLI — install once. Requires Python 3.10 or later.
Ask the user for:
- their Garmin email
- their region (
garmin.cominternational orgarmin.cnChina) - where they want JSON files written (default:
~/.local/share/garmin-sync)
Write ~/.config/garmin-sync/profiles.toml:
[profiles.me]
email = "you@example.com"
domain = "garmin.com"
token_dir = "~/.garminconnect-garmin_com"
output_dir = "~/.local/share/garmin-sync"For multiple users (family, spouse), see docs/multi-user.md.
Ask the user for their Garmin password (export it as $GARMIN_PASSWORD
or set password_env_var in the profile), then run:
garmin-sync setup --profile meIf Garmin requires MFA, the CLI will interactively prompt for the 6-digit
code — relay the prompt to the user. Tokens cache under token_dir and
auto-refresh while syncing continues; re-run setup only if the password
changes or a stale-token error occurs.
See docs/auth-troubleshooting.md for
non-interactive (TOTP) workflows.
garmin-sync sync --profile me --days 1 # yesterday
garmin-sync sync --profile me --days 30 # backfill 30 daysVerify the file landed:
test -f "$(dirname "$(garmin-sync sync --profile me --days 1 2>&1 | grep -oE '/.*\.json' | head -1)")/$(date -v-1d +%Y-%m-%d).json" \
&& echo OK
# Linux: replace `date -v-1d` with `date -d yesterday`If the JSON is missing fields the user expects (Body Battery, HRV, etc.), the watch may not have pushed that day's data to Garmin's cloud yet — see Prerequisite above.
garmin-sync runs on demand — installing it does not start a daemon
or register a cron job. To keep data fresh automatically, add a cron
entry:
30 6 * * * GARMIN_PASSWORD='...' /usr/local/bin/garmin-sync sync --profile me --days 1 >> /var/log/garmin-sync.log 2>&1Or use the host agent's scheduler (Hermes cron jobs, launchd on macOS, systemd timers on Linux, etc.).
Important
Open the Garmin Connect mobile app and pull-to-refresh before the
scheduled sync runs. garmin-sync reads from Garmin's cloud, so if
the watch hasn't pushed today's data yet, the sync will write a
half-empty JSON. Keep the phone next to the watch overnight so the
morning push happens automatically.
{
"date": "2026-05-28",
"display_name": "Lei",
"sleep": {
"score": 88,
"start": "2026-05-28 00:56",
"end": "2026-05-28 08:30",
"stages": {
"total_min": 450, "deep_min": 114, "light_min": 272, "rem_min": 64,
"awake_min": 4, "avg_spo2": 93.0, "lowest_spo2": 86,
"avg_spo2_hr": 60.0, "avg_respiration": 12.0,
"lowest_respiration": 10.0, "avg_sleep_stress": 10.0
}
},
"steps": {"total": 8833, "distance_km": 7.269, "goal": 7540},
"hrv": {
"weekly_avg_ms": 47, "last_night_ms": 46, "status": "BALANCED",
"last_night_5_min_high_ms": 61,
"baseline": {"balanced_low": 39, "balanced_upper": 51, "marker_value": 0.58},
"feedback_phrase": "HRV_BALANCED_6"
},
"spo2": {"avg_pct": 93.0, "min_pct": 86, "avg_hr_bpm": 60.0},
"body_battery": {"charged": 86, "drained": 92, "max": 99, "min": 7},
"resting_heart_rate":{"value": 56.0},
"heart_rate": {"min": 54, "max": 130, "resting": 56, "last_7d_avg_resting": 56},
"calories": {"total_kcal": 2556, "active_kcal": 537, "bmr_kcal": 2019},
"floors": {"ascended": 0.0, "descended": 6.45, "goal": 10},
"activity_seconds": {"highly_active_sec": 977, "active_sec": 8202, "sedentary_sec": 49981, "sleeping_sec": 27240},
"vo2_max": {"running": 43.0, "running_precise": 42.5},
"stress": {"overall": 43, "level": "中", "rest_min": 494, "low_min": 189, "medium_min": 259, "high_min": 288},
"respiration": {"low": 9.0, "high": 22.0},
"intensity_minutes": {"moderate_min": 3, "vigorous_min": 0, "weekly_goal_min": 150}
}A single sync of one day makes 13–14 HTTP requests (14 if VO2 Max
isn't reported for that day — the fetcher widens to a 1-year range). For a
daily cron job this is comfortably inside Garmin's per-account limits;
backfilling --days 365 once is also fine. Tight loops (e.g. --days 1
in a busy loop) will eventually trip rate limiting.
garmin-sync export-csv --profile me --start 2026-05-01 --end 2026-05-29 --out ~/garmin-may.csvFlattens the daily JSON into one row per day. Column order is fixed; new metrics are appended at the end across releases so existing spreadsheets don't break.
Full header row (44 columns)
date, sleep_score, sleep_total_min, sleep_deep_min, sleep_light_min, sleep_rem_min,
sleep_awake_min, sleep_avg_spo2, sleep_lowest_spo2, sleep_avg_respiration,
sleep_avg_stress, steps_total, steps_distance_km, steps_goal, hrv_weekly_avg_ms,
hrv_last_night_ms, hrv_status, hrv_5min_high_ms, hrv_baseline_low,
hrv_baseline_upper, spo2_avg_pct, spo2_min_pct, spo2_avg_hr_bpm,
body_battery_charged, body_battery_drained, body_battery_max, body_battery_min,
stress_overall, stress_level, stress_rest_min, stress_low_min, stress_medium_min,
stress_high_min, respiration_low, respiration_high, respiration_avg,
intensity_moderate_min, intensity_vigorous_min, intensity_weekly_goal_min,
training_readiness_score, training_readiness_status, resting_heart_rate,
vo2_max_running, vo2_max_cycling, activities_count
Important
Missing values are written as empty strings, not as 0 or NaN, so
downstream tools can distinguish "no data" from "value was zero".
The two examples at the top of this README were generated by plot:
pip install 'garmin-sync[plots]'
garmin-sync plot --profile me --metric hrv --days 30 --out hrv.pngSingle-metric line chart + 7-day rolling mean. Missing days break the line
(no zero-fill). Headless-safe (Agg backend). Run
garmin-sync plot --list-metrics for the supported metric list.
Does it work with garmin.cn (China region) accounts?
Warning
Sleep, steps, HRV, SpO2, stress, intensity minutes, daily summary, and
activities are confirmed working. Body Battery, Resting HR, VO2 Max,
Training Readiness, and Respiration are almost certainly 404 on
garmin.cn (based on community reports + path-prefix inference — we
haven't tested directly on a .cn account). If you have a CN account and
need those, you'll need Garmin support to migrate the account region.
Does it sync on a schedule automatically?
No. garmin-sync runs only when you invoke it (or when your AI agent
does so via the skill). For a daily snapshot, schedule it yourself —
see Quick start.
Does it handle MFA?
Yes. setup prompts for the 6-digit code interactively. Token persistence
means one prompt per setup, not per sync.
Where do tokens / passwords go?
Tokens: plaintext JSON in token_dir (default ~/.garminconnect-<domain>/).
Passwords: only read from env vars (or ~/.hermes/.env if present);
nothing is ever written back.
Will it get rate-limited?
One sync --days N = ~13×N HTTP requests. Daily cron is fine. Burst
backfilling 1–2 years works. Looping sync --days 1 in a busy loop will
eventually 429 you.
Pre-1.0. The JSON schema is "stable enough that I use it daily" but I may add fields. Removing or renaming an existing field requires a minor version bump.

