Garmin
Sync a Garmin Connect account into swamp: one authenticated transport, plus domain models that read its cached responses — devices with a capability map that gates the rest, the activity history with FIT/TCX/GPX downloads, daily wellness, weight/body composition, and training + performance metrics.
Garmin Connect has no public API. It needs a signed OAuth1 handshake to mint a
rotating OAuth2 bearer token, it rate-limits aggressively, and every data
domain would otherwise repeat that fragile login. This extension does it once:
@svendowideit/garmin-connect owns login, token refresh, authenticated cached
GETs and binary downloads, while domain models declare the connectapi paths
they need and parse the cached bodies. The seam is a workflow: transport fetch
→ domain sync. Because Garmin features depend on the devices an account owns,
the devices model derives a capability map so later workflows can gate on it
instead of failing on data a device cannot produce; and the wellness and
performance models drop metrics (HRV, SpO2, VO2max, FTP) whose devices are
absent.
WHAT IT DOES
It turns a fragile, undocumented fitness API into composable swamp data. One model owns the whole auth chain (SSO → OAuth1 → OAuth2), a refresh token lasts weeks so unattended runs keep working with no password, responses are cached on disk so a rate-limited or offline run degrades to last-known data, and every domain fetch is one paced batch. You get a normalised activity history with per-activity splits and weather plus original activity files as first-class swamp files; a per-day wellness roll-up (steps, sleep and sleep score, stress, heart rate, body battery); weight with body composition; and training + performance metrics (training status and readiness, VO2max per sport, race predictions, endurance and hill score, FTP, personal records), all gated by a device capability map.
INSTALL
swamp extension pull @svendowideit/garminDEPENDENCIES
None. It uses only Deno's built-in fetch and WebCrypto (the OAuth1 HMAC-SHA1 signer is implemented here), plus the bundled deno binary swamp already ships. Nothing is installed on the host.
RUN
# See what is configured and the exact commands to fix any gap (read-only).
swamp model @svendowideit/garmin-connect method run setup garmin-connect
# Preferred: seed the session from a base64 garth token store, so no
# password is ever handled and scheduled runs need no interactivity.
swamp vault put garmin-secrets GARMIN_TOKEN_STORE
swamp model @svendowideit/garmin-connect method run import-tokens garmin-connect
# Or log in with credentials; add --input interactiveMfa=<code> if Garmin
# asks for a one-time code.
swamp vault put garmin-secrets GARMIN_EMAIL
swamp vault put garmin-secrets GARMIN_PASSWORD
swamp model @svendowideit/garmin-connect method run login garmin-connect
# Refresh the session then sync each domain; these run in sequence on
# their own schedule once 'swamp serve' is up.
swamp workflow run @svendowideit/garmin-session
swamp workflow run @svendowideit/garmin-devices-sync
swamp workflow run @svendowideit/garmin-activities-sync
swamp workflow run @svendowideit/garmin-health-sync
swamp workflow run @svendowideit/garmin-body-sync
swamp workflow run @svendowideit/garmin-performance-sync
# Backfill a wider window (any domain sync accepts a date range).
swamp workflow run @svendowideit/garmin-performance-sync --input startDate=2026-01-01 --input endDate=2026-01-31
# Download original activity files (FIT by default) for the synced list.
swamp workflow run @svendowideit/garmin-download --input format=tcx --input limit=50
# Read the data: wellness, weight, or performance and records.
swamp data get garmin-health health-range --json \
| jq '.content.summaries[] | {date, steps, sleepScore, avgStress}'
swamp data get garmin-performance records --json \
| jq '.content | {ftp, best, personalRecords}'
swamp data get garmin-performance metrics-<date> --json \
| jq '.content | {trainingStatus, trainingReadiness, vo2maxRunning}'CONFIGURE
All optional, set on the model instance (recommended — scheduled runs have no --input) or overridden per call with --input:
domain Region domain (default garmin.com; garmin.cn for China)
vaultName Vault for credentials + session (default garmin-secrets). The session and pending-MFA token resources are pinned to garmin-secrets, so sensitive tokens are always written there regardless of other vaults.
cacheDir Shared response cache (default ~/.swamp/garmin-cache)
requestDelayMs Minimum delay between origin requests (default 500)
maxRetries Retries after 429/5xx (default 2)
maxFetchesPerCall Origin-fetch cap per fetch-many/download-many (default 100)
capabilityOverrides Force capabilities true/false when detection misses
detailKinds Per-activity detail to fetch (default detail, splits, weather)
metrics Wellness and performance metrics to fetch (see README)
respectCapabilities Drop device-gated metrics (default true)
timezone IANA zone for activity bucketing and the wellness
and body default window; empty = host local. Set it
so a morning run fetches the day you are living in.
days Local wellness/body days through today (default 3; cap 14)
unit Body display unit: kg or lb (default kg; grams always kept)See every setting and its current value: swamp model @svendowideit/garmin-connect method run setup garmin-connect swamp model @svendowideit/garmin-devices method run setup garmin-devices swamp model @svendowideit/garmin-activities method run setup garmin-activities swamp model @svendowideit/garmin-health method run setup garmin-health swamp model @svendowideit/garmin-body method run setup garmin-body swamp model @svendowideit/garmin-performance method run setup garmin-performance
SETUP
Every model ships a read-only setup method: run it to see the config that
was detected or reused and exactly what is missing before a sync runs (it
prints no secret). Global arguments are persisted on the model and read back
on every run, credentials and session live in the vault, and each model
verifies the real state — a valid session, cached responses, a resolvable
window — stopping with a clear message on a blocking failure. Capabilities
enable progressively: a model fetches only the domains its config and the
account's device capability map support.
UPGRADE
Nothing is installed on the host, so there is no in-place rebuild to run.
Upgrade by updating the extension; each model carries its own upgrades
entry and migrates its stored configuration on the next run, reusing the
existing vault session and cache.
swamp extension update @svendowideit/garminWHAT IT INSTALLS
Nothing on the host — no services, daemons, or webhooks. Six cron triggers are registered while 'swamp serve' runs, staggered through the morning: garmin-session at 0 5 * * *, garmin-devices-sync at 10 5 * * *, garmin-activities-sync at 20 5 * * *, garmin-health-sync at 30 5 * * *, garmin-body-sync at 40 5 * * *, and garmin-performance-sync at 50 5 * * *. Downloads run on demand. All state lives in swamp resources; the session tokens live in the vault (marked sensitive), pinned to the garmin-secrets vault (vaultName on the session and pendingMfa resource specs) so creating another vault can never redirect them — see the README's 'Vault pinning'.
Global Arguments
| Argument | Type | Description |
|---|---|---|
| domain? | string | Garmin region domain; use garmin.cn for the China region |
| vaultName? | string | Vault the model reads credentials from and persists the session into |
| usernameKey? | string | Vault key holding the Garmin account email |
| passwordKey? | string | Vault key holding the Garmin account password |
| mfaCodeKey? | string | Vault key holding a one-time MFA code for a non-interactive first login |
| tokenStoreKey? | string | Vault key holding a base64 `garth` token store (preferred: no password) |
| username? | string | Garmin email; normally left empty and read from the vault |
| password? | string | Garmin password; normally left empty and read from the vault |
| tokenStore? | string | Inline base64 token store; normally left empty and read from the vault |
| cacheDir? | string | Shared directory raw Garmin responses are cached under |
| requestDelayMs? | number | Minimum delay between origin requests, across runs (0 disables pacing) |
| maxRetries? | number | Retries after HTTP 429/5xx, with exponential backoff |
| retryDelayMs? | number | Base backoff after a retryable failure (Retry-After takes precedence) |
| requestTimeoutMs? | number | Wall-clock budget for a single Garmin request |
| maxFetchesPerCall? | number | Maximum origin fetches a single fetch-many call makes (cached hits are free) |
| defaultMaxAgeMs? | number | Default freshness window for cached responses (0 = always prefer the cache) |
| Argument | Type | Description |
|---|---|---|
| tokenStore? | string | Base64 `garth` token store; omit to read it from the vault |
| Argument | Type | Description |
|---|---|---|
| displayName? | string | Set the display name directly, skipping the network call |
| Argument | Type | Description |
|---|---|---|
| interactiveMfa? | string | One-time MFA code to complete a challenge raised on a previous login run |
| Argument | Type | Description |
|---|---|---|
| paths | array | connectapi paths to fetch (each cached independently; blanks skipped) |
| maxFetches? | number | Cap on origin fetches this call (omit = global maxFetchesPerCall) |
| Argument | Type | Description |
|---|---|---|
| activityId | string | Garmin activity id (numeric string) |
| format? | enum | Export format; fit returns Garmin's original ZIP-wrapped FIT file |
| Argument | Type | Description |
|---|---|---|
| activityIds | array | Activity ids to download. Already-downloaded ids are skipped, so a |
| format? | enum | Export format applied to every id |
| maxDownloads? | number | Cap on downloads this call (omit = global maxFetchesPerCall). Ids beyond |
| skipExisting? | boolean | Skip ids whose export is already stored, so re-runs are cheap |
Resources
Files
Global Arguments
| Argument | Type | Description |
|---|---|---|
| cacheDir? | string | Shared cache directory to read the transport's responses from (must |
| capabilityOverrides? | record | Force specific capabilities true/false, overriding detection. Use this |
| Argument | Type | Description |
|---|---|---|
| overrides? | record | Per-run capability overrides, merged over the global ones |
Resources
Global Arguments
| Argument | Type | Description |
|---|---|---|
| cacheDir? | string | Shared cache directory to read the transport's responses from (must |
| detailKinds? | array | Which per-activity sub-resources `detail-paths` requests by default |
| timezone? | string | IANA timezone (e.g. Australia/Brisbane) used to bucket activity start |
| Argument | Type | Description |
|---|---|---|
| days? | number | How many days back from today to list (ignored when startDate is set) |
| startDate? | string | Explicit start date, YYYY-MM-DD (overrides `days`) |
| endDate? | string | Explicit end date, YYYY-MM-DD (defaults to today) |
| timezone? | string | Override the global timezone for this call when resolving the default |
| activityType? | string | Filter by Garmin activity type key (e.g. cycling, running, swimming) |
| limit? | number | Maximum activities to request in one page |
| sortOrder? | enum | Newest-first (desc) or oldest-first (asc) |
| Argument | Type | Description |
|---|---|---|
| ids? | array | Activity ids whose detail paths to build |
| kinds? | array | Override the global detailKinds for this call |
| useLastList? | boolean | When `ids` is omitted, read the ids from this model's most recent |
| Argument | Type | Description |
|---|---|---|
| ids? | array | Limit parsing to these activity ids. Omit to parse the latest list. |
| includeDetails? | boolean | Also parse any cached per-activity detail responses into `activity-detail` |
Resources
Global Arguments
| Argument | Type | Description |
|---|---|---|
| cacheDir? | string | Shared cache directory to read the transport's responses from (must |
| metrics? | array | |
| respectCapabilities? | boolean | Drop device-gated metrics the account's devices do not support, using |
| timezone? | string | IANA timezone (e.g. Australia/Brisbane) used to resolve the default |
| days? | number | Number of local days to fetch (through today) when no explicit date or |
| Argument | Type | Description |
|---|---|---|
| date? | string | Single day to fetch, YYYY-MM-DD (defaults to yesterday — a full day's data) |
| startDate? | string | Range start, YYYY-MM-DD (overrides `date`) |
| endDate? | string | Range end, YYYY-MM-DD (defaults to startDate) |
| metrics? | array | Override the global metrics for this call |
| timezone? | string | Override the global timezone for resolving the default date window. |
| days? | number | Override the global `days` (local days through today) for the default |
| capabilities? | record | The device capability map, wired in by the workflow from |
| displayName? | string | Account display name, wired in by the workflow from the transport's |
| maxDays? | number | Safety cap on the number of days a range may expand to |
| Argument | Type | Description |
|---|---|---|
| date? | string | Single day to parse, YYYY-MM-DD |
| startDate? | string | Range start, YYYY-MM-DD |
| endDate? | string | Range end, YYYY-MM-DD |
| metrics? | array | Metrics to parse (must match the paths that were fetched) |
| displayName? | string | Account display name; falls back to the cached social profile |
Resources
Global Arguments
| Argument | Type | Description |
|---|---|---|
| cacheDir? | string | Shared cache directory to read the transport's responses from (must |
| unit? | enum | Display unit for the normalised `weight` field; raw grams are always kept |
| timezone? | string | IANA timezone (e.g. Australia/Brisbane) used to resolve the default date |
| days? | number | Number of local days to fetch (through today) when no explicit date or |
| Argument | Type | Description |
|---|---|---|
| date? | string | Single day, YYYY-MM-DD (overrides the range) |
| startDate? | string | Range start, YYYY-MM-DD |
| endDate? | string | Range end, YYYY-MM-DD |
| timezone? | string | Override the global timezone for resolving the default date window. |
| days? | number | Override the global `days` (local days through today) for the default |
| maxDays? | number | Safety cap on range days when fanning out per-day paths |
| mode? | enum | `range` = one range request; `daily` = one day-view request per day |
| Argument | Type | Description |
|---|---|---|
| date? | string | Single day to parse, YYYY-MM-DD |
| startDate? | string | Range start, YYYY-MM-DD |
| endDate? | string | Range end, YYYY-MM-DD |
Resources
Global Arguments
| Argument | Type | Description |
|---|---|---|
| cacheDir? | string | Shared cache directory to read the transport's responses from (must |
| metrics? | array | |
| respectCapabilities? | boolean | Drop device-gated metrics the account's devices do not support, using |
| Argument | Type | Description |
|---|---|---|
| date? | string | Single day for daily metrics, YYYY-MM-DD (defaults to yesterday) |
| startDate? | string | Range start, YYYY-MM-DD |
| endDate? | string | Range end, YYYY-MM-DD |
| metrics? | array | Override the global metrics for this call |
| capabilities? | record | Device capability map, wired in by the workflow |
| displayName? | string | Account display name, wired in by the workflow; falls back to the cached profile |
| latestOnly? | boolean | Fetch only the latest (non-date) metrics — cheap, useful for a quick refresh |
| maxDays? | number | Safety cap on the number of days a range may expand to |
| Argument | Type | Description |
|---|---|---|
| date? | string | Single day to parse, YYYY-MM-DD |
| startDate? | string | Range start, YYYY-MM-DD |
| endDate? | string | Range end, YYYY-MM-DD |
| displayName? | string | Account display name; falls back to the cached social profile |
Resources
Ensure an authenticated Garmin Connect session before any Garmin data sync. This is the common transport workflow the other @svendowideit/garmin workflows call. It never fetches Garmin data itself — it only guarantees that @svendowideit/garmin-connect holds a usable bearer token: 1. `ensure` reuses a valid stored token, or refreshes an expired one with the stored OAuth1 token (no credentials needed), and writes a `status` resource. 2. `login` runs the SSO →
Sync the Garmin device inventory and derive the capability map. This workflow is the reference example of the transport→domain seam the whole @svendowideit/garmin family uses: 1. session call the common @svendowideit/garmin-session workflow, which guarantees a valid bearer token (refreshing if needed). 2. device-paths ask @svendowideit/garmin-devices which connectapi paths it needs, so the paths live next to the parser, not here. 3. fetch
Sync the Garmin activity history for a date window. Same transport→domain seam as garmin-devices-sync, scaled to a high-volume domain: 1. session guarantee a valid bearer token (transport workflow). 2. list-path @svendowideit/garmin-activities builds the date-ranged, paged activity list path for the window. 3. detail-paths turn the ids returned by the previous run's list into per-activity detail paths (splits, weather, HR z
Download original activity files (FIT by default) for a synced date window. Sits on top of the activities sync and the transport's fan-out download: 1. session guarantee a valid bearer token. 2. activity-ids read the ids from @svendowideit/garmin-activities' `activity-list` (synced by garmin-activities-sync). 3. download-all @svendowideit/garmin-connect's download-many stores each export as a swamp file artefact in ONE call. Ids already
Sync daily wellness from Garmin Connect for a date window. Same transport→domain seam as the other Garmin syncs, but with a prerequisite the device endpoints do not need: 1. session guarantee a valid bearer token. 2. profile fetch the account's social profile. Several Garmin wellness endpoints are addressed by the display name, which this records so the health model can build their paths. 3. health-paths @svendowideit/garmin-health build
Sync weight and body composition from Garmin Connect. The simplest transport→domain seam of the family, because the weight endpoints need no display name: 1. session guarantee a valid bearer token. 2. body-paths @svendowideit/garmin-body builds the weigh-in paths for the window — one range request by default, or one day-view request per day when a date may hold several weigh-ins. 3. fetch one fetch-many caches them. 4. sync parse into
Sync training and performance metrics from Garmin Connect. The same transport→domain seam as the health sync, with a mixed metric set: 1. session guarantee a valid bearer token. 2. profile fetch the account display name (race predictions and personal records are addressed by it). 3. perf-paths @svendowideit/garmin-performance builds the paths — one per day for date-keyed metrics, one total for the latest metrics (FTP, pers
Modified 2 behaviors
- Has README or module doc2/2earned
- README has a code example1/1earned
- README is substantive1/1earned
- Most symbols documented1/1earned
- No slow types (deprecated)1/1earned
- Dependencies pass trust audit2/2earned
- Has description1/1earned
- Platform support declared (or universal)2/2earned
- License1/2partial
no SPDX license declared
- Verified public repository2/2earned