Object-Orient

garmin-sync

Community Object-Orient
Updated

Pulls your Garmin Connect data into a local SQLite database on a schedule, and serves it back to an AI assistant over MCP, read-only.

garmin-sync

Pulls your Garmin Connect data into a local SQLite database on a schedule, andserves it back to an AI assistant over MCP, read-only.

Your health history is otherwise only visible through Garmin's own app, onescreen at a time. This puts it in a file you own, in a shape you can query.

mkdir -p data tokens logs                                    # Docker would make these root-owned
cp .env.example .env                                         # your Garmin login
docker compose run --rm garmin-sync python first_login.py    # once; handles 2FA
docker compose run --rm garmin-sync                          # pull
docker compose up -d garmin-mcp                              # optional query server

The shipped .env.example backfills from the start of 2025. WidenBACKFILL_START once you have seen a run work — every extra year is another~5,800 requests to an API that rate-limits unofficial clients.

compose.yaml needs no editing. Everything is relative to the folder it sitsin, so a clone runs where it stands.

What you end up with

Garmin Connect
      │  python-garminconnect (mobile SSO + curl_cffi TLS impersonation)
      ▼
   sync.py ──► garmin.db     raw JSON  +  normalised tables
                    │
                    └──────► mcp_server.py  (read-only, for an assistant)

Two layers, on purpose. raw_records keeps every API response verbatim, soa Garmin field change never loses data you already have. On top of that sittyped tables — activities, daily_summary, sleep, hrv, body_battery,training_readiness, weight — for querying. If a normaliser breaks on achanged payload the raw row is still stored; the two are independent by design.

Ten of the sixteen collectors are archived but not yet queryable. Thesecond layer was only ever built for six of them:

Queryable — typed tables, visible to SQL and to the MCP server stats, sleep, hrv, body_battery, training_readiness, weigh_ins
Archived only — captured as raw JSON, no typed table yet all_day_stress, steps, spo2, respiration, intensity_minutes, floors, resting_hr, max_metrics, training_status, hydration

Nothing is lost — every one of those is fetched daily and stored complete, andyou can dig it out of raw_records with SQLite's JSON functions. But yourassistant cannot see it, and neither can a plain query, until someone writesthe normaliser. Adding one is a small, self-contained job in collectors.py:read a stored payload, decide which fields matter, map them to a table.

Incremental. Each collector keeps a high-water mark, so a run fetches onlythe days it has not got. The mark advances only across an unbroken run ofsuccesses — a day that fails holds it back and is re-requested next run, ratherthan being stepped over and lost. Runs that end with days still owed reportfailure and name them.

A day that keeps failing cannot hold a collector back forever, though, or oneday Garmin will never serve would stop the archive growing. AfterMAX_DAY_ATTEMPTS tries it is recorded in the sync_gaps table and steppedover. Losing a day quietly and abandoning one on the record are differentthings; select * from sync_gaps tells you which days are missing and why.

A reality check. Garmin has no official personal-data API and activelyblocks third-party clients (Cloudflare TLS fingerprinting since March 2026).This leans on the community garminconnect library, which currently getsthrough. When a Garmin change breaks it, the fix is usually to bumpgarminconnect (and curl_cffi, which does the TLS impersonation) inrequirements.txt and rebuild. Treat that as a when-not-if: those two arepinned for reproducibility, but they are the pins to move, because stayingcurrent is the whole point of them.

Running it daily

garmin-sync is a one-shot container: it runs, it exits. Scheduling is left toyour machine rather than baked in.

deploy/ has systemd units — garmin-sync.service and garmin-sync.timer —which is what the author uses. cron or any other scheduler works equally well;the container just needs invoking once a day.

Pick your timezone deliberately with TZ. Garmin days are local days, so itdecides where one ends.

The MCP server

garmin-mcp exposes the database to an AI assistant with a small set ofread-only tools — recent days, sleep, weekly trends, and a guarded querysurface.

It has no authentication. Anything that can reach the port can read yourentire health history. It therefore binds 127.0.0.1 unless you say otherwise.To expose it further, set MCP_BIND_ADDR to an address on a private mesh(Tailscale, WireGuard) or put an authenticating proxy in front. It warns onstartup if it is bound anywhere but loopback.

Read-only rests on PRAGMA query_only, which SQLite enforces itself and whichno SQL text can talk its way past. The single-statement SELECT guard and the rowcap sit on top as defence in depth — the guard blanks out string literals beforelooking for write keywords, so searching for an activity called "Morning update"is not mistaken for one. It is not mode=ro, deliberately — a read-onlyhandle cannot create the WAL -shm file, so it fails to open the databasewhenever no writer is active, which is almost always.

Configuration

Everything comes from .env. .env.example lists every setting the codereads, commented out at its default — the table below is just the ones you aremost likely to touch.

GARMIN_EMAIL / GARMIN_PASSWORD your Garmin Connect login
TZ decides where a "day" ends. Default Etc/UTC
BACKFILL_START how far back the first run reaches. Ships as 2025-01-01. Unset means about ten years — roughly 58,000 requests. Widen it deliberately
REQUEST_SLEEP pause between calls. Raise it if you see 429
MAX_CONSECUTIVE_FAILURES give up on a collector after this many failures in a row, so rate limiting ends the run instead of prolonging it. Default 10
MAX_DAY_ATTEMPTS give up on one stubborn day after this many attempts, recording it in sync_gaps. Default 5
MCP_HOST the address the server binds inside its own process. Loopback unless set
MCP_BIND_ADDR Docker only: the host address the container's port is published on. Loopback unless set
MQTT_HOST optional Home Assistant status publishing. Empty disables it

Credentials are mounted as a file rather than passed as environment variables,so Compose never tries to interpolate a $ inside your password.

Reading the data yourself

It is an ordinary SQLite file. Anything that opens one will do —DB Browser for SQLite, the sqlite3 CLI, pandas,a BI tool:

sqlite3 data/garmin.db \
  "select date, total_steps, resting_hr from daily_summary order by date desc limit 7"

If you are not using Docker

The code is plain Python and runs directly — Python 3.12 or newer (the Garminclient requires it), pip install -r requirements.txt, thenpython first_login.py once and python sync.py on a schedule.run_sync.bat and setup_scheduler.ps1 register a Windows Task Scheduler jobfor that arrangement. They are kept because they work, not because they are therecommended path.

Files

sync.py the incremental engine — the thing your scheduler runs
collectors.py what gets pulled and how it is normalised. Edit to add or drop data
database.py SQLAlchemy models and engine
garmin_auth.py login and token persistence
first_login.py one-time interactive login, handles 2FA
mcp_server.py read-only MCP query server
monitor.py optional MQTT status for Home Assistant
config.py every setting, read from .env
test_sync_gaps.py tests for the high-water mark and gap handling
test_query_guard.py tests for the MCP query guard
compose.yaml standalone deployment. Start here
compose.garmin.yaml the author's homelab stack. Absolute paths, external network — it will not run elsewhere
deploy/ systemd units and the author's deploy script

Troubleshooting

Login fails on a scheduled run. Tokens expired — re-run first_login.py.It is interactive, so it cannot happen unattended.

429 Too Many Requests. Garmin's rate limit. Wait, and raiseREQUEST_SLEEP. Long backfills are the usual cause.

A whole data type is empty. Your device probably does not record it. Thesync still succeeds and stores nothing for that type.

The container cannot write to data/. The image runs as uid 1000, whichmust own the mounted directories. Rebuild withdocker compose build --build-arg APP_UID=$(id -u).

A run reports gaps. Some days failed and are still owed. The next runretries them; if it keeps happening, the log names the collector and date.

Home Assistant shows "degraded". The schedule is healthy but the archive isnot complete — some days were given up on. select * from sync_gaps where abandoned = 1 lists them. Green means up to date and nothing missing.

Prior art

GarminDB is the mature option — moredata types, FIT-file parsing, its own analysis notebooks. Use it if you wantdepth.

This exists for a narrower shape: a container that pulls yesterday and stops, adatabase an assistant can read directly, and a status signal for HomeAssistant. That is a deployment preference, not a claim to be better.

Status

Published as reference and not actively maintained. It runs daily on theauthor's server; that is the extent of the testing. MIT.

MCP Server · Populars

MCP Server · New

    talivia-group

    Talivia Agent Kit

    Revenue-first website analytics installed and verified by AI agents through MCP

    Community talivia-group
    gura105

    Operational Ontology

    A minimal, readable reference implementation of the Operational Ontology pattern. Palantir Foundry is one implementation; this is the concept, minimized.

    Community gura105
    EllisMorrow

    Caelune

    Caelune (星野) — Local-first retrieval for private Markdown, PDF, and Tika documents, with a Windows desktop app and read-only MCP server.|本地优先的私人知识检索工具。

    Community EllisMorrow
    vmware-skills

    VMware AIops

    VMware vCenter/ESXi AI-powered monitoring and operations. Two skills: vmware-monitor (read-only, safe) and vmware-aiops (full operations) | Claude Code Skill

    Community vmware-skills
    asdecided

    AsDecided

    Native deterministic requirements-as-code engine and read-only MCP server.

    Community asdecided