Unofficial and unaffiliated. This project is not affiliated with, endorsed by, or supported by Garmin. It reaches Garmin Connect through an unofficial API client that can stop working whenever Garmin changes their site. You run it against your own account, at your own risk.
A self-hosted MCP server that gives Claude read access to your Garmin Connect account — activities, sleep, HRV, body-weight trend, HR zones, blood pressure, hydration — plus a small write path (weight/BP/hydration logging) and an independent training-load calculation for the sessions Garmin's own Firstbeat pipeline can't cover.
It's the data plane only. No coaching logic, no thresholds, no "you should train today" verdicts live here — those belong in a separate Claude Skill (see garmin-coach if you're using this alongside it). This server's job is to hand back real numbers, cleanly labeled, with nulls where the data genuinely isn't there.
- Ask Claude about your training in plain language — "how'd my sleep look this week," "log today's weight," "what's my HRV trend" — without opening the Garmin Connect app.
- Fixes real Garmin/Firstbeat gaps, doesn't paper over them. Garmin's Training Effect and Training Load are computed on-device and never recalculated for externally-uploaded activities (Zwift, TrainerRoad, Tonal-to-Garmin pipelines, etc.) — this project ships its own TRIMP-based training-load calculation that covers those sessions too, and every tool that does this kind of substitution says so explicitly in its output rather than silently presenting a number as Garmin's own.
- No fabricated data. Missing HRV, missing training effect, too few data points for a trend — these come back as
nullwith a stated reason, never a guessed or interpolated number. - Self-hosted, single-user. Runs on your own machine in Docker; only you hold the Garmin login and the server's own token. Nothing about your data passes through a third-party service beyond Garmin's own API and (optionally) Claude.
- Read is separate from write. Nothing writes to your Garmin account unless a tool is explicitly a write tool (
log_weight,log_bp,log_hydration, and theirdelete_*counterparts) — and those are documented as such.
Reads: get_activities, get_activity_detail, get_activity_trend, get_sleep, get_daily_stats, get_readiness, get_body_trend, get_vo2max, get_zone_summary, get_training_load, get_bp_trend, get_hydration.
Writes: log_weight, log_bp / delete_bp, log_hydration / delete_hydration.
Every tool's docstring documents where its numbers come from, what's genuinely missing vs. suppressed for being too sparse to trust, and (for get_training_load) that the figure is this project's own calculation, not Garmin's.
- Python 3.11+, Docker, a Garmin Connect account (your own login — MFA is fine, see below).
- Somewhere to run this that can stay online (a home server, NAS, always-on PC). This is not a cloud-hosted service.
Garmin's API requires an interactive login (MFA-capable) the first time; after that, a token is cached and reused.
python -m venv .venv
.venv\Scripts\activate # or: source .venv/bin/activate
pip install -e .
python scripts/login.py ./data/garthThis writes a token store to ./data/garth — mounted into the container in the next step. You should not need to do this again unless the tokens are revoked (password change, ~1 year expiry).
cp .env.example .envFill in:
| Var | What it's for |
|---|---|
MCP_BEARER_TOKEN |
The one-time login password for the server's OAuth flow (see "Auth" below). Pick a long random string. |
MCP_PUBLIC_URL |
The externally-reachable URL you'll expose this at (see step 4). Required for correct OAuth redirect URLs — 127.0.0.1 won't work here. |
SQLITE_PATH |
Leave as default unless you've changed the GarminDB layout. |
TZ |
Your local timezone, e.g. America/New_York. |
docker compose up --build -d
curl http://localhost:18080/health # {"status": "ok", ...}/health is unauthenticated; every other endpoint needs a real OAuth access token (see below), not the raw MCP_BEARER_TOKEN.
Some tools (get_body_trend, step-based trends) read from a local SQLite database synced by GarminDB rather than the live API, since a 30/90-day trend query against Garmin's live endpoints is both slow and rate-limit-risky.
python -m venv .garmindb-venv
.garmindb-venv\Scripts\pip install garmindb
python scripts/sync_garmindb.pySchedule it to run nightly (Windows Task Scheduler shown; cron works the same way on Linux/macOS):
schtasks /create /tn "garmin-mcp-sync" /tr "\"<path-to>\.venv\Scripts\python.exe\" \"<path-to>\scripts\sync_garmindb.py\"" /sc daily /st 03:00Tools that depend on this will say so clearly (TrendDataUnavailableError) if you skip this step.
Claude's connector UI requires OAuth 2.1 with Dynamic Client Registration — it won't accept a static API key directly. This server implements that (see SPEC.md for the design), gated behind the MCP_BEARER_TOKEN you set above, which acts as a one-time login password rather than a per-request header.
- Expose port
18080publicly over HTTPS — a reverse tunnel (Tailscale Funnel, Cloudflare Tunnel, ngrok) is the simplest path if you don't already have a public domain pointed at this machine. PointMCP_PUBLIC_URLin.envat that URL and restart the container. - In Claude, add a custom connector pointing at
<MCP_PUBLIC_URL>/mcp. - When prompted to sign in, enter your
MCP_BEARER_TOKENas the password. Claude then holds a short-lived OAuth token it refreshes automatically — the bearer token itself is never sent again after this step.
- 10 failed login attempts within 60 seconds locks that client out for 5 minutes.
- The container should only ever be published to
127.0.0.1(seecompose.yml) — let your tunnel/reverse-proxy be the actual public-facing edge, not the container directly.
Does this send my Garmin data anywhere besides Claude?
No. The server talks to Garmin's API and to whatever local SQLite DB GarminDB syncs; the only outbound consumer is whatever MCP client you connect (Claude, or curl/MCP Inspector for testing).
Why does get_training_load not match what I see in Garmin Connect?
Garmin's Training Effect/Training Load numbers come from Firstbeat, and Firstbeat only runs on the recording device — it never reprocesses an externally-uploaded FIT file. If any of your activities come from something other than a Garmin watch (Tonal, Zwift, TrainerRoad, manual upload), Garmin's own field is null for those and always will be. get_training_load computes its own TRIMP-style figure instead, applied uniformly across every session with heart-rate data, and labels itself explicitly as an independent calculation via its method field — it is not trying to reproduce Garmin's number.
Why is some field null when I know the data exists in Garmin Connect?
Two common reasons: (1) the value genuinely isn't populated yet for that day (Garmin doesn't backfill HRV/VO2max retroactively), or (2) there's too little data in the requested window to compute a trustworthy trend, in which case the tool returns null with a *_reason/*_note field explaining why, rather than a number fitted to sparse data.
Does this give Claude any ability to act on my behalf beyond reading/logging data? No. The only write tools are weight, blood pressure, and hydration logging (plus their deletes) — nothing else. There's no path from this server to any other Garmin account action (no deleting activities, no changing device settings, no messaging).
Can I run this without exposing it to the internet?
Yes — skip step 4 above and connect a local MCP client (MCP Inspector, or Claude Code running on the same machine) directly at http://127.0.0.1:18080/mcp. You lose mobile access from Claude's app, but nothing else requires public exposure.
Is this an official Garmin project?
No. It's built on the unofficial garminconnect Python client, which reverse-engineers Garmin Connect's own web API. It can break if Garmin changes that API; that's an accepted tradeoff of not having an official public API to build against.
docs/self-hosted-setup.md covers exposing this
server publicly with a step-by-step Tailscale Funnel example, plus notes on
picking a host port, scheduling the GarminDB sync, and running this
alongside another project that touches the same Garmin account. Cloudflare
Tunnel and other reverse-tunnel options work the same way in principle and
are left as an exercise — the doc only assumes you end up with a stable
public HTTPS URL to put in MCP_PUBLIC_URL.
garmin-mcp/
├── src/garmin_mcp/
│ ├── server.py # FastMCP app, tool registration
│ ├── oauth.py # OAuth 2.1 / DCR provider, login form, rate limiting
│ ├── garmin.py # thin wrapper over garminconnect
│ ├── store.py # SQLite (GarminDB) access
│ └── models.py # return shapes
├── scripts/
│ ├── login.py # one-time interactive Garmin login
│ ├── smoke.py # manual verification script
│ └── sync_garmindb.py # nightly GarminDB sync
├── docs/
│ └── self-hosted-setup.md # this deployment's real config
├── Dockerfile
├── compose.yml
├── .env.example
├── SPEC.md # full build spec / design decisions
└── README.md
See SPEC.md for the original design spec and locked decisions, and the build log below for what was actually verified against the live API (which occasionally disagreed with the spec's assumptions) as this was built out.
Kept for anyone extending this: what was verified against the real API/library (not assumed), what broke, and why specific design choices were made. Read top to bottom for the build history in order.
M1 — Garmin path proven. garminconnect==0.3.2 confirmed working against a real account:
Garmin()+client.login(tokenstore=dir)— token-only login, no MFA prompt when a token store already exists.Garmin(email=, password=, prompt_mfa=lambda: ...)+client.login(tokenstore=dir)— interactive first-time login (scripts/login.py).client.get_sleep_data(cdate)— returns adailySleepDTOwithsleepTimeSeconds/deepSleepSeconds/lightSleepSeconds/remSleepSeconds/awakeSleepSeconds.client.get_activities_by_date(startdate, enddate)— returns a list of activity dicts.client.add_weigh_in(weight, unitKey="lbs")+client.delete_weigh_in(weight_pk, cdate)— write path, verified: wrote a real 180.0 lb test entry and deleted it again cleanly.
Note: this garminconnect version does its own token persistence (a single garmin_tokens.json in the directory passed as tokenstore) rather than garth's default two-file dump, and garth is not a separate installed dependency.
M2 — MCP server, read tools only. FastMCP 3.4.6 over Streamable HTTP, all 6 read tools verified against MCP Inspector CLI with real data from the account:
get_activities,get_activity_detail,get_sleep,get_daily_stats,get_body_trend,get_readiness.- No auth middleware yet (M3) and no Docker yet (M3) — this runs as a plain local process.
Field mappings worth knowing (found by inspecting live responses, not guessed):
get_stats(cdate)is a goldmine for daily stats:restingHeartRate,lastSevenDaysAvgRestingHeartRate(used asrhr_7d_baseline),bodyBatteryHighestValue/LowestValue/MostRecentValue,totalSteps,activeKilocalories,averageStressLevelall come from this one call.- HRV comes from
get_hrv_data(cdate)['hrvSummary']['lastNightAvg'];weeklyAvg/baselinearenullwhile Garmin considers the device "onboarding" — surfaced asnull, never fabricated. - Sleep score is
get_sleep_data(cdate)['dailySleepDTO']['sleepScores']['overall']['value']; resting HR for that night is the sibling top-levelrestingHeartRatekey (not insidedailySleepDTO). - Body composition weight is in grams, not kg (
/ 453.59237for lb). garminconnectraisesGarminConnectAuthenticationError/GarminConnectTooManyRequestsError/GarminConnectConnectionError—garmin.py's_call()wraps every client call with bounded backoff on 429/connection errors and a clear, non-retrying error on auth failure.
M3 — Auth + Docker. Verified end to end inside a running container:
auth.py'sBearerAuthMiddleware(StarletteBaseHTTPMiddleware) checked theauthorizationheader on every request except/health, usinghmac.compare_digestagainstMCP_BEARER_TOKEN. Missing token, wrong scheme, or wrong value all got a 401. An unsetMCP_BEARER_TOKENfailed closed (denied everything) rather than accepting any token. Superseded by OAuth below — see that section for why a plain static header wasn't enough once this needed to work as a Claude connector./health(unauthenticated,mcp.custom_route) returns{status, version, garmin_token_expires_at}. The token expiry is read straight off the persistedgarmin_tokens.jsonand its JWTexpclaim — no network call, no login, so health stays fast and doesn't depend on Garmin being reachable.Dockerfile(python:3.12-slim, installs viapyproject.toml) andcompose.yml. The app listens on0.0.0.0:8080inside the container (required for Docker's port-publish to reach it);compose.ymlpublishes it to the host as127.0.0.1:18080:8080— reachable from the host's loopback only, which is what a Cloudflare Tunnel running on the same host needs. Host port18080was picked to sit clear of the port ranges other local services on the host already use.- Tested by building the image, running it with the reused token store mounted read-only, and confirming:
/health→ 200 with real expiry;tools/listwith no header → 401;tools/callwith the correct bearer token → real Garmin data.
Rate limiting (added once this went publicly reachable). FailedAttemptLimiter in auth.py: 10 failed auth attempts within 60s locks that client out for 5 minutes -- including subsequent attempts with the correct token, so a leaked/guessed token can't just be retried past a temporary block. Caught a real bug while adding this: Docker's bridge networking means request.client.host is always the Docker gateway IP (172.20.0.1) for every request regardless of true origin, which would make all real clients share one lockout bucket -- a naive implementation would have meant one noisy client (or one round of testing) locking out everyone. Fixed by trusting X-Forwarded-For instead, which is safe specifically because the container is published as 127.0.0.1:18080 (loopback-only) -- only a locally-running trusted process (Tailscale Funnel, Cloudflare Tunnel, our own tests) can reach that port at all, so a public attacker can't bypass it to spoof the header themselves. Verified with two distinct forged X-Forwarded-For clients: one gets locked out after 10 bad attempts, the other keeps working normally with the correct token.
OAuth (replaced the plain bearer middleware). Claude's "Add custom connector" UI doesn't accept a static header — per the MCP authorization spec, it expects the server to support OAuth 2.1 with Dynamic Client Registration (it POSTs /register and walks an authorize/token flow before ever sending a request with a bearer header on it). Confirmed this the hard way: registering garmin-mcp in Claude failed with "Couldn't register with garmin-mcp's sign-in service" — the client had no fallback to a plain API-key field, only "add an OAuth Client ID."
oauth.py'sSingleUserOAuthProvidersubclasses FastMCP'sInMemoryOAuthProvider, which already implements DCR, PKCE, and token issuance/refresh correctly — reusing it means the security-critical bookkeeping (code/token generation and expiry, redirect_uri validation via the SDK's ownAuthorizationRequest/client.validate_redirect_uri) is the SDK's tested code, not hand-rolled.InMemoryOAuthProvideris explicitly documented as "for testing purposes" because Dynamic Client Registration is meant to be open (any client can self-register — that's the point of DCR), so itsauthorize()step auto-approves any registered client with no credential check at all. Used as-is on a public server, that would mean anyone could complete the OAuth dance and get a working access token without ever knowingMCP_BEARER_TOKEN— DCR itself isn't a security boundary, so the boundary has to be here instead.- The fix:
SingleUserOAuthProvideroverridesget_routes()to swap in a custom/authorizehandler that renders a one-field login form (the existingMCP_BEARER_TOKEN, now used as a login password rather than a per-request header) before calling the inheritedauthorize(). Wrong token → re-renders the form with an error and feeds the sameFailedAttemptLimiterfrom the rate-limiting work above (now guarding the login form instead of the old per-request check). Correct token → normal OAuth code issuance, unchanged fromInMemoryOAuthProvider. - Net effect:
MCP_BEARER_TOKENis now transmitted once, at login, instead of on every request — after that, Claude holds a short-lived (1hr) OAuth access token that auto-refreshes. Confirmed the old static secret alone no longer works directly against/mcp(401 invalid_token) — only a real OAuth-issued access token does. MCP_PUBLIC_URL(new env var) tells the provider its own externally-reachable URL, since OAuth issuer/redirect URLs must be correct absolute URLs, not127.0.0.1.- Verified the complete flow manually end to end:
/register(DCR) →GET /authorize(form renders, all params preserved as hidden fields) → wrong token (401, re-rendered form, rate-limited after 10 attempts) → correct token (302 with code) →POST /token(real access + refresh token) → tool call with the access token (real Garmin data) → confirmed the old static token alone is rejected. Then redeployed for real and confirmed/.well-known/oauth-authorization-serverserves correct external URLs from an independent network, matching the pattern used to verify the public tunnel itself.
OAuth state didn't survive a restart (found live, 2026-08-12). InMemoryOAuthProvider holds every registered client and issued access/refresh token as plain in-process dicts -- confirmed after a host reboot: the container came back up healthy (/health fine, Tailscale Funnel fine), but Claude's existing session got a flat 401 invalid_token on every request, because the process it was issued against no longer existed. SingleUserOAuthProvider now persists that same state to /data/oauth_state.json (the volume that already holds the Garmin token store, so no new mount needed) -- loaded on startup, saved after every mutating call (register_client, authorize, exchange_authorization_code, exchange_refresh_token, revoke_token), with expired entries dropped on load rather than carried forward. Verified with a register → authorize → exchange → fresh-instance-same-file round trip: client registration and both tokens all reload correctly. This file is bearer-credential-equivalent (a valid refresh token in it means "logged in," no further check) -- same sensitivity as garmin_tokens.json next to it, covered by the same /data gitignore. One remaining gap: a client that was mid-login (registered but hadn't completed /authorize yet) at the moment of a restart just needs to retry the login form -- not persisted differently, not worth the complexity for a single-user server.
M4 — Write tools. log_weight(weight_lb, date=None) added, defaulting to today, rejecting outside 80-500 lb.
- Idempotent per date: deletes any existing weigh-in(s) for that date via
delete_weigh_inbefore adding the new one viaadd_weigh_in, so repeated calls replace rather than duplicate. - Verified through the running MCP tool (not just the library): wrote 190.5 lb to a test date, then 191.0 lb to the same date, confirmed via
get_daily_weigh_insthat exactly one entry remained (191.0 — the second write, no duplicate), then deleted it to leave the account clean. unitKey="lbs"is passed straight through toadd_weigh_in— thisgarminconnectversion accepts lbs natively (seeVALID_WEIGHT_UNITS = {"kg", "lbs"}), so no manual kg conversion was needed despite the spec's assumption that Garmin's API only takes kg.
M5 — GarminDB + trend tools. get_body_trend now reads a local SQLite database instead of the live Garmin API.
- Isolated venv (
.garmindb-venv/, gitignored).garmindb(PyPIGarminDb, 3.8.0) depends ongarminconnect==0.3.3-- a different release than the server's pinned0.3.2. Installing it into the server's.venvsilently upgradedgarminconnectand would have changed the exact API behavior M1-M4 were verified against. It's installed into its own venv instead, invoked only viasubprocessfromscripts/sync_garmindb.py; the server never imports it. - Shared token store, no password on disk.
garmindb's ownGarminConnectAuthAdaptertries a cached DI token file before falling back to a username/password fromGarminConnectConfig.json. Its config_dir is pointed atdata/garth-- the same directory the server uses forGARMIN_TOKEN_DIR-- so it logs in with the existing token andcredentials.passwordin the config stays empty. Bothgarminconnectversions read/write the samedi_token/di_refresh_token/di_client_idJSON shape, so this works even though the two components use different library versions. - Lesson learned the expensive way: GarminDB's weight download fetches one JSON file per day in the requested range, throttled to ~1 request/second. An initial config with
weight_start_dateat 2020 meant a ~2,400-day backfill (~43 minutes) for a gate that only needs 90 days -- caught partway through (723/2413 days) and killed. Reconfigured*_start_dateto 180 days back (~3 minutes for a full sync) before rerunning. Nightly runs use--latest, which only pulls new days, so this cost is one-time. - Weight unit, verified not assumed: the synced
weighttable stores pounds, not kg -- confirmed by comparing a synced row against the live API's value for the same date. store.pyopensSQLITE_PATH(GarminDB's owngarmin.db, not a separate database we maintain) read-only and queries theweighttable directly. Measured at ~0.0008s forget_body_trend(90)end to end -- the 1-second gate cleared by three orders of magnitude.- If
SQLITE_PATHdoesn't exist yet (before the first sync),get_body_trendraises a clearTrendDataUnavailableErrortelling you to runscripts/sync_garmindb.py, rather than silently falling back to the live API or fabricating data.
Post-launch fixes, found auditing real usage (2026-08-10). Two real GarminDB gaps surfaced once actual write traffic hit the account:
- Sync gaps, two independent causes.
--latestmode only ever looks forward from the newest date already local, so a weigh-in logged for a past date (backfilling) is invisible to it forever, not just delayed. Separately, GarminDB caches each day's downloaded JSON and skips re-fetching it by default -- so a day checked before a backfilled entry existed keeps serving that stale empty cache even across full resyncs, unless--overwriteforces a re-fetch.scripts/sync_garmindb.py --fullnow passes--overwrite; a second scheduled task (garmin-mcp-sync-full, daily) runs it alongside the existing 20-minute--latesttask.get_body_trendalso now returns acoverageblock and suppressestrend_lb_per_weekwith a stated reason when data's too sparse to trust, rather than ever reporting a misleadingly precise number. - HRV import is broken in this GarminDB version -- confirmed live: files download and parse fine, but zero rows ever land in the local
hrvtable, no error logged. Because the table stays permanently empty,--latestsilently fell back to a full 180-day HRV re-scan on every single 20-minute sync. DisabledhrvinGarminConnectConfig.json'senabled_statsentirely. Safe: no tool reads HRV from the local DB --get_daily_stats/get_readinesspullhrv_ms/hrv_7d_baselinefrom the live API, onlyweightis DB-backed.
Round 2 (2026-08-11): six new tools, ranked by "what would change a coaching decision." get_vo2max, get_activity_trend, get_zone_summary, get_training_load (read-only), and log_bp/get_bp_trend/delete_bp + log_hydration/get_hydration/delete_hydration (full read+write). All verified against real API calls and real data, not the feature-request doc's assumptions -- several didn't match:
- VO2max: this account has zero measurements across its entire history -- tool is correct, just empty until Garmin computes one. Found via a documented community bug report (python-garminconnect#74) that Garmin's endpoint returns the most recently known value regardless of which date is queried between real measurements --
get_vo2maxdedupes on the response's owncalendarDaterather than the requested date to handle this correctly. - Blood pressure: writing one test reading revealed Garmin auto-classifies every BP entry into a hypertension stage (
"STAGE_1_HIGH"etc.) -- deliberately stripped fromget_bp_trend's output; would have been exactly the kind of clinical interpretation this server isn't supposed to emit. Also:pulseturned out to be a required field ingarminconnect's validation (int 20-250, no default), not optional as originally assumed. - Hydration:
add_hydration_datais additive, not replace --log_hydrationkeeps that semantic since hydration is naturally a running daily total, unlike weight/BP. No delete endpoint exists at all;delete_hydrationreads the current total and subtracts it exactly. Separately, GarminDB's syncedhydration_goalis a flat placeholder (100) on every day checked, nowhere close to the real goal the live API reports --goal_ml/pct_of_goalare nulled out below an implausibility threshold rather than shipping a wrong percentage. - Zone summary: no zone-config endpoint exists in this library (checked profile/settings/device-settings methods) --
zone_boundaries_bpmis read from a real activity's own HR-zone response instead, and reports actual bpm cutoffs rather than guessing whether they're Garmin-default or a custom (e.g. Karvonen) model. - Training load: confirmed live that externally-pushed activities (e.g. from Tonal) return Garmin/Firstbeat's
activityTrainingLoad: null-- Firstbeat only runs on-device and never reprocesses externally-uploaded FIT files, so nothing on the push side would fix it. Rewroteget_training_loadto compute its own TRIMP-style figure instead -- duration x HR-intensity, using the same zone-relative HR modelget_zone_summaryalready established (no real HRmax exists anywhere in Garmin's API to build a textbook %HRR from) -- applied uniformly to every session withavg_hr.methodalways labels it as an independent calculation, not Garmin's number;sessions_missing_hr/coverage_notecover excluded sessions;ratio_suppressed_reasonfires on thin windows or when the acute/chronic comparison would silently span azone_config_idchange. - Steps/NEAT trend: DB-backed from GarminDB's
garmin_summary.dbdays_summaryrollup table. Coverage depends on how long GarminDB has been syncing for your account.
The 20-minute --latest sync kept getting slower, traced to another rolling-reprocess pattern (2026-08-21). Same class of bug as the HRV one above (a "latest" sync silently redoing more work than it needs to every run), different cause. Measured a real run directly rather than guessing: 2m47s total, with 84 of those 167 seconds spent re-parsing full FIT detail (sets, laps, records) for the last 25 activities -- not just genuinely new ones since the last sync. download_latest_activities: 25 in GarminConnectConfig.json is GarminDB's own re-fetch window (by design, to catch late corrections), but it reprocesses that whole window every single run, and Tonal-pushed strength activities carry far more per-set detail than a typical run/bike FIT file -- so the more of them sit inside that rolling 25-activity window, the slower every run gets, compounding as more get pushed. (The "Analyzing Data" phase, which does scale with total history, was confirmed fast -- under 2 seconds -- so that wasn't the cause.)
Fixed by lowering download_latest_activities to 1: confirmed via garmindb_cli.py source that latest_activity_count() (the --latest/20-minute job) and all_activity_count() (download_all_activities, the nightly --full --overwrite job) are separate config keys -- this change only affects the 20-minute job, the nightly full-refresh safety net that would catch anything a too-narrow window missed is untouched. Re-measured after the change: activity FIT processing dropped from 84s (23 files) to 25s (21 files); total run time from 2m47s to 1m45s. download_latest_activities lives in data/garth/GarminConnectConfig.json, which is gitignored (host-specific runtime config, same as the Garmin token store next to it) -- this note is the only record of the change and its reasoning.
A second bottleneck is now dominant in the remaining runtime and wasn't part of this fix: processing monitoring FIT data (FileType.monitoring_b/hrv_status, ~350-360 small files per run) took ~59 of the 105 seconds in the post-fix run. Worth a closer look later if sync time matters further -- not investigated here since it's unrelated to the activity-reprocessing pattern this note is about.