# AGENTS.md — garminconnect-js Briefing for coding agents. Not a tutorial; see `README.md` for that. This file states facts an agent needs to avoid confidently wrong code. ## 1. What this is, and the hard constraints `garminconnect-js` is a zero-dependency TypeScript client for Garmin Connect, for **Node and Next.js server runtimes**. It talks to undocumented Garmin Connect endpoints over HTTPS. Do not assume method names or response shapes from other Garmin clients (e.g. Python's garminconnect); use section 3. This file is authoritative, and these facts in particular are easy to get wrong by guessing: - **`buildWorkout` (section 3) and the `garminconnect-js/exercises` subpath are this library's own.** Neither will be in your training data. Read those sections rather than inferring an API. - **Gear defaults are set with `setGearActivityDefaults`**, a read-modify-write of the v2 gear record; the old dedicated default-gear endpoint is dead and there is no `setGearDefault` (section 6). `deleteGear` removes gear outright. - **Courses (`listCourses` … `downloadCourseGpx`, eight methods):** creating one is two calls — import parses, create saves — and a fresh course 429s "not yet ready" for a few seconds (section 6). - **Calendar events, heart-rate zone writes, activity RPE/feel/event type, `getDailyStats` and the two GraphQL schedule reads** were each re-verified live on 2026-10-06. Note the RPE is stored TIMES TEN and the schedule summaries lag writes by seconds (section 3). **Food logging (ten methods) needs Garmin Connect+**, which needs a paired device: every one but `getNutritionFoodLogRange` is a bare 403 without it. - **`getGoals` defaults `start` to 1**, because `goal-service` is 1-indexed and `start=0` returns `[]` on an account that has goals (section 6). - **Return types follow live responses.** Several endpoints that look like they should return an object actually return an array (and vice versa); section 3 records what was observed. - **`getActivity` returns `ActivitySummary`, NOT the list-row `Activity`** (since 0.10.0): measurements are under `summaryDTO`, the type under `activityTypeDTO`. Many response types extend interfaces generated from Garmin's official Personal API spec (`PersonalApi`, section 3, "Types from Garmin's Personal API spec"); every field there is optional. - **`searchActivities` fails SILENTLY on bad names**: an unknown `sortBy` or `requiredFields` value returns `[]`, not an error, and Garmin's field names are not always the response's (`averageHR` matches nothing). List filters are sent as repeated parameters; never comma-join. - **The `Live-verified` column in section 3 is the real confidence signal.** `attempted` and `n/a` appear there and mean what they say. - **Node runtime only.** Uses `node:crypto` and `Buffer`. Never works on the Edge runtime. In a Next.js route handler you MUST add `export const runtime = "nodejs";`. - **Server-only.** Credentials and tokens must never reach the browser. There is no client-side export, no `"use client"` entry point, and no safe way to call this from a Client Component. - **Zero runtime dependencies.** - **Node >= 18** (`engines.node` in `package.json`). ## 2. Minimal working example ```ts import { GarminClient, Garmin, FileTokenStore } from "garminconnect-js"; const client = new GarminClient({ tokenStore: new FileTokenStore("./tokens") }); await client.login(process.env.GARMIN_EMAIL!, process.env.GARMIN_PASSWORD!); const garmin = new Garmin(client); console.log(await garmin.getUserProfile()); console.log(await garmin.getSleepData("2026-09-20")); ``` For a process that already has tokens on disk, skip `login()` and call `client.loadTokens()` instead (returns `false`, not throw, if nothing is stored): ```ts const client = new GarminClient({ tokenStore: new FileTokenStore("./tokens") }); if (!(await client.loadTokens())) throw new Error("not connected to Garmin"); const garmin = new Garmin(client); ``` Every identifier above (`GarminClient`, `Garmin`, `FileTokenStore`) is exported from `src/index.ts` and therefore from the package root `garminconnect-js`. ## 3. Complete method reference ### `Garmin` (src/garmin.ts) — construct with `new Garmin(client: GarminClient)` | Method | Signature | Live-verified | | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `getUserProfile` | `(): Promise` | yes | | `getUserSettings` | `(): Promise` | yes: fetched directly on 2026-09-24, where before it was only ever exercised indirectly through `unitSystem`. Top-level keys `{id, userData, userSleep, userMenstrualCycleSettings, connectDate, sourceType, userSleepWindows}` | | `displayName` | `(): Promise` | via `getUserProfile` | | `fullName` | `(): Promise` | via `getUserProfile` | | `userName` | `(): Promise` | via `getUserProfile` | | `unitSystem` | `(): Promise` | yes | | `getUserprofileSettings` | `(): Promise` — GETs `/userprofile-service/userprofile/settings` (SINGULAR "settings", distinct from `getUserSettings`'s "user-settings"; the two paths are one character apart and easy to transpose); passes through unchecked. Three profile endpoints, three methods: `getUserProfile()` reads `/userprofile-service/socialProfile` (and backs `displayName`/`fullName`/`userName`), `getUserSettings()` reads `/userprofile-service/userprofile/user-settings` (and backs `unitSystem`), and this one reads `.../settings` | yes | | `hasConnectPlus` | `(): Promise` — whether the account has a Garmin Connect+ subscription, read from the cached user profile (`/userprofile-service/socialProfile`, so usually no extra request): `true` when `hasPremiumSocialIcon` is `true` or `userRoles` holds any `ROLE_SP_FEATURE_n` entry. The methods in `CONNECT_PLUS_METHODS` (`searchFoods`, `getCustomFoods`, `getCustomFoodServingUnits`, `createCustomFood`, `updateCustomFood`, `deleteCustomFood`, `logFood`, `quickAddFood`, `deleteFoodLogs`) need it; without it they throw `GarminConnectPlusRequiredError` instead of the bare 403 Garmin sends | yes, 2026-10-06: `false` on the test account (no premium icon, no `ROLE_SP_FEATURE_n`), `true` on an account with Connect+ (`hasPremiumSocialIcon: true`, eleven `ROLE_SP_FEATURE_n` roles) | | `getUserSummary` | `(cdate: string \| Date): Promise` | yes | | `getStats` | `(cdate: string \| Date): Promise` — alias of `getUserSummary` | yes | | `getStepsData` | `(cdate: string \| Date): Promise` | yes | | `getHeartRates` | `(cdate: string \| Date): Promise` | yes | | `getSleepData` | `(cdate: string \| Date): Promise` | yes | | `getHrvData` | `(cdate: string \| Date): Promise` | yes | | `getBodyBattery` | `(startdate: string \| Date, enddate?: string \| Date): Promise` | yes | | `getBodyBatteryEvents` | `(cdate: string \| Date): Promise` | yes | | `getFloors` | `(cdate: string \| Date): Promise` — throws `GarminError` if Garmin returns nothing | yes | | `getDailySteps` | `(start: string \| Date, end: string \| Date): Promise` — auto-chunks ranges over Garmin's 28-day-per-request limit into ≤28-day windows and concatenates; a single request within the limit passes its (possibly `null`) result through unchecked | yes | | `getDailyStats` | `(start: string \| Date, end: string \| Date, statsType: DailyStatsType): Promise` — GETs `/usersummary-service/stats/daily/{start}/{end}?statsType=CALORIES\|STEPS`; `statsType` is exactly `"CALORIES"` or `"STEPS"` (any other value, lower-case included, is a Garmin 404). Garmin 400s when `end - start > 27`, so longer ranges are fetched in 28-day windows and the rows concatenated; Garmin's per-request `aggregations` (averages) are DROPPED rather than returned wrong for a chunked range. A range with no data returns `[]` (Garmin answers `null`). Overlaps `getDailySteps`/`getCaloriesDaily`; this one returns total/active/resting calories together, and steps with `totalDistance` and `stepGoal` | yes, 2026-10-06. Row shape read-only on a real account (`{calendarDate, values: {totalCalories, activeCalories, restingCalories}}` / `{calendarDate, values: {totalSteps, totalDistance, stepGoal}}`, 28 rows); the 28-day limit probed both sides; a 70-day range chunked without a 400 on the test account (`smoke:gaps`), which has no wellness data and so returns `[]` | | `getWeeklySteps` | `(end: string \| Date, weeks?: number): Promise` — `weeks` defaults to 52, must be a positive integer | yes | | `getWeeklyStress` | `(end: string \| Date, weeks?: number): Promise` — same `weeks` default/validation as `getWeeklySteps` | yes | | `getWeeklyIntensityMinutes` | `(start: string \| Date, end: string \| Date): Promise` | yes | | `getStatsAndBody` | `(cdate: string \| Date): Promise` — merges `getUserSummary` with the body-composition `totalAverage` block, delegating to `getBodyComposition` (bodyComposition service) for the latter | yes | | `setBloodPressure` | `(systolic: number, diastolic: number, pulse?: number, when?: Date, notes?: string): Promise` — validates systolic 70-260, diastolic 40-150, pulse (if given) 20-250, all integers; `when` defaults to `new Date()` | yes, full round-trip on 2026-09-23: wrote 131/87, read the STORED value back via `getBloodPressure`, then deleted it by its `version` and polled to confirm it was gone | | `getBloodPressure` | `(startdate: string \| Date, enddate?: string \| Date): Promise` — `enddate` defaults to `startdate` | yes | | `deleteBloodPressure` | `(version: number \| string, cdate: string \| Date): Promise` — deletes one reading by its `version`, taken from `getBloodPressure` | yes: verified as the delete half of `setBloodPressure`'s round-trip; the `version` comes from the read-back, not from the write's response | | `addHydrationData` | `(valueInMl: number, when?: Date, cdate?: string \| Date): Promise` — raw milliliters, magnitude capped at 10000, negative values allowed; no delete endpoint exists, so **not safely round-trippable** | yes: written live on 2026-09-23 and read back via `getHydrationData`. PERMANENT: there is still no delete endpoint, so the seeded entries cannot be removed | | `getHydrationData` | `(cdate: string \| Date): Promise` | yes | | `getRespirationData` | `(cdate: string \| Date): Promise` | yes | | `getSpo2Data` | `(cdate: string \| Date): Promise` — coerces a string `lastSevenDaysAvgSpO2` to a number | yes | | `getSpo2Acclimation` | `(cdate: string \| Date): Promise` — GETs `/wellness-service/wellness/daily/spo2acclimation/{cdate}` (Garmin's Personal API spec): the day's SpO2 averages, sleep and hourly series and the monitoring environment, typed from `PersonalApi.DailySpO2AndAcclimationDTO`. Distinct from `getSpo2Data`'s path. A day with no data (even 2030-01-01) still returns the object, fields `null` | yes, 2026-10-23, read-only on a real account (`smoke:real`); all 28 spec fields present | | `getIntensityMinutesData` | `(cdate: string \| Date): Promise` | yes | | `getAllDayStress` | `(cdate: string \| Date): Promise` | yes | | `getStressData` | `(cdate: string \| Date): Promise` — identical URL to `getAllDayStress`; an alias | yes | | `getAllDayEvents` | `(cdate: string \| Date): Promise` | yes | | `getSleepDaily` | `(start: string \| Date, end: string \| Date): Promise` — Garmin's endpoint has a documented **28-day-per-request limit**; ranges beyond that are auto-chunked, de-duplicated by `calendarDate`, and sorted | yes | | `setSleepWindow` | `(cdate: string \| Date, sleepStart: Date, sleepEnd: Date): Promise` — Sets one night's bedtime and wake time, as Garmin Connect's edit-sleep-times dialog does. `cdate` is the date Garmin files the night under, the WAKE-UP day. Reads the night through `getSleepData` first. A RECORDED night's `id` is its current `sleepStartTimestampGMT` in epoch ms and CHANGES after every edit, so it is read fresh, then PUT to `/sleep-service/sleep/dailySleep/{id}` with `{calendarDate, sleepStartTimestampGMT, sleepEndTimestampGMT, sleepTimeSeconds, napTimeSeconds, sleepWindowConfirmed: true, userProfilePK, id}`, timestamps in epoch MILLISECONDS. A night no device recorded comes back as a shell with `id: null`; then the same body without `id` is POSTed to `/sleep-service/sleep/dailySleep`, which CREATES the window. That POST answers `id: null` although the stored record has one, so the method returns the read-back instead. Returns the stored record (`sleepWindowConfirmationType: "manually_confirmed"`, the new `id`). Garmin recomputes `sleepTimeSeconds` (it subtracts awake time) and the sleep score, but NOT the sleep stages: added time is counted as light sleep. Garmin 400s a window more than 6 days from `calendarDate` ("Sleep start and end must be within 6 days of calendarDate."); a 30-hour window is accepted. **There is no delete** (`DELETE .../dailySleep/{id}` is a 405): a created window can be moved, never removed. Moving one back restores the window and duration, though the night stays `manually_confirmed`. Throws `GarminError` before any request for an invalid `Date` or `sleepStart >= sleepEnd`, and before any write when Garmin returns no record, the record has no numeric `userProfilePK`, or its `id` is neither null nor a number | yes. Edit: 2026-11-11 on a real account, captured from Garmin's web client and replayed there (bedtime moved about two hours earlier, one minute further, then back, each read back), then through this client (`scripts/smoke-sleep-real.ts`): moved a minute, read back with a new `id`, restored, and window, duration and stage totals matched the snapshot. Create: 2026-11-12 on the test account (`npm run smoke:gaps`): window created on an empty night, read back, moved, restored, and the 6-day 400 confirmed. The test account now carries permanent windows on 2026-10-15 and 2026-10-16 | | `getRhrDay` | `(cdate: string \| Date): Promise` | yes | | `getRhrDaily` | `(start: string \| Date, end: string \| Date): Promise` — reshapes `allMetrics.metricsMap` into `[{calendarDate, value}]`, dropping null values | yes | | `getCaloriesDaily` | `(start: string \| Date, end: string \| Date): Promise` — merges active (metricId 22) and resting/BMR (metricId 23) series into `[{calendarDate, active, resting, total}]` | yes | | `getHrvDataRange` | `(start: string \| Date, end: string \| Date): Promise` | yes | | `getDailySummaries` | `(start: string \| Date, end: string \| Date): Promise` — GETs `/usersummary-service/v2/dailysummaries?startDate&endDate` and unwraps `data`: one object per day grouped by topic (`movement`, `calories`, `heartRate`, `intensityMinutes`, `stress`, `bodyBattery`, `hydration`, `respiration`, `pulseOx`, `jetLag`), typed from `PersonalApi.UserDailySummaryV2DTO`. Garmin caps one request at a year ("must be P1Y or less": a 365-day span passes, 366 is a 400), so longer ranges go in 365-day windows. Days with no data are ABSENT, not empty rows; a reversed range throws `GarminError` before any request (Garmin would 400 "startDate must occur before endDate") | yes, 2026-10-23, read-only on a real account (`smoke:real`): 7 days returned 7 rows; a 2-year range went out in three requests and returned the account's 131 days, no 400 | | `getDailyStress` | `(start: string \| Date, end: string \| Date): Promise` — GETs `/usersummary-service/stats/stress/daily/{start}/{end}`: `{calendarDate, values: {overallStressLevel, restStressDuration, lowStressDuration, mediumStressDuration, highStressDuration}}` per day, durations in seconds. Garmin 400s `end - start > 27` ("cannot be more than 28 days"), so longer ranges go in 28-day windows; a reversed range throws `GarminError` before any request | yes, 2026-10-23, read-only on a real account: 41 days chunked into two requests, 41 rows; the 27/28 boundary probed | | `getDailyHydration` | `(start: string \| Date, end: string \| Date): Promise` — GETs `/usersummary-service/stats/hydration/daily/{start}/{end}`: `{calendarDate, valueInML, goalInML}` per day that has an entry (the spec's `sweatLossInML`/`activityIntakeInML` were not seen). Garmin 400s `end - start > 30` ("cannot be more than 31 days"), so longer ranges go in 31-day windows | yes, 2026-10-23: three rows read back read-only from the test account's seeded hydration; `[]` on the real account, which logs none; the 30/31 boundary probed | | `countActivities` | `(): Promise` — returns the envelope's `totalCount`, not the whole response; throws `GarminError` if Garmin returns nothing or a non-numeric `totalCount` | yes | | `getActivities` | `(start?: number, limit?: number): Promise` — defaults `start=0, limit=20` | yes | | `getActivitiesForDate` | `(fordate: string \| Date): Promise` — passes through unchecked; despite the name, the path is `/mobile-gateway/heartRate/...`, not an activities-service path | yes | | `getActivitiesByDate` | `(startdate: string \| Date, enddate?: string \| Date, activitytype?: string, sortorder?: string): Promise` — paginates internally: fetches fixed pages of 20, incrementing `start` by 20, until an empty page (normal end) or 2000 pages without one (throws `GarminError`) | yes | | `getLastActivity` | `(): Promise` — delegates to `getActivities(0, 1)`, returns the last element or `null` | yes | | `getActivity` | `(activityId: number \| string): Promise` — NOT the list-row `Activity` shape: measurements are under `summaryDTO` (meters, seconds), the type under `activityTypeDTO`. `ActivitySummary` extends `PersonalApi.ActivitySummaryDTO`, typed from Garmin's Personal API spec (AGENTS.md section 3) | yes | | `getActivityTypes` | `(): Promise` — passes through unchecked; `ActivityTypesResponse` is `ActivityType[]` (154 entries observed live: `{typeId, typeKey, parentTypeId, isHidden, restricted, trimmable}`), **an ARRAY, verified live** | yes | | `searchActivities` | `(filters?: ActivitySearchFilters): Promise` — one page from `/activitylist-service/activities/search/activities` (the `getActivities` endpoint) with the filters Garmin's Personal API spec documents: `search`, `activityType` (a PARENT key, includes its subtypes; a subtype there is a 400), `activitySubType`, `excludedActivitySubTypes`, `eventType`, `startDate`/`endDate`, min/max distance (m), duration and elapsed duration (s), elevation gain (m), `minAvgPower` (W), `favorite`, `excludeChildren`, `sortBy`/`sortOrder`, `requiredFields`, `boundingBox` and dive filters. Lists go as REPEATED params (`requiredField=a&requiredField=b`); the comma form returns nothing. **Three silent traps**, found live: an unknown `sortBy` or `requiredFields` name returns `[]` with no error (and `averageHR` counts as unknown); a box missing an edge is ignored entirely (refused here before sending); a box over 50 km is a 400. `limit` defaults to 20 | yes, 2026-10-23, read-only on a real account: every filter above probed against the rows it returned (all matched), and the method itself in `smoke:real` | | `getActivityWorkouts` | `(activityId: number \| string): Promise` — GETs `/activity-service/activity/{id}/workouts`: the workouts done during the activity, `{index, workoutName, timeCreated, sport, manufacturer, multisportSet, steps, workoutPhrase}` with the steps the device ran (`PersonalApi.ActivityWorkoutDTO`, matched key for key, steps included). `[]` for an activity done without one | yes, 2026-10-23, read-only on a real account: an activity with a `workoutId` returned its workout | | `deleteActivity` | `(activityId: number \| string): Promise` — resolves to `null` on success (204). Live-verified on synthetic fixtures created by the probe itself (never against pre-existing data): create → delete → poll-confirm gone via `getActivities` | yes (fixture-only) | | `setActivityName` | `(activityId: number \| string, activityName: string): Promise` — resolves to `null` on success. Live-verified: value read back via `getActivity` after the call, not just that the request was accepted | yes | | `setActivityType` | `(activityId: number \| string, typeId: number, typeKey: string, parentTypeId: number): Promise` — resolves to `null` on success. Live-verified: `activityTypeDTO` read back via `getActivity` after the call | yes | | `setActivityDescription` | `(activityId: number \| string, description: string): Promise` — resolves to `null` on success. Live-verified: `description` read back via `getActivity` after the call | yes | | `getActivityEventTypes` | `(): Promise` — GETs `/activity-service/activity/eventTypes`: nine `{typeId, typeKey, sortOrder}` entries (`race`, `recreation`, `specialEvent`, `training`, `transportation`, `touring`, `geocaching`, `fitness`, `uncategorized`) | yes: 2026-10-06, nine entries | | `setActivityEventType` | `(activityId: number \| string, eventType: ActivityEventTypeKey): Promise` — Partial `PUT /activity-service/activity/{id}` with `{activityId, eventTypeDTO: {typeKey}}`. The `typeKey` alone is enough; Garmin fills in `typeId`/`sortOrder`. Throws `GarminError` before any request for a key outside the nine | yes: set to `race`, read back via `getActivity`, restored | | `setActivityPerceivedEffort` | `(activityId: number \| string, rpe: number \| null): Promise` — Partial PUT of `summaryDTO.directWorkoutRpe`, which Garmin stores **TIMES TEN**: pass RPE 1-10 (integer), it sends 10-100; `null` clears it. Garmin itself rejects 150 with a 400 `MEASUREMENT_NOT_VALID`. Never read-modify-write the whole `summaryDTO` instead: PUTting its stored start-coordinate pair back is a 400 | yes: RPE 7 read back as `directWorkoutRpe: 70`; `null` read back as absent | | `setActivityFeel` | `(activityId: number \| string, feel: ActivityFeel \| null): Promise` — Partial PUT of `summaryDTO.directWorkoutFeel`, one of 0/25/50/75/100 ("How did you feel?", very weak to very strong); `null` clears it. Garmin stores any number (30 read back as 30) that its apps cannot display, so the method refuses anything else | yes: 75 read back; `null` read back as absent | | `setActivityDistance` | `(activityId: number \| string, meters: number): Promise` — Partial PUT of `summaryDTO.distance` in METERS (no conversion; `createManualActivity` is the one that takes km), the edit Garmin Connect offers for treadmill runs. Garmin recomputes `averageSpeed`/`averageMovingSpeed` from it; duration, calories and laps stay as recorded, so the laps no longer sum to the total. `getActivities` lags the change by a few seconds while `getActivity` shows it at once. Throws `GarminError` before any request for a non-positive or non-finite value | yes, 2026-10-26 on a real account: a watch-recorded `treadmill_running` activity set 4500 -> 4510 m and back, each read back with the recomputed speeds; other activity types (outdoor GPS runs) untested | | `createManualActivityFromJson` | `(payload: Record): Promise` — sends `payload` to Garmin verbatim, no shape validation | yes (exercised via `createManualActivity`, which delegates to it with no other code path) | | `createManualActivity` | `(startDatetime: string, timeZone: string, typeKey: string, distanceKm: number, durationMin: number, activityName: string): Promise` — **converts units**: `distanceKm * 1000` → meters, `durationMin * 60` → seconds, before building the request body; `startDatetime` is NOT routed through `formatDate` (it's a full local timestamp, not a bare calendar date) and **must include milliseconds**, e.g. `"2026-09-22T10:00:00.000"`; omitting them produced a live HTTP 500 `ValueInstantiationException` from Garmin during verification. Live-verified: the converted `summaryDTO.distance`/`summaryDTO.duration` were read back via `getActivity` and matched the expected meters/seconds exactly (5.5km/30min → 5500m/1800s) | yes | | `importActivity` | `(file: Blob, filename: string): Promise` — multipart upload to `/upload-service/upload/{ext}` (extension from `filename`, must be `fit`/`gpx`/`tcx`) with the load-bearing `NK`/`origin`/custom `User-Agent` headers that make Garmin treat it as an import rather than a device sync; a 409 is re-raised as `GarminConnectionError` ("Activity already exists (duplicate): ...") | yes: a synthetic GPX (fake, Null-Island-adjacent coordinates) was uploaded, Garmin processed it asynchronously (~3-6s), the resulting activity was found by polling `getActivities`, then deleted | | `uploadActivity` | `(file: Blob, filename: string): Promise` — multipart upload to the PLAIN `/upload-service/upload` path, no extension suffix and none of `importActivity`'s load-bearing import headers (ordinary device-sync-shaped upload, distinct from `importActivity`'s spoofed-client import) | yes: a synthetic GPX (Null-Island coordinates) was uploaded, Garmin processed it asynchronously (~10-20s), the resulting activity was found by polling `getActivities`, then deleted and confirmed gone | | `getPersonalRecord` | `(): Promise` — GETs `/personalrecord-service/personalrecord/prs/{displayName}`; no args; passes through unchecked. `PersonalRecords` is `PersonalRecord[]`, **an ARRAY, verified live** (the test account returned `array[0]`). Note the plural type name: the method name is singular but the payload is a list | yes, and the row shape is no longer unverified: a real account returned `array[9]` on 2026-09-24, each row 17 keys `{id, typeId, status, activityId, activityName, activityType, activityStartDateTimeInGMT, actStartDateTimeInGMTFormatted, activityStartDateTimeLocal, activityStartDateTimeLocalFormatted, value, prStartTimeGmt, prStartTimeGmtFormatted, prStartTimeLocal, prStartTimeLocalFormatted, prTypeLabelKey, poolLengthUnit}` | | `downloadActivity` | `(activityId: number \| string, format?: ActivityDownloadFormat): Promise` — `ActivityDownloadFormat` is `"ORIGINAL" \| "TCX" \| "GPX" \| "KML" \| "CSV"`, default `"ORIGINAL"` | yes: all 5 formats downloaded against a synthetic fixture and confirmed non-empty (364-1505 bytes each) | | `getActivitySplits` | `(activityId: number \| string): Promise` — passes through unchecked | yes | | `getActivityTypedSplits` | `(activityId: number \| string): Promise` — passes through unchecked; richer detail than `getActivitySplits` for some activity types (e.g. Bouldering) | yes | | `getActivitySplitSummaries` | `(activityId: number \| string): Promise` — passes through unchecked | yes | | `getActivityWeather` | `(activityId: number \| string): Promise` — passes through unchecked | yes | | `getActivityHrInTimezones` | `(activityId: number \| string): Promise` — passes through unchecked | yes (returned `[]` live; a synthetic manual/GPX fixture carries no HR data) | | `getActivityPowerInTimezones` | `(activityId: number \| string): Promise` — passes through unchecked | yes (returned `[]` live; same reason) | | `getActivityDetails` | `(activityId: number \| string, maxchart?: number, maxpoly?: number): Promise` — defaults `maxchart=2000, maxpoly=4000`, sent as `maxChartSize`/`maxPolylineSize`; passes through unchecked | yes | | `getActivityExerciseSets` | `(activityId: number \| string): Promise` — passes through unchecked | yes | | `setActivityExerciseSets` | `(activityId: number \| string, payload: ActivityExerciseSets): Promise` — **replace-all semantics**, `payload` sent verbatim. See gotchas for the payload shape Garmin actually requires (undocumented by Garmin) | yes: value read back via `getActivityExerciseSets` after the call and matched exactly (`duration`, `repetitionCount`, `exercises[0].category`) | | `getActivityGear` | `(activityId: number \| string): Promise` — passes through unchecked; returns an ARRAY, verified live | yes | | `getGearActivities` | `(gearUUID: string, limit?: number): Promise` — `limit` clamped to 1000; returns `[]` on a 404 instead of throwing | yes: both paths now. A real gearUUID with one linked activity returned that activity (2026-09-24); a syntactically valid but non-existent gearUUID still returns `[]` on the resulting 404 | | `addGearToActivity` | `(gearUUID: string, activityId: number \| string): Promise` — on 404 re-raises as `GarminConnectionError` ("gear not found (likely retired/removed)") | yes. SUCCESS path closed 2026-09-24 (`npm run smoke:gaps`): gear created with a backdated `firstUseDate`, linked, then READ BACK via `getActivityGear`, which listed it. The 404 path and its error message remain covered by `smoke-writes.ts` | | `removeGearFromActivity` | `(gearUUID: string, activityId: number \| string): Promise` — **PUT**, not DELETE; same 404-handling pattern as `addGearToActivity` | yes. SUCCESS path closed 2026-09-24 in the same round-trip: after the PUT, `getActivityGear` no longer lists the gear. The 404 path remains covered separately | | `getProgressSummaryBetweenDates` | `(startdate: string \| Date, enddate: string \| Date, metric?: string, groupbyactivities?: boolean): Promise` — defaults `metric="distance", groupbyactivities=true`; both dates routed through `formatDate`; passes through unchecked | yes | | `downloadHealthSnapshot` | `(requestedDate: string \| Date): Promise` — routed through `formatDate`, routed through `client.download` | yes: RESOLVED 2026-09-24. A real account returned a 15,352-byte payload for the current date. This path had never once produced a success anywhere in this project; the 404s seen before were genuinely "no snapshot for this account/date", as the reasoning had guessed but could not show | | `getGear` | `(userProfileNumber: number \| string): Promise` — hits `/gear-service/gear/filterGear?userProfilePk=...`; passes through unchecked; returns an ARRAY of gear entries, verified live. This is the dedicated gear-CRUD service (`src/services/gear.ts`), distinct from `getActivityGear` (activities service, reuses the same base URL with `activityId` instead) | yes: live account returned the gear list (an array of gear objects) with no 404, confirming the un-versioned `filterGear` path is NOT deprecated/broken, contrary to the concern that Garmin's own web client uses `/gear-service/gear/v2/list` instead | | `createGear` | `(gearType: string, brand: string, model: string, name: string, firstUseDate: string \| Date, usageType?: GearUsageType, maxUsageDistanceKm?: number, maxUsageDurationMin?: number, notes?: string, activityTypeKeys?: string[]): Promise` — POSTs `/gear-service/gear/v2`; defaults `usageType="DISTANCE", notes=""`; `firstUseDate` routed through `formatDate`; `gearType`/`usageType` upper-cased via `validateSportKey`; **converts** `maxUsageDistanceKm` -> `maxUsageDistanceMeters` (`round(km*1000)`, floor 1) and `maxUsageDurationMin` -> `maxUsageDurationSeconds` (`round(min*60)`, floor 1), the opposite direction from `addWeighIn`'s "send raw" rule; returns the response unchecked | yes: BOTH conversions now confirmed. Distance: 5 km read back as `maximumMeters: 5000`. Duration: 90 min read back as `maxUsageDurationSeconds: 5400` from `/gear-service/gear/v2/{uuid}`, a record no earlier read surfaced. `usageType` must be one of `NONE`/`DISTANCE`/`DURATION`/`DATE` (live enum); `"TIME"` is rejected with a 400. `notes` must be non-empty | | `getGearStats` | `(gearUUID: string): Promise` — GETs `/gear-service/gear/stats/{gearUUID}`; `gearUUID` validated via `validateUuid` (hex, hyphens optional); returns `{}` on a 404 instead of throwing; other errors re-raised | yes: both a real gear UUID (returned real stats) and a bogus hex UUID (returned `{}` on the 404) were exercised live | | `deleteGear` | `(gearUUID: string): Promise` — `DELETE /gear-service/gear/v2/{gearUUID}`, resolves `null` on success (204). **The UUID must be HYPHENATED**: `getGear` returns them WITHOUT hyphens and that form 404s, so this method re-inserts them for you when handed the bare 32-char form; only hand-rolled URLs hit the 404. IRREVERSIBLE: removes the gear AND its activity history | yes: endpoint observed in Garmin's own web client (gear detail -> ⋮ -> Delete) on 2026-09-23, then verified through this client: bare uuid -> 404, hyphenated -> 204, gear count 4 -> 3 | | `setGearActivityDefaults` | `(gearUUID: string, activityTypeKeys: string[]): Promise` — The working way to set gear defaults (the old dedicated default-gear endpoint is dead; there is no `setGearDefault`). Read-modify-writes the v2 record: GETs `/gear-service/gear/v2/{uuid}`, replaces `associatedActivityTypes` with `[{activityTypeKey, defaultGear: true, preferredGear: false}]` per key, PUTs the whole record back. Keys are **lowercase** (`"running"`), matching `createGear`, NOT `setGearDefault`'s upper-cased form. `[]` clears all defaults. Concurrent callers can clobber each other | yes: set `["running","cycling"]` live and read it back from both the v2 record and `getGearDefaults` (`activityTypePk` 1 and 2) | | `getGearDefaults` | `(userProfileNumber: number \| string): Promise` — GETs `/gear-service/gear/user/{userProfileNumber}/activityTypes`; passes through unchecked; returns an ARRAY of `{uuid, activityTypePk, defaultGear}` entries, verified live | yes | | `listCourses` | `(): Promise` — GETs `/web-gateway/course/owner/` (trailing slash included); returns an ENVELOPE `{ coursesForUser: CourseSummary[] }`, not a bare array | yes, 2026-09-24, `npm run smoke:gaps`: the probe course appeared in `coursesForUser` after create | | `getCourse` | `(courseId: number \| string): Promise` — GETs `/course-service/course/{courseId}`; a missing id is a 404 `GarminHttpError` ("Course not found : {id}") | yes: the read-back for every course write, and the 404 after `deleteCourse` | | `importCourseGpx` | `(file: Blob, filename: string): Promise` — multipart POST (field `file`) to `/course-service/course/import`. **Parses only, saves NOTHING**: the result has `courseId: null` and a RESAMPLED track (13 GPX points came back as 37) with null elevations/timestamps; `courseName` is the GPX ``. Only `.gpx` accepted (only `.gpx` verified); throws `GarminError` on an empty response | yes: `courseId: null`, 37 geoPoints, and no course was created by the call | | `createCourse` | `(input: CourseInput): Promise` — POSTs `/course-service/course` with the template Garmin's web client sends. `CourseInput` = `{ name, geoPoints, coursePoints?, activityTypeId? = 1 (running), privacy? = "private" }`; privacy maps to `rulePK` public 1 / private 2. Garmin computes `distanceMeter` and elevation. Throws `GarminError` before any request on an empty name or fewer than two geoPoints | yes, stored record read back: name, `rulePK` 2, `activityTypePk` 10 as sent, `distanceMeter` 1042.07 | | `createCourseFromGpx` | `(file: Blob, filename: string, options?: { name?, activityTypeId?, privacy? }): Promise` — `importCourseGpx` then `createCourse`. Name defaults to the GPX ``, then the filename without `.gpx` | yes: this is the path `smoke:gaps` creates its course through | | `updateCourse` | `(courseId: number \| string, changes: { name?, privacy? }): Promise` — READ-MODIFY-WRITE: GETs the record, overlays the changes, PUTs the whole record back to `/course-service/course/{courseId}`, the same request Garmin's own web editor sends on Save (observed 2026-09-24). Concurrent callers can clobber each other. Throws before any request if neither field is given or the name is blank. A just-created course may 429 "not yet ready" (see gotchas) | yes: renamed and switched to public, both read back (`rulePK` 1) | | `deleteCourse` | `(courseId: number \| string): Promise` — `DELETE /course-service/course/{courseId}`, resolves `null` (204). IRREVERSIBLE. May 429 "not yet ready" for a few seconds after creation | yes: `getCourse` 404s afterwards | | `downloadCourseGpx` | `(courseId: number \| string): Promise` — GETs `/course-service/course/gpx/{courseId}` through `client.download` | yes: 4,576 bytes of GPX from the probe course | | `listCalendarEvents` | `(startdate?: string \| Date, enddate?: string \| Date): Promise` — Events also appear in `getScheduledWorkouts`' month feed as `itemType` `event`; this GETs `/calendar-service/events`, with `startDate`/`endDate` when both dates are given (inclusive) or no params for every event on the account; one date alone throws | yes: 2026-10-06, both forms listed the probe event | | `getCalendarEvent` | `(eventId: number \| string): Promise` — GETs `/calendar-service/event/{id}`; a missing id is a 404 `GarminHttpError` | yes: the read-back for every event write, and the 404 after `deleteCalendarEvent` | | `getSharedCalendarEvent` | `(shareableEventUuid: string): Promise` — GETs `/calendar-service/event/{uuid}/shareable`: an event from Garmin's public events catalogue (Races & Events -> Find an Event), with `locationStartPoint`, `subscribersCount`, `provider` and `subscribed`. Works WITHOUT subscribing (`subscribed: false`). The plain `/event/{uuid}` is a 404. Events you create never get a shareable uuid (Garmin forces them private) | yes: 2026-10-06, a catalogue 10K read by its uuid on a real account without subscribing, exactly as Garmin's web client does when the race is selected | | `createCalendarEvent` | `(input: CalendarEventInput): Promise` — POSTs `/calendar-service/event`. `CalendarEventInput` = `{name, date, startTime?: "HH:MM", timeZoneId?, eventType?, race?, primary?, distance?: {value, unit: meter\|kilometer\|yard\|mile}, goalTimeSeconds?, location?, url?, note?}`; only `name`/`date` are required by Garmin (`'addEvent.arg3.eventName' must not be blank` otherwise). Garmin forces every user-created event **PRIVATE and unshareable** whatever is sent, sets `isTrainingEvent` whenever `isPrimaryEvent` is, and answers an unknown `eventType` with a 500. It also rewrote `Europe/Zagreb` to `Europe/Paris` (same offset) while keeping `America/New_York` as sent | yes: every field read back (distance, goal, primary, start time, privacy `PRIVATE`); the event also appears in `getScheduledWorkouts`' month feed as `itemType: "event"` | | `updateCalendarEvent` | `(eventId: number \| string, changes: CalendarEventUpdate): Promise` — READ-MODIFY-WRITE: GETs the event, overlays `{name?, date?, location?, url?, note?, race?, goalTimeSeconds?}` (`null` clears an optional one), PUTs the whole record back to `/calendar-service/event/{id}`; Garmin answers with no body, so it resolves to `null`. Concurrent callers can clobber each other | yes: rename, note cleared, race off and goal changed all read back, distance untouched | | `deleteCalendarEvent` | `(eventId: number \| string): Promise` — `DELETE /calendar-service/event/{id}`, resolves `null` (204). IRREVERSIBLE | yes: `getCalendarEvent` 404s afterwards | | `getEarnedBadges` | `(): Promise` — GETs `/badge-service/badge/earned`; each badge gains `badgeImageUrls: { small, large }`, added by this library (Garmin sends no image URL): `https://connect.garmin.com/images/badges/xxhdpi/badge__sml.png` (`_lrg.png` for `large`), the URL Garmin Connect's web app builds; public, no session. Every other field passes through unchanged; stays nullable (does NOT coalesce to `[]`) | yes, and `badgeImageUrls`: 2026-09-24, all 732 URLs (23 earned + 343 available badges, 149 of them without a `badgeUuid`, both sizes) returned 200 | | `getAvailableBadges` | `(): Promise` — GETs `/badge-service/badge/available?showExclusiveBadge=true`; same `badgeImageUrls` as `getEarnedBadges`; stays nullable | yes | | `getInProgressBadges` | `(): Promise` — no HTTP path of its own: calls `getEarnedBadges()` and `getAvailableBadges()`, filters each with an in-progress predicate (progress truthy; if `progress === target`, only "in progress" when `badgeLimitCount` is set and `badgeEarnedNumber < badgeLimitCount`), then merges both filtered lists into a `Map` keyed by `badgeId` (available overwrites earned on collision, keeping the earned entry's position); never raises: a `null` from either call is treated as `[]`. Badges carry `badgeImageUrls` (inherited from the two calls) | yes | | `getBadgeDetail` | `(badgeId: number): Promise` — GETs `/badge-service/badge/detail/v3/{badgeId}`, the request Garmin Connect's web app makes when a badge is opened; `badgeId` validated as a positive integer. Returns an OBJECT: the `getEarnedBadges` fields plus `relatedBadges` (the rest of the series, each with `earnedByMe`), `badgeAssocType`/`badgeAssocDataId`/`badgeAssocDataName` (for `"activityId"`, the activity that earned it) and `followings`. Works for unearned badges. An unknown id is a **400** `GarminHttpError`, not a 404. The badge and each `relatedBadges` entry gain `badgeImageUrls` (see `getEarnedBadges`). Carries no description text; see the `BadgeDetail` type for where Garmin's web app gets it | yes, 2026-09-24 on a real account: `run_5km` (earned, `badgeAssocType: "activityId"`, 4 related), a September challenge badge (`"none"`, `relatedBadges: []`), `run_10km` (unearned, `earnedByMe: false`), and id 999999999 → 400 | | `getAdhocChallenges` | `(start: number, limit: number): Promise` — GETs `/adhocchallenge-service/adHocChallenge/historical`; `start` validated non-negative, `limit` validated positive (throws `GarminError` otherwise); passes through unchecked; returns an ARRAY, verified live | yes | | `getBadgeChallenges` | `(start: number, limit: number): Promise` — GETs `/badgechallenge-service/badgeChallenge/completed`; same `start`/`limit` validation as `getAdhocChallenges`; passes through unchecked; returns an ARRAY, verified live. **Live discovery**: Garmin's server rejects `start=0` with a 400 (`"start should > 0."`) on this endpoint even though the client-side check allows it (non-negative); the 400 is Garmin's server, not a wrong URL; call with `start>=1` in practice | yes (with `start=1`; `start=0` reproduces the documented server-side 400) | | `getAvailableBadgeChallenges` | `(start: number, limit: number): Promise` — GETs `/badgechallenge-service/badgeChallenge/available`; same `start`/`limit` validation; passes through unchecked; same array-not-dict correction and same live `start=0` -> 400 discovery as `getBadgeChallenges` | yes (with `start=1`) | | `getNonCompletedBadgeChallenges` | `(start: number, limit: number): Promise` — GETs `/badgechallenge-service/badgeChallenge/non-completed`; same `start`/`limit` validation; passes through unchecked; same array-not-dict correction and same live `start=0` -> 400 discovery as `getBadgeChallenges` | yes (with `start=1`) | | `getInprogressVirtualChallenges` | `(start: number, limit: number): Promise` — GETs `/badgechallenge-service/virtualChallenge/inProgress`; **asymmetric validation**: `start` validated POSITIVE here (rejects `start=0`), unlike the non-negative `start` on the four challenge methods above; `limit` validated positive; passes through unchecked; same array-not-dict correction | yes | | `getWeighIns` | `(startdate: string \| Date, enddate: string \| Date): Promise` | yes | | `getWeightGoals` | `(startdate: string \| Date, enddate: string \| Date): Promise` — GETs `/goal-service/goal/user/effective/weightgoal/{start}/{end}`: `{userGoals, userGoalRanges}`, weights in GRAMS (`PersonalApi.UserGoalsRangeDTO`). No range limit found (1000 days answered); a reversed range throws before any request although Garmin itself accepts one | partially: the envelope read back on both accounts (`userGoals: []`, `userGoalRanges: null`); neither has a weight goal, so the row shape is the spec's, unverified | | `addWeighIn` | `(weightValue: number, unitKey?: "kg" \| "lbs", when?: Date): Promise` — defaults `unitKey="kg"`, `when=new Date()` | yes | | `deleteWeighIn` | `(cdate: string \| Date, weightPk: number): Promise` | yes | | `addWeighInWithTimestamps` | `(weightValue: number, unitKey?: "kg" \| "lbs", dateTimestamp?: string, gmtTimestamp?: string, when?: Date): Promise` — same raw-value, no-conversion rule as `addWeighIn`; the two timestamps are COUPLED: the local instant resolves from `dateTimestamp` if given (naive strings read as LOCAL time) else from `when` (defaults `new Date()`), and `gmtTimestamp`, when omitted, is derived from THAT resolved instant, never independently from `when`. Both supplied strings are re-formatted rather than forwarded verbatim (a naive `gmtTimestamp` is read as UTC) | yes: round-tripped live (write → `getWeighIns` read-back confirmed the stored value → `deleteWeighIn`); see gotchas for both directions observed | | `getDailyWeighIns` | `(cdate: string \| Date): Promise` — GETs `/weight-service/weight/dayview/{cdate}?includeAll=true`; passes through unchecked | yes | | `deleteWeighIns` | `(cdate: string \| Date, deleteAll?: boolean): Promise` — no HTTP path of its own: calls `getDailyWeighIns`, then loops `deleteWeighIn` per entry; returns `null` (deletes nothing) if there are zero entries, or more than one entry and `deleteAll` is not `true`; otherwise deletes every entry that day and returns the count. **IRREVERSIBLE**: deletes ALL weigh-ins recorded on `cdate` when it proceeds | yes: exercised live against weigh-ins created by the same probe run only; see gotchas | | `getBodyComposition` | `(startdate: string \| Date, enddate?: string \| Date): Promise` — GETs `/weight-service/weight/dateRange`; `enddate` defaults to `startdate`; throws `GarminError` if `startdate > enddate`; passes through unchecked. `getStatsAndBody` (wellness service) now delegates to this instead of inlining its own copy of the same call | yes | | `addBodyComposition` | `(weight: number, extra?: WeightScaleFields & { timestamp?: string }): Promise` — builds a `.fit` binary in memory (`src/util/fit.ts`, a hand-rolled FIT encoder) and uploads it via `client.upload` to `/upload-service/upload`; `weight` validated positive/finite, throws `GarminError` otherwise; passes `client.upload`'s result through unchecked | yes: **the hand-rolled FIT encoder is now proven end-to-end**: 69.42 kg uploaded as a `.fit` binary, then read back from `getBodyComposition` as 69.42 kg. Garmin both ACCEPTED the bytes (so the CRC and header are right) and PARSED them correctly (so the x100 weight scaling is right). A wrong CRC would have been rejected; a wrong scale factor would have been accepted and misparsed, which only this read-back could distinguish | | `getMaxMetrics` | `(cdate: string \| Date): Promise` — the date is repeated twice in the path (start=end=cdate); passes through unchecked | yes | | `getMaxMetricsRange` | `(start: string \| Date, end: string \| Date): Promise` — throws `GarminError` if `start > end`; passes through unchecked | yes | | `getFunctionalThresholdPowerRange` | `(start: string \| Date, end: string \| Date, sport?: string, aggregation?: FtpAggregation): Promise` — defaults `sport="RUNNING"`, `aggregation="daily"`; `sport` upper-cased and validated (`^[A-Z_]+$`); `aggregation` restricted to `{daily,weekly,monthly,yearly}`; passes through unchecked | yes | | `getLactateThreshold` | `(latest?: boolean, startDate?: string \| Date, endDate?: string \| Date, aggregation?: FtpAggregation): Promise` — defaults `latest=true`, `aggregation="daily"`. TWO DIFFERENT branches, see gotchas: `latest=true` returns `{speed_and_heart_rate, power}` from two GETs; `latest=false` (requires `startDate`, throws otherwise) returns `{speed, heart_rate, power}` from three GETs | yes: both branches | | `getTrainingReadiness` | `(cdate: string \| Date): Promise` — passes through unchecked | yes | | `getMorningTrainingReadiness` | `(cdate: string \| Date): Promise` — delegates to `getTrainingReadiness`, no HTTP call of its own; filters for `inputContext === "AFTER_WAKEUP_RESET"`, falls back to the first entry; `null` for a falsy or empty result | yes | | `getEnduranceScore` | `(startdate: string \| Date, enddate?: string \| Date): Promise` — TWO branches by presence of `enddate`, see gotchas: no `enddate` hits the single-day endpoint; with `enddate` hits `.../stats` with hard-coded `aggregation="weekly"` | yes: both branches | | `getRunningTolerance` | `(startdate: string \| Date, enddate: string \| Date, aggregation?: RunningToleranceAggregation): Promise` — defaults `aggregation="weekly"`; restricted to `{daily,weekly}` (narrower than the FTP/lactate methods); passes through unchecked | yes | | `getRacePredictions` | `(startdate?: string \| Date, enddate?: string \| Date, type?: "daily" \| "monthly"): Promise` — TWO branches, all-or-nothing params (throws on a partial combination), see gotchas: no params hits `.../latest/{displayName}`; all three hit `.../{type}/{displayName}`, capped at a 366-day span | yes: both branches | | `getTrainingStatus` | `(cdate: string \| Date): Promise` — passes through unchecked | yes | | `getFitnessAgeData` | `(cdate: string \| Date): Promise` — passes through unchecked | yes | | `getHillScore` | `(startdate: string \| Date, enddate?: string \| Date): Promise` — TWO branches by presence of `enddate`, same shape as `getEnduranceScore` but the range branch hard-codes `aggregation="daily"` (NOT `"weekly"`; do not conflate the two), see gotchas | yes: both branches | | `getCyclingFtp` | `(): Promise` — latest value only; use `getFunctionalThresholdPowerRange` for history | yes | | `getHeartRateZones` | `(): Promise` — passes through unchecked | yes | | `setHeartRateZones` | `(update: HeartRateZoneUpdate): Promise` — READ-MODIFY-WRITE of one profile: GETs `/biometric-service/heartRateZones`, overlays `{sport? = "DEFAULT", trainingMethod?, maxHeartRate?, restingHeartRate?, lactateThresholdHeartRate?, zoneFloors?: [5 bpm]}` with `changeState: "CHANGED"`, PUTs `[profile]` (204), then returns the profile READ BACK. A sport with no profile starts from DEFAULT's. Setting `restingHeartRate` also turns off `restingHrAutoUpdateUsed`. **Garmin does NOT recompute floors** when the method or a heart rate changes. Send `zoneFloors` too. Floors must be strictly ascending (Garmin 400s `"Zone Floor values must be ascending"`; checked here first) | yes: DEFAULT floors changed and read back, a RUNNING profile created as HR_RESERVE, everything restored | | `deleteHeartRateZones` | `(sport: string): Promise` — PUTs the stored profile back with `changeState: "DELETED"`, which removes it so the sport falls back to DEFAULT. Refuses DEFAULT; throws for a sport with no profile instead of doing nothing | yes: the RUNNING profile was gone on read-back | | `getPowerZones` | `(): Promise` — passes through unchecked | yes | | `getPowerZonesForSport` | `(sport: string): Promise` — `sport` upper-cased and validated the same way as `getFunctionalThresholdPowerRange` | yes | | `getWorkouts` | `(start?: number, limit?: number): Promise` — defaults `start=0, limit=100`; passes through unchecked, stays nullable (does NOT coalesce to `[]`) | yes | | `getWorkoutById` | `(workoutId: number \| string): Promise` — passes through unchecked | yes | | `deleteWorkout` | `(workoutId: number \| string): Promise` — deletes the template from the workout library, irreversible | yes (fixture-only: create via `uploadRunningWorkout`, delete, poll-confirm gone) | | `downloadWorkout` | `(workoutId: number \| string): Promise` — FIT-file bytes | yes | | `uploadWorkout` | `(workoutJson: Record \| unknown[] \| string): Promise` — a string is JSON-parsed (throws `GarminError` on invalid JSON or a non-object/array result) | yes | | `updateWorkout` | `(workoutId: number \| string, workoutJson: Record \| string): Promise` — full-replace PUT; forces `workoutId` into the body to match the path id; unlike `uploadWorkout`, a string must resolve to an object, not an array | yes | | `uploadRunningWorkout` | `(workout: WorkoutInput): Promise` — fills the default `running` `sportType` (`{sportTypeId:1, sportTypeKey:"running", displayOrder:1}`) if the caller didn't supply one, then delegates to `uploadWorkout`; see gotchas for where the body shape came from | yes | | `uploadCyclingWorkout` | `(workout: WorkoutInput): Promise` — same pattern, default `sportType` `{sportTypeId:2, sportTypeKey:"cycling", displayOrder:2}` | yes | | `uploadSwimmingWorkout` | `(workout: WorkoutInput): Promise` — same pattern, default `sportType` `{sportTypeId:4, sportTypeKey:"swimming", displayOrder:3}` | yes | | `uploadStrengthWorkout` | `(workout: WorkoutInput): Promise` — same pattern, default `sportType` `{sportTypeId:5, sportTypeKey:"strength_training", displayOrder:5}` | yes | | `pushWorkoutToDevice` | `(workoutId?: number \| string, deviceId?: number \| string): Promise` — multi-call: resolves a missing `deviceId` via `/device-service/deviceservice/mylastused`'s `userDeviceId`, a missing `workoutId` via `getWorkouts(0,1)`'s first result (throws if none), then reads `getWorkoutById(workoutId).workoutName` for the push message on the final POST | yes: CLOSED 2026-09-24 against a real account with a paired device, the one write this project performs outside the test account (`scripts/push-workout-real.ts`, which requires an explicit confirmation flag). **The return type was WRONG and only this run could show it**: declared `WorkoutRecord \| null`, actually `DeviceMessage[]`: an array, of device-messaging records, not workouts. Nothing caught it because the final POST had never once succeeded. Pushing the same workout again returns `[]` (already queued) | | `getScheduledWorkouts` | `(year: number \| string, month: number \| string): Promise` — `month` is 1-12 on the way in, converted to 0-indexed on the wire; validates `year>=2000`, `month` 1-12; passes through unchecked | yes | | `getScheduledWorkoutById` | `(scheduledWorkoutId: number \| string): Promise` — uses a DIFFERENT base (`/workout-service/schedule`) than `getScheduledWorkouts` (`/calendar-service`); passes through unchecked | yes | | `getNextScheduledWorkout` | `(): Promise` — computed from two `getScheduledWorkouts` calls (this month + next, handling Dec->Jan rollover); returns `{}` if nothing matches, never throws | yes | | `scheduleWorkout` | `(workoutId: number \| string, dateStr: string \| Date): Promise` — `dateStr` routed through `formatDate` | yes | | `unscheduleWorkout` | `(scheduledWorkoutId: number \| string): Promise` — removes the calendar entry without deleting the workout template; irreversible | yes | | `getScheduledWorkoutSummaries` | `(startdate: string \| Date, enddate: string \| Date): Promise` — POSTs the fixed GraphQL query `workoutScheduleSummariesScalar(startDate, endDate)`: every scheduled workout in the range, plan or self-scheduled (`tpType: null`), as compact rows whose `scheduledWorkoutId` is what `unscheduleWorkout` takes, or `null` for a Garmin Coach workout, which also has `tpType: null` like a self-scheduled one (tell them apart by `fbtAdaptivePlanId`). Dates are validated before they are spliced into the query. A GraphQL error arrives as HTTP 200 with `errors`, turned into a `GarminError`. **It LAGS writes**: a just-scheduled workout is in the month feed at once but absent here for a few seconds | yes: 119 ITP-plan rows over a range; a self-scheduled workout appeared within 10s and left after `unscheduleWorkout` | | `getAdaptiveWorkout` | `(workoutUuid: string): Promise` — GETs `/workout-service/fbt-adaptive/{uuid}`: a Garmin Coach workout with segments, steps and `estimatedTrainingEffect`. Coach workouts have no `workoutId`, and `getWorkoutById` cannot fetch them: `/workout-service/workout/{uuid}` is a 404. Take the uuid from `getTrainingPlanWorkouts` or `getScheduledWorkoutSummaries` | yes: 2026-10-06 against a Garmin Run Coach plan enrolled for the test and quit afterwards (`DELETE /trainingplan-service/trainingplan/trainingplan/{id}`, 204, as Garmin's web client sends) | | `getTrainingPlanWorkouts` | `(calendarDate: string \| Date, options?: {firstDayOfWeek?: "monday" \| "sunday", lang?: string}): Promise` — POSTs GraphQL `trainingPlanScalar(calendarDate, lang, firstDayOfWeek)` (all three arguments are REQUIRED by Garmin) and unwraps `trainingPlanWorkoutScheduleDTOS`: one entry per enrolled plan, `{trainingPlanId, planName, trainingPlanClassification, trainingPlanDetailsDTO, workoutScheduleSummaries}`. Garmin picks the window (18 workouts across several weeks for an ITP plan); `[]` with no plan | yes: the exact query this method sends returned 18 workouts for an ITP plan on 2026-10-06, and the method returns `[]` once no plan is enrolled (`smoke:gaps`); and against a Garmin Run Coach (`FBT_ADAPTIVE`) plan it returned 7 workouts, every one with a `workoutUuid` and NO `workoutId`/`scheduledWorkoutId` | | `getDevices` | `(): Promise` — passes through unchecked; undocumented per-device shape, `deviceId` is the field the other device methods key off of | yes | | `getDeviceSettings` | `(deviceId: number \| string): Promise` — `deviceId` coerced to an int, validated positive, re-stringified before being placed in the path; passes through unchecked. Two-call sequence: get a `deviceId` from a `getDevices()` entry first, then pass it here | yes: closed 2026-09-24 against a real account with two paired devices (`npm run smoke:real`). Returns a flat OBJECT of ~135 keys (`deviceId`, `timeFormat`, `alarms`, `supportedAlarmModes`, `activityTracking`, `watchFace`, …), **not an array**, which also closes the `getDeviceAlarms` risk noted in gotchas | | `getPrimaryTrainingDevice` | `(): Promise` — passes through unchecked | yes | | `getDeviceSolarData` | `(deviceId: number \| string, startdate: string \| Date, enddate?: string \| Date): Promise` — the only raising method in this group: throws `GarminConnectionError` if the response is falsy or missing the `deviceSolarInput` key; returns `resp.deviceSolarInput`, NOT the envelope. `enddate` defaults to `startdate`, and `singleDayView` is sent `"true"` exactly when `enddate` was omitted | yes: closed 2026-09-24 the same way. The envelope key `deviceSolarInput` is confirmed present and the method returned its (empty) inner array for a non-solar device. The throw-on-missing-key branch remains unit-tested only, precisely because the key WAS there | | `getDeviceAlarms` | `(): Promise` — no HTTP path of its own: calls `getDevices()` once, then `getDeviceSettings(device.deviceId)` once per device (N+1 fan-out, sequential by design; do not parallelize), concatenating each device's `alarms`; a device with no alarms contributes nothing, never throws for that case | yes, with one gap: the N+1 fan-out ran live on 2026-09-24 across TWO real paired devices: one `getDevices`, then one `getDeviceSettings` per device, concatenated. Both devices had `alarms: []`, so concatenating NON-empty alarm lists is still unit-tested only, as is the missing-`deviceId` throw | | `getDeviceLastUsed` | `(): Promise` — passes through unchecked; also used internally by `pushWorkoutToDevice` to resolve a missing `deviceId` | yes | | `getMenstrualDataForDate` | `(fordate: string \| Date): Promise` — passes through unchecked | yes | | `getMenstrualCalendarData` | `(startdate: string \| Date, enddate: string \| Date): Promise` — passes through unchecked; Garmin rejects windows of 92+ inclusive days; not enforced here (caller's responsibility) | yes | | `getMenstrualLastConfirmed` | `(fordate: string \| Date): Promise` — passes through unchecked | yes | | `getMenstrualCycleSummary` | `(fordate: string \| Date): Promise` — passes through unchecked | yes | | `getMenstrualReports` | `(fordate: string \| Date, numberOfCycles?: number, options?: {nextReport?: boolean, reportType?: string, todayCalendarDate?: string \| Date}): Promise` — defaults `numberOfCycles=6, nextReport=false, reportType="CYCLE"`, `todayCalendarDate` defaults to today; `numberOfCycles` restricted to `{1,6,12}` or throws `GarminError`; passes through unchecked | yes | | `getPregnancySummary` | `(): Promise` — passes through unchecked | yes | | `updateMenstrualDailyLog` | `(calendarDate: string \| Date, options?: {symptoms?: string[], moods?: string[], flow?: string, discharge?: string[], sexDrive?: string, sexualActivity?: string, notes?: string, ovulationDay?: boolean}): Promise` — **full-day replace, not a merge**; at least one optional field required or throws `GarminError`; `notes: undefined` preserves the existing note, `notes: ""` clears it (distinct, load-bearing); `discharge` rejects combining `"NO_DISCHARGE"` with any other value | yes: executed live on 2026-09-23 under the account-scoped exemption, AFTER the MCT settings existed. Returns the stored day log (`symptoms`, `flow`, …), read back via `getMenstrualDataForDate` | | `updateMenstrualCalendar` | `(startdate: string \| Date, enddate: string \| Date, cycleDatesLists: (string \| Date)[][], options?: {todayCalendarDate?: string \| Date}): Promise` — **full replace, not a merge**; each `cycleDatesLists` group must be non-empty, consecutive calendar dates, and fall within `[startdate, enddate]`, else throws `GarminError` | yes: executed live on 2026-09-23 under the account-scoped exemption, AFTER the MCT settings existed; read back via the menstrual read methods | | `initMenstrualCycleSetup` | `(periodStartDate: string \| Date, periodLength: number, cycleLength: number): Promise` — does NOT also update tracking-preference settings (call `updateMenstrualSettings` separately for that); not intended for an already-configured account | yes: executed live on 2026-09-23 under the account-scoped exemption, AFTER the MCT settings existed; read back via the menstrual read methods | | `confirmMenstrualPeriodStart` | `(periodStartDate: string \| Date, periodLength: number, cycleLength: number, options?: {predictedCycle?: boolean}): Promise` — POSTs `/periodichealth-service/menstrualcycle/{periodStartDate}` directly, NOT the `dayview`/`calendar`/`lastconfirmed`/`summary` sub-paths | yes: executed live on 2026-09-23 under the account-scoped exemption, AFTER the MCT settings existed; read back via the menstrual read methods | | `updateMenstrualSettings` | `(settings: Record, options?: {userSettingsId?: number}): Promise` — `settings` must be non-empty or throws `GarminError`; multi-step: GETs `/userprofile-service/userprofile/user-settings`, overlays `settings` onto the current `userMenstrualCycleSettings`, then PUTs the same endpoint, including `id` if resolvable | yes: executed live on 2026-09-23. **CAVEAT**: on an account with NO `userMenstrualCycleSettings` block it returns 2xx and changes nothing: a silent no-op. It only has an effect once MCT is configured | | `getGoals` | `(status?: "active" \| "future" \| "past", start?: number, limit?: number): Promise` — defaults `status="active", **start=1**, limit=30`. `start` defaults to 1, NOT 0, because goal-service is 1-INDEXED and `start=0` silently returns `[]`; throws `GarminError` before any request for an invalid `status`. **Paginated, multi-call**: starting at `start`, fetches successive pages of `limit` entries (incrementing `start` by `limit` each call) until a page comes back empty/falsy, same fixed-page-size pattern as `getActivitiesByDate`; throws `GarminError` if `MAX_PAGINATED_REQUESTS` (2000) pages are fetched without ever seeing an empty one. **Sends the load-bearing `Sec-Fetch-Site: same-origin` header on every request**: without it `goal-service` silently returns `[]` for newer custom accumulation-goal types; no error, no 404, just wrong data | yes: live-verified on an account holding one real goal, created through Garmin's own UI. **This exposed a silent bug**: `start=0` returned `[]` while `start=1` returned the goal, so a 0-based default reports "no goals" on an account that has them. Hence the default of 1; pagination and the 2000-page cap remain unit-tested only | | `getGolfSummary` | `(start?: number, limit?: number): Promise` — defaults `start=0, limit=100`; `start` validated non-negative, `limit` validated positive (throws `GarminError` otherwise); query params are literally hyphenated (`per-page`, `start`), matching Garmin's own naming. **An OBJECT, not an array, verified live**: the test account (0 rounds recorded) returned a single pagination-envelope OBJECT `{pageNumber, rowsPerPage, totalRows}`, not an array | yes: `{"pageNumber":1,"rowsPerPage":10,"totalRows":0}` on the (golf-less) test account, confirming the URL/params and the corrected non-array shape; the per-scorecard row shape (when `totalRows > 0`) is unverified | | `getGolfScorecard` | `(scorecardId: number \| string): Promise` — GETs `/gcs-golfcommunity/api/v2/scorecard/detail`; hyphenated query params `scorecard-ids` and `include-longest-shot-distance` (sent as the literal string `"true"`); passes through unchecked | attempted: a fabricated `scorecardId` (no real scorecard exists on the test account) once returned 200 with `{}`, not a 404, so the path is reachable; that proves the URL parses and nothing more, and the real-scorecard response shape is unverified. The smoke probe now SKIPs rather than fabricating an id: `gcs-golfcommunity` scorecard ids look globally scoped, so a low fabricated id could name another user's scorecard | | `getGolfShotData` | `(scorecardId: number \| string, holeNumbers?: string): Promise` — GETs `/gcs-golfcommunity/api/v2/shot/scorecard/{scorecardId}/hole`; `holeNumbers` accepts commas or hyphens as separators (spaces stripped), re-joined with `-` before sending as the hyphenated `hole-numbers` param; **if any requested hole number is >9, the filter is silently dropped and all 18 holes are requested instead** (Garmin's endpoint drops double-digit hole numbers from a filtered query); omitting `holeNumbers` also fetches all 18; passes through unchecked | attempted: a fabricated `scorecardId` returned an HTTP **410** (Gone), not the 404 a no-such-resource read normally produces elsewhere in this library. Whether the path is still live is UNCONFIRMED: 410 conventionally means "removed", and only a real scorecard id can distinguish that from "no such scorecard". The hole-number normalization and the >9 drop-filter branch are unit-tested only, never exercised against Garmin | | `getGolfClubStats` | `(limit?: number): Promise` — defaults `limit=1000`; validated positive; GETs `/gcs-golfcommunity/api/v2/club/player`; hyphenated query params `per-page` and `include-stats` (literal `"true"`). **An ARRAY, verified live**: the test account returned a JSON ARRAY of 17 club entries (`{id, clubTypeId, shaftLength, flexTypeId, averageDistance, adviceDistance, retired, deleted, lastModifiedTime}`), not a single object | yes: `array[17]` on the test account (pre-existing club data, not created by the probe) | | `getGolfUserStats` | `(): Promise` — GETs `/gcs-golfcommunity/api/v2/player/stats`; handicap and strokes-gained overview, no params; passes through unchecked | yes: `{"numRounds":0}` on the (golf-less) test account, confirming the URL; the full field list once rounds exist is unverified | | `getNutritionDailyFoodLog` | `(cdate: string \| Date): Promise` — GETs `/nutrition-service/food/logs/{cdate}`; passes through unchecked | yes: a live 6-key object on the test account for a recent date, confirming the URL; the field list is undocumented (see `NutritionDailyFoodLog`'s index-signature type) | | `getNutritionDailyMeals` | `(cdate: string \| Date): Promise` — GETs `/nutrition-service/meals/{cdate}`; passes through unchecked | yes: a live 2-key object on the test account for a recent date, confirming the URL | | `getNutritionDailySettings` | `(cdate: string \| Date): Promise` — GETs `/nutrition-service/settings/{cdate}`; passes through unchecked | yes: `null` on the test account for a recent date (no nutrition settings configured), confirming the URL responds without a 404 | | `getNutritionFoodLogRange` | `(startdate: string \| Date, enddate: string \| Date): Promise` — GETs `/nutrition-service/food/logs/range?startDate&endDate`: `{dailyNutritionSummaries}`, one day-log per day that has anything logged. The ONLY food-logging call that works without Connect+ (returns no days then) | yes: a day with three probe entries came back as one summary; `[]` on accounts with nothing logged | | `searchFoods` | `(query: string, start?: number, limit?: number): Promise` — GETs `/nutrition-service/food/search?searchExpression&start&limit` (defaults 0/20): `{results: Food[], moreDataAvailable}`; catalogue foods carry `source: "FATSECRET"` and several servings each. **Needs Garmin Connect+**, which needs a paired Garmin device (a bare `403 ForbiddenException` without it) | yes: 2026-10-06 on a real account with Connect+ (`scripts/smoke-nutrition-real.ts`, which deletes everything it creates); the test account gets 403 | | `getCustomFoods` | `(search?: string, start?: number, limit?: number): Promise` — GETs `/nutrition-service/customFood` with `includeContent=true`: `{customFoods, moreDataAvailable}`. **`limit` is capped at 20** (Garmin 400s above it). There is NO get-by-id: `GET /customFood/{id}` is a 405, so search by name to read one back. Needs Connect+ | yes: 2026-10-06 on a real account with Connect+ (`scripts/smoke-nutrition-real.ts`, which deletes everything it creates); the test account gets 403 | | `getCustomFoodServingUnits` | `(): Promise<{servingUnits: {name}[]} \| null>` — GETs `/nutrition-service/metadata/customFoodServingUnits` (13 units). Needs Connect+ | yes: 2026-10-06 on a real account with Connect+ (`scripts/smoke-nutrition-real.ts`, which deletes everything it creates); the test account gets 403 | | `createCustomFood` | `(input: CustomFoodInput): Promise` — **A PUT**, not a POST, to `/nutrition-service/customFood`; `CustomFoodInput` = `{name, calories, servingUnit? = "G", servingSize? = 100, brand?, carbs?, protein?, fat?, fiber?, sugar?, saturatedFat?, transFat?, sodium?, cholesterol?, potassium?, calcium?, iron?, vitaminD?}` per ONE serving, absolute amounts (not %DV); numbers are sent as strings, as Garmin's own client does. Returns the stored food with the `foodId`/`servingId` `logFood` takes. Needs Connect+ | yes: 2026-10-06 on a real account with Connect+ (`scripts/smoke-nutrition-real.ts`, which deletes everything it creates); the test account gets 403; 250 kcal read back through `getCustomFoods` | | `updateCustomFood` | `(foodId: string, servingId: string, input: CustomFoodInput): Promise` — The same PUT carrying both ids. **FULL REPLACE**: a nutrient or brand left out is removed (verified: the brand was dropped). Needs Connect+ | yes: 2026-10-06 on a real account with Connect+ (`scripts/smoke-nutrition-real.ts`, which deletes everything it creates); the test account gets 403; 260 kcal / protein 12 read back, brand gone | | `deleteCustomFood` | `(foodId: string): Promise` — `DELETE /nutrition-service/customFood/{foodId}`, resolves `null`. IRREVERSIBLE. Needs Connect+ | yes: 2026-10-06 on a real account with Connect+ (`scripts/smoke-nutrition-real.ts`, which deletes everything it creates); the test account gets 403; no longer listed | | `logFood` | `(input: FoodLogInput): Promise` — PUTs `/nutrition-service/food/logs` with one `REGULAR_LOG` item and returns the whole day's log. `FoodLogInput` = `{date, foodId, servingId, servings? = 1, time?, meal?, source? = "GARMIN", regionCode?, languageCode?}`. Pass the catalogue food's `source`/`regionCode`/`languageCode` for a search result. **Needs a `mealId`**, which only exists after Garmin's nutrition setup in the app (400 `mealId must not be null` otherwise; this method throws a clearer error first). The meal is the named one, else the one whose window holds `time`, else SNACKS; with a `meal` and no `time` it picks a time that fits. Garmin VALIDATES the pairing: a snack inside LUNCH's window is a 400 `"Meal time for Snacks overlap with meal type: LUNCH"`. Needs Connect+ | yes: 2026-10-06 on a real account with Connect+ (`scripts/smoke-nutrition-real.ts`, which deletes everything it creates); the test account gets 403; a custom food at 1.5 servings at 12:30 landed in LUNCH, a catalogue food named BREAKFAST in BREAKFAST | | `quickAddFood` | `(input: QuickAddInput): Promise` — PUTs `/nutrition-service/food/logs/quickAdd`: an entry by `name` + `calories`/`carbs`/`protein`/`fat` with no food behind it (`QUICK_ADD`). Same meal rules as `logFood`. Needs Connect+ | yes: 2026-10-06 on a real account with Connect+ (`scripts/smoke-nutrition-real.ts`, which deletes everything it creates); the test account gets 403; landed in SNACKS | | `deleteFoodLogs` | `(date: string \| Date, logIds: string[]): Promise` — `DELETE /nutrition-service/food/logs/{date}` with `{logIds}` as the body: any number in ONE call, regular and quick-add alike. `logId`s are on the entries of `getNutritionDailyFoodLog`. IRREVERSIBLE. Needs Connect+ | yes: 2026-10-06 on a real account with Connect+ (`scripts/smoke-nutrition-real.ts`, which deletes everything it creates); the test account gets 403; three entries removed in one call, the account's own untouched | | `getTrainingPlans` | `(): Promise` — GETs `/trainingplan-service/trainingplan/plans`; no params; passes through unchecked | yes: a live ENVELOPE object on the test account: `{ trainingPlanList: [], searchFilter: {...} }`. Note the shape: the plans are under `trainingPlanList`, NOT at the top level, so treating the result as an array yields nothing (a smoke probe made exactly that mistake and could never find an id). The test account has no actual plans, so the per-plan row shape inside the list is unverified | | `getTrainingPlanById` | `(planId: number \| string): Promise` — GETs `/trainingplan-service/trainingplan/phased/{planId}`; passes through unchecked | yes: CLOSED 2026-09-24 against an **ITP** plan (36 keys). The earlier "needs a PHASED plan" reading was WRONG: `/phased/{id}` is not restricted to `trainingPlanCategory === "PHASED"`. It serves ITP too and rejects only STATIC, which is what a Garmin Coach plan is. The 400 `"Not a phased plan."` names the path, not the required category | | `getAdaptiveTrainingPlanById` | `(planId: number \| string): Promise` — GETs `/trainingplan-service/trainingplan/fbt-adaptive/{planId}`, a distinct sub-path from `getTrainingPlanById`'s `phased` path; passes through unchecked | yes: verified against a STATIC Garmin Coach plan (32 keys) and again on 2026-09-24 against an ITP plan (35 keys). Unlike `getTrainingPlanById` it also serves STATIC, so it is the more permissive of the two | | `deleteTrainingPlan` | `(planId: number \| string): Promise` — `DELETE /trainingplan-service/trainingplan/trainingplan/{planId}` (the `trainingplan` segment really is doubled): the request Garmin Connect's "Quit Plan" sends, observed in the web client on 2026-10-06. Resolves `null` (204); the plan's scheduled workouts leave the calendar, completed activities stay. A second call is a 404 `"Training plan not found with ID: …"`. IRREVERSIBLE | yes, 2026-10-06 on a real account: a Garmin Run Coach plan enrolled for the test was removed through this method; `getTrainingPlans` then listed 0 plans and `getAdaptiveTrainingPlanById` 404'd | | `getLifestyleLoggingData` | `(cdate: string \| Date): Promise` — GETs `/lifestylelogging-service/dailyLog/{cdate}`; passes through unchecked. Grouped under `misc` per the plan's explicit instruction, even though it superficially resembles a wellness-daily endpoint | yes: a live 200 on the test account confirms the URL; field-level shape is undocumented | | `requestReload` | `(cdate: string \| Date): Promise` — POSTs `/wellness-service/wellness/epoch/request/{cdate}` with no JSON body; asks Garmin to reload/recompute a day's data (Garmin offloads older data, so this forces it back) | yes: run live on 2026-09-23; returns `{userProfilePk, calendarDate, status: "COMPLETE", source: "USER", ghReloadMetaData, createDate, deviceList}`. Harmless: it asks Garmin to re-process an existing day, it does not create data. | | `queryGarminGraphql` | `(query: Record): Promise` — POSTs the caller's GraphQL body verbatim to `/graphql-gateway/graphql`. **The leading slash is load-bearing**: `connectapi` composes the request URL by plain string concatenation (`` `https://connectapi.${domain}${path}` ``), not `URL`-relative joining, so omitting the leading slash here would silently glue onto the hostname (`connectapi.garmin.comgraphql-gateway/graphql`) rather than 404; the usual "a 404 means the URL is wrong" heuristic would not even catch it. The leading slash is therefore hardcoded and deliberate; `tests/services/misc.test.ts` pins the literal composed URL | yes: a minimal `{"query": "{ __typename }"}` POST against the live account returned an HTTP 200 (a GraphQL-shaped error body, not a 404), confirming the composed URL is reachable; the schema itself is undocumented and out of scope | | `logout` | `(): Promise` — clears the configured `TokenStore` (`host.client.tokenStore.clear()`); makes **no HTTP call** (the token is never revoked server-side). Does NOT clear the in-memory tokens already held by the calling `GarminClient` instance: there is no public API to do that, and this method's host is deliberately scoped to `{ client }` only. **NEVER call this against a `FileTokenStore` pointed at `./tokens`**: that is the test harness's live session | n/a, and fully covered: it makes no HTTP call, so there is nothing to verify against Garmin, and that is not a gap. Unit-tested against `MemoryTokenStore` and a temp-dir `FileTokenStore` (on-disk files confirmed deleted), with the no-HTTP-call property ASSERTED against an intercepted client rather than assumed, plus the in-memory-tokens-survive trap below. Intentionally never run against `./tokens` | `Garmin` exposes the underlying client as `readonly client: GarminClient`. ### `GarminClient` (src/client.ts) — construct with `new GarminClient(options?: GarminClientOptions)` ```ts interface GarminClientOptions { tokenStore?: TokenStore; // default: new MemoryTokenStore() isCn?: boolean; // default false; true routes to garmin.cn timeoutMs?: number; // default 10s for ordinary calls retries?: number; backoffMs?: number; fetchImpl?: typeof fetch; // injection point for tests } ``` | Method | Signature | Notes | | --------------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `login` | `(email: string, password: string): Promise` | `LoginResult` is `{state:"success", oauth1, oauth2} \| {state:"mfa_required", mfaState}`. Does NOT throw on MFA; check `result.state`. | | `resumeLogin` | `(mfaState: MfaState, code: string): Promise` | Completes an MFA login started elsewhere. | | `loadTokens` | `(): Promise` | Returns `false`, does not throw, when the store is empty. | | `setTokens` | `(tokens: Tokens): void` | Synchronous, in-memory only; does not call `tokenStore.save`. | | `getTokens` | `(): Tokens \| null` | | | `refreshTokens` | `(options?: { withinMs?: number }): Promise` | Refreshes the OAuth2 token if it expires within `withinMs` (min 60 s) and returns the current tokens, persisting through the `TokenStore`; shares an in-flight refresh. For servers that carry tokens outside a store (the hosted MCP server seals them into OAuth tokens). Ordinary calls never need it. | | `connectapi` | `(path: string, options?: ApiOptions): Promise` | The escape hatch (see section 4). Returns `null` on a 204 or empty body. | | `download` | `(path: string, options?: ApiOptions): Promise` | Default timeout 60s, not 10s. Live-verified via `downloadActivity` (all five formats). | | `upload` | `(file: Blob, filename: string, path?: string, options?: Pick): Promise` | `path` defaults to `/upload-service/upload`. Multipart, field name `file`; `Content-Type` is left unset so `fetch` sets the boundary. Default timeout 60s. `options.headers` (added for `importActivity`) seeds a `Headers` on top of the default `User-Agent`, then the transport `.set()`s its own `authorization` last: a caller can override `User-Agent` but never the injected bearer token. This used to be object spread, which only overwrote on an EXACT key match: a caller-supplied capital-A `Authorization` survived and `new Headers()` combined the two into `Bearer CALLER, Bearer REAL`. `Headers.set` is case-insensitive, so the invariant now genuinely holds (tested in `tests/path-safety.test.ts`). Live-verified via `importActivity` (a synthetic GPX upload). | `ApiOptions`: `{ method?, params?: QueryParams, json?: unknown, headers?: Record, timeoutMs?: number }`. There is no public `retry` field on `ApiOptions` — see section 6 for what that means for POST. `QueryParams` is `Record`: `undefined` is left out and an ARRAY repeats the key once per element (`requiredField=a&requiredField=b`), which is how Garmin's list parameters take several values; a comma-joined string is not the same thing to Garmin. `readonly domain: string` (`"garmin.com"` or `"garmin.cn"`) and `readonly tokenStore: TokenStore` are also public on `GarminClient`. ### `buildWorkout` (src/workout-builder.ts) — a fluent workout builder > Everything an agent needs is in this section; it does not depend on any other file. The > human-facing guide with worked examples for every sport is [`WORKOUTS.md`](WORKOUTS.md), which > ships in the package, and runnable versions are in `examples/workout-gallery.ts`. NOT a method on `Garmin`; a standalone export. `uploadWorkout` still accepts raw JSON unchanged — `build()` returns the very same `WorkoutInput` you would write by hand. ```ts import { buildWorkout } from "garminconnect-js"; const workout = buildWorkout("Threshold 100s", { sport: "swimming", poolLength: 25 }) .warmup({ distance: 400, stroke: "free" }) .rest({ lapButton: true }) .repeat(8, (set) => set.interval({ distance: 100, stroke: "free", drill: "kick" }).rest(15)) .cooldown({ distance: 200, stroke: "any_stroke" }) .build(); await garmin.uploadWorkout(workout); ``` It exists to make the four documented traps unreachable: | Trap | What the builder does | |---|---| | `stepOrder` is global and runs through repeat children | Owns one counter for the whole tree | | id/key/displayOrder triples must agree | Pairs them from the live enums; a typo is a compile error | | a rest in seconds is `fixed.rest`, not `time` | `.rest(15)` picks `fixed.rest`; `.rest({ time: 15 })` still gives `time` | | pace targets are descending m/s speeds | `target: { pace: { minPerKm: [4.5, 5] } }` converts and inverts | **Steps**: `.warmup` `.cooldown` `.interval` `.recovery` `.main` `.other` `.rest` — all 8 of Garmin's step types (`repeat` is the block form below). **End conditions**, exactly one per step, all live-verified: `distance` (m) · `time` (s) · `reps` · `lapButton` · `restSeconds` (`fixed.rest`) · `calories` · `heartRateBpm` · `powerWatts` · `fixedRepetition` (pool lengths). **Targets** via `target`, and a second simultaneous one via `secondaryTarget`: `pace: { minPerKm | minPerMile }` · `speedMetresPerSecond` · `powerZone` · `powerWatts` · `heartRateZone` · `heartRateBpm` · `cadence` · `gradePercent` · `resistance` · `swimCssOffsetSeconds`. `gradePercent` is accepted as a PRIMARY target only. As a `secondaryTarget` Garmin stores its first value MULTIPLIED BY TEN and the second unchanged (`[2, 5]` reads back `[20, 5]`, verified over six pairs), so the builder throws instead of sending it. Grade as the primary target is exact. Note power and heart rate each have TWO forms under the SAME target key — a configured zone (`powerZone`/`heartRateZone`, stored as `zoneNumber`) or an explicit range (`powerWatts`/ `heartRateBpm`, stored as a value pair). Both are live-verified. `secondaryTarget` maps onto the `secondaryTargetType`/`secondaryTargetValueOne`/`secondaryZoneNumber` family, which is what the bike designer calls "Secondary Target"; the fields are omitted entirely when unused. Watch the one naming overlap: at the TOP level of a step, `heartRateBpm`/`powerWatts` are numbers and mean END CONDITIONS; inside `target`, they are ranges and mean TARGETS. **Swim**: `stroke` (10 values) · `drill` (3) · `equipment` (5), plus `poolLength`/`poolLengthUnit` on the workout. **Strength/HIIT**: `exercise: { category, name? }` — a category alone is accepted — and `weightKg`. **Any step**: `notes`. **Exercise `category` is TYPE-CONSTRAINED to 53 values**, because an invalid one fails the WHOLE upload with `400 "Invalid category"` — not just that step. The set is the FIT SDK `exercise_category` enum (34) plus 19 Connect additions, exported as `WORKOUT_EXERCISE_CATEGORIES`: FIT — `BENCH_PRESS` `CALF_RAISE` `CARDIO` `CARRY` `CHOP` `CORE` `CRUNCH` `CURL` `DEADLIFT` `FLYE` `HIP_RAISE` `HIP_STABILITY` `HIP_SWING` `HYPEREXTENSION` `LATERAL_RAISE` `LEG_CURL` `LEG_RAISE` `LUNGE` `OLYMPIC_LIFT` `PLANK` `PLYO` `PULL_UP` `PUSH_UP` `ROW` `SHOULDER_PRESS` `SHOULDER_STABILITY` `SHRUG` `SIT_UP` `SQUAT` `TOTAL_BODY` `TRICEPS_EXTENSION` `WARM_UP` `RUN` `UNKNOWN`; Connect — `BANDED_EXERCISES` `BATTLE_ROPE` `SLED` `SUSPENSION` `SANDBAG` `SLEDGE_HAMMER` `TIRE` `LADDER` `BIKE_OUTDOOR` `INDOOR_BIKE` `RUN_INDOOR` `ELLIPTICAL` `STAIR_STEPPER` `FLOOR_CLIMB` `BIKE` `STRETCH` `INDOOR_ROW` `POSE` `MOVE`. (`BIKE`/`STRETCH`/`UNKNOWN` work via the API but are absent from the web picker; `POSE` — yoga poses — `MOVE` — pilates/mobility moves — and `INDOOR_ROW` are in no picker either and were found in Garmin's translations bundle.) 49 other plausible names were tested and ALL rejected — notably `COOL_DOWN` (while `WARM_UP` is valid), `YOGA`/`PILATES`/`MOBILITY` (sports, not categories), muscle groups like `CHEST`/`LEGS`, and lift names like `CLEAN`/`SNATCH` (those go in `name` under `OLYMPIC_LIFT`). **Exercise `name` FAILS SILENTLY, unlike `category`.** An unrecognised name is NOT rejected — it is stored as an EMPTY STRING and the upload succeeds. The same happens when the name is real but belongs to another category (`{category: "SQUAT", name: "BARBELL_BENCH_PRESS"}` stores `""`). **The builder's `exercise` option makes that a compile error.** Its type is `WorkoutExercise`: once `category` is set, `name` autocompletes to that category's names only, and any other string fails `tsc`. The data behind it is the `garminconnect-js/exercises` subpath, a SEPARATE entry point holding all **1830 verified names across 51 categories**. The root bundle's JavaScript carries none of it (~60 KB) — only its `.d.ts` imports the name types — so there is no runtime cost. ```ts import { buildWorkout } from "garminconnect-js"; import { exercise, isExerciseName, EXERCISES } from "garminconnect-js/exercises"; buildWorkout("Legs", { sport: "strength_training" }) .interval({ reps: 8, exercise: { category: "SQUAT", name: "BARBELL_BACK_SQUAT" } }) .interval({ reps: 8, exercise: { category: "SQUAT", name: "BARBELL_BENCH_PRESS" } }) // compile error .interval({ reps: 8, exercise: exercise("SQUAT", "BARBELL_BACK_SQUAT") }) // same check, as a helper .build(); isExerciseName("SQUAT", name); // runtime check, for names that are only strings at runtime EXERCISES.SQUAT; // readonly string[] — all 106 squat variants ``` - `exercise(category, name)` returns `{ category, name }`, the exact shape a step's `exercise` option takes. It is the identity function at runtime — the checking is entirely in the types. - `ExerciseName<"SQUAT">` is the name union for one category; `ExerciseCategoryWithNames` is the key union of `EXERCISES`. - `STRETCH` and `UNKNOWN` are valid categories with NO names in Garmin's catalogue, so they are absent from `EXERCISES` — pass them as a bare `{ category }`. `WORKOUT_EXERCISE_CATEGORIES` in the package root remains the full list of 53. - Provenance: candidates came from Garmin's own translations bundle (`connect.garmin.com/web-translations/exercise_types/exercise_types.properties`, public, no auth); `scripts/verify-exercises.ts` then uploaded every one to a live account and kept only the names Garmin echoed back unchanged. 44 were rejected that way. Nine real keys begin with an underscore (`_3_WAY_CALF_RAISE`) — that is not a typo, and the form without it is rejected. - A name held in a plain `string` (from a database, a form) no longer compiles. Check it with `isExerciseName` first, then cast; or read the workout back and assert `exerciseName` is non-empty. A category typed as the whole `ExerciseCategory` union still works with no `name`. **Blocks**: `.repeat(n, fn)` and `.repeatForSeconds(s, fn)` (the latter sets `numberOfIterations: null`). Blocks nest, including a count-based repeat inside a time-based one. **Multi-sport**: `.leg(sport, fn)` per leg — using it switches the workout to `multi_sport`. **Sports** (`WorkoutSport`): `running` `cycling` `swimming` `strength_training` `cardio_training` `hiit` `yoga` `pilates` `mobility` `rucking` `other` `multi_sport`. It deliberately EXCLUDES walking and hiking, because Garmin has no workout sport type for either (see the walking/hiking gotcha in section 6) — so the builder cannot reproduce that bug. Guards throw `GarminError` for: no end condition, two end conditions, an empty workout, an empty repeat, an empty name, a non-positive repeat count, and mixing single-sport steps with multi-sport legs. Verified live across **all twelve sports** by `scripts/smoke-builder.ts` (`npm run smoke:builder`), using deliberately complex workouts — a 13-step swim, a 10-step strength session with nested repeats and four exercises, a three-leg brick, and a HIIT set with a count-based repeat nested inside a time-based one: each is built, uploaded, READ BACK and deleted, asserting the stored sport key, globally unique stepOrders throughout the tree, and a field specific to what is being tested — the pace target's descending m/s pair, power zone's `zoneNumber`, swim's `poolLength`/drill/paddles/`fixed.rest`, strength's exercise pair and weight, the brick's two segments and transitions flag, and HIIT's `numberOfIterations: null` on a time-based repeat. 12/12 pass. The probe asserts the STORED document rather than the POST's status, deliberately: a 2xx is how `uploadWalkingWorkout` came to be marked verified while storing a null sport. ### `workoutFromSpec` (src/workout-spec.ts): workouts as JSON Not a `Garmin` method. It's the JSON twin of `buildWorkout`, for input that cannot hold callbacks: a database row, a form, an LLM tool call. ```ts import { workoutFromSpec } from "garminconnect-js"; import { EXERCISES } from "garminconnect-js/exercises"; const workout = workoutFromSpec( { name: "6x400", sport: "running", steps: [ { type: "warmup", lapButton: true }, { type: "repeat", times: 6, steps: [ { type: "interval", distance: 400, target: { pace: { minPerKm: [4, 4.2] } } }, { type: "recovery", time: 120 }, ] }, { type: "cooldown", time: 600 }, ], }, EXERCISES, // REQUIRED: the catalogue is a parameter so the root bundle stays free of it ); await garmin.uploadWorkout(workout); ``` - A step is `{ type, ...StepOptions }` with `type` one of `warmup cooldown interval recovery main other rest`, or a block: `{ type: "repeat", times, steps }` / `{ type: "repeatForSeconds", seconds, steps }`. Multi-sport uses `legs: [{ sport, steps }]` instead of `steps`. - It REPLAYS the spec through the real builder, so every builder guard applies. - It is STRICTER than the builder: unknown keys throw with the JSON path (`workout.steps[1].steps[0]: unknown key "pace"; did you mean target: { pace: … }?`), and an exercise name missing from the catalogue throws with the closest valid names. Garmin itself would store it as `""`. - `WORKOUT_SPEC_JSON_SCHEMA` (draft 2020-12) describes the input. It's built from the same constants, so it follows the library, and it leaves exercise names out on purpose (runtime check instead). Its root has no `oneOf`, so it's usable as a tool input schema. ### Types from Garmin's Personal API spec (`PersonalApi`, src/generated/personal-api.ts) Garmin publishes an OpenAPI spec for its API-key "Personal API" on `personal.connectapi.garmin.com`. This library does NOT use that API, but the spec's 26 paths are the same services we call, and `npm run smoke:personal-api` found the field names match on `connectapi.garmin.com` (2026-10-23). `npm run personal-api-types` generates 106 interfaces from it, exported from the package root as the type namespace `PersonalApi` (e.g. `PersonalApi.SummaryDTO`), plus `PersonalApi.Endpoints`, a path-to-response-type map usable with `client.connectapi(…)`. - **Every field is optional and nullable**, and every interface keeps `[key: string]: unknown`. Garmin leaves out fields that do not apply (a run has no swim cadence), and sends fields the spec does not list. The spec is what MAY appear, never what will. - Corrections the generator applies, each observed on our host: `LocalTime`/`ConnectDate`/ `ConnectTimestamp` are strings; `fitnessage/v2/{date}` is one object, not an array; enums are open unions (`| (string & {})`). - **No field descriptions are copied**, on purpose: the developer site's redistribution terms are unknown. The spec is fetched at generation time, never committed. Regenerating needs network. - Our response types EXTEND these only where our method calls the very path the probe verified: `Activity` (`SummarizedActivityDTO`), `ActivitySummary` (`ActivitySummaryDTO`), `HrvData`, `HrvDataRange`, `BodyBatteryEntry`, `DailyStepsEntry`, `WeighInRange`, `UserprofileSettings`, `NutritionDailyFoodLog` and `DailySummary`. Where ours calls a different path (`getSleepData`, `getHeartRates`, `getUserSummary` use `/{displayName}` variants; `getHeartRateZones` and `getFitnessAgeData` use the pre-`v2` paths), the types were deliberately left alone. ### `garminconnect-js/manifest`: every method, machine-readable `GARMIN_METHODS` describes every `Garmin` method: `name`, `category` (the docs/api slug), `description`, `params` (each with a JSON Schema, `optional`, and `role: "file" | "filename"` for `Blob` inputs), `safety` (`read` / `write` / `destructive`) and `io` (`json` / `binary-in` / `binary-out`). It exists so tools can be GENERATED from this library; the MCP server in `mcp/` is one. It is generated from `src/garmin.ts` by `scripts/generate-manifest.ts` using the TypeScript compiler, and committed as `src/generated/manifest.ts`. `tests/manifest.test.ts` fails if it is stale, so **after changing any `Garmin` method signature, run `npm run manifest`**. A new method whose name fits no safety prefix fails generation until it is added to `SAFETY_OVERRIDES` with a reason. ## 4. These methods do NOT exist (mostly) `Garmin` has **202 methods**, every one listed in section 3. If a method is not in that table, it does not exist. **Do not invent methods on `Garmin` or `GarminClient` by analogy with Python snake_case names; check section 3 for the real camelCase name and signature.** Some absences are deliberate, because the obvious method could only produce a broken result: - **No walking or hiking workout helper.** Garmin has no walking or hiking workout sport type: a workout POSTed with activity-type ids 17/18 is accepted and stored with `sportTypeId: 0, sportTypeKey: null`, a workout with no sport. For a walk or hike template, use `uploadWorkout` with `OTHER` (3) or `CARDIO_TRAINING` (6). - **No `setGearDefault`.** The dedicated default-gear endpoint is dead (see gotchas). Use `setGearActivityDefaults`. - **Three profile endpoints, three methods.** `getUserProfile()` fetches `/userprofile-service/socialProfile` (`displayName`, `fullName` and `userName` are cached accessors that read from it); `getUserSettings()` fetches `/userprofile-service/userprofile/user-settings` (`unitSystem` reads from it); `getUserprofileSettings()` fetches `/userprofile-service/userprofile/settings`. Do not confuse the first two: "user profile" settings live under `getUserSettings`, not `getUserProfile`. **Genuinely absent**: Connect IQ store browsing, course SEGMENTS (courses themselves are covered — see the course rows in section 3), social/friends connections, and writing nutrition goals (not implemented because Garmin requires macro goals alongside the calorie goal). **What to do instead:** call `client.connectapi(path, options)` directly with the real Garmin Connect endpoint path. `connectapi` is the general HTTP-with-auth primitive every `Garmin` method above is built on — it is not private, and using it for a gap like this is the intended pattern, not a workaround. Define your own response type; this library has no type for endpoints it doesn't implement. ```ts // Example: the EU upload-consent flag (see section 6's "EU upload consent" gotcha) — read here // because no Garmin method wraps it; it is a real Garmin Connect endpoint this library's own // write paths can 412 against. // Response shape below is LIVE-OBSERVED, not assumed. There is no `enabled` field: // the signal is `userOption`, which reads "opt-in" once consent has been granted. const consent = await client.connectapi<{ userOption?: string }>( "/gdprconsent-service/feature/UPLOAD", ); const uploadConsentGranted = consent?.userOption === "opt-in"; ``` `tests/agents-md.test.ts` fails if this example names an endpoint that a real `Garmin` method already covers. **If you retarget it, copy any load-bearing headers the real endpoint needs** — a `goal-service` call without `Sec-Fetch-Site: same-origin`, for instance, silently returns `[]`. ## 5. Auth model `TokenStore` (src/auth/token-store.ts) is a three-method interface: ```ts interface TokenStore { load(): Promise; save(tokens: Tokens): Promise; clear(): Promise; } ``` `MemoryTokenStore` (default, process-lifetime only) and `FileTokenStore(dir)` (garth-compatible on-disk JSON, atomic writes) ship in the package. For a real server, implement `TokenStore` against your own database or cache — that is the normal production choice, not an edge case. **If you implement `TokenStore` yourself, persist and return `expires_at` and `refresh_token_expires_at` as numbers.** They are not decorative: every refresh decision reads them. A schema, a JSON column with a field allowlist, or a hand-rolled serializer that drops them is the realistic way to lose them. `isExpired`/`refreshExpired` fail CLOSED on a non-finite stamp (an unknown expiry counts as expired, so the client refreshes rather than sailing past it), and `FileTokenStore.load()` rejects a file missing either one outright — `loadTokens()` returns `false` and your caller is told to log in, instead of the client carrying a token it can never make a decision about. Round-trip your store once in a test and assert both numbers survive. Refresh is driven by the **OAuth1 token**, not an OAuth2 refresh token — Garmin's flow has none. Observed lifetimes: OAuth2 access token ~27h, OAuth1 token usable for ~30 days. A session rolls forward indefinitely as long as some call happens at least once every 30 days; past that, `GarminAuthError` is thrown and the user must log in again. Refresh happens automatically inside `GarminClient` before every `connectapi`/`download`/`upload` call, and the refreshed tokens are persisted via `tokenStore.save()` — callers never call refresh themselves. `GarminAuthError` means re-login is required. The library never prompts for credentials on its own; that's the caller's job. MFA: `login()` returns `{ state: "mfa_required", mfaState }` instead of throwing or blocking for input. `mfaState: MfaState` is plain JSON, contains no password, and can be put in a session store and carried across an HTTP boundary — e.g. one Next.js route handler returns it, a later request to a different route handler calls `client.resumeLogin(mfaState, code)` to finish the login. This is the intended pattern for serverless MFA, not a workaround. **But `MfaState` is still a short-lived secret, not inert JSON**: `mfaState.cookies` is a live, partially-authenticated SSO session. Anyone holding it can finish the login with the code. Encrypt it at rest, scope it to the one session that started the login, and delete it the moment `resumeLogin` returns (`README.md` states the same rule — keep the two in step). **Widget fallback.** `login()` first uses the mobile JSON API. Only when that credentials step is rate limited (HTTP 429, or a 200 body with `error["status-code"] === "429"`) does it sign in once through the SSO web widget, with a fresh cookie jar and a 3–8 s pause (`GarminClientOptions.loginDelayMs`). A 429 after the password was accepted (`preauthorized`, `exchange`) never falls back. Widget rejections keep the `SSO error:` prefix (`INVALID_CREDENTIALS`, `ACCOUNT_RESTRICTED`, `INVALID_MFA_CODE`). `MfaState` is a union on `flow` (`"mobile"`, absent = mobile, or `"widget"`); narrow before reading `loginParams`. Widget tickets are exchanged with `login-url=https://sso./sso/embed`. ## 6. Gotchas - **`setActivityExerciseSets`'s payload shape is undocumented and stricter than it looks.** Live testing against the test account found Garmin rejects several plausible shapes before accepting one: it needs `activityId` repeated at the top level, inside each `exerciseSets[]` entry, AND inside each `exercises[]` entry (a bare `{category, name}` exercise object fails with "Activity ID should not be Null in the Exercises Object"); each set needs `setType` (fails with "Set Type in a Set message can not be Null" otherwise); and each set needs a `startTime` (an ISO-ish timestamp; its absence produced a bare 500 `NullPointerException` with no more specific message). The confirmed-working shape is: ```ts { activityId, exerciseSets: [{ activityId, duration, repetitionCount, setType: "ACTIVE", startTime, exercises: [{ activityId, category: "SQUAT", subCategory: null }], }], } ``` This library does not validate or reshape the caller's payload — it is sent verbatim — so this note exists to save a caller from rediscovering the same three 400/500s. - **`getActivityGear`, `getGearActivities`, `addGearToActivity`, `removeGearFromActivity` live in `src/services/activities.ts`**, not `gear.ts`: they take an activity id and are the activity/gear-association surface. Gear CRUD (`getGear`, `createGear`, `getGearStats`, `getGearDefaults`, `setGearActivityDefaults`, `deleteGear`) lives in `src/services/gear.ts`. - **`addGearToActivity`/`removeGearFromActivity`: both directions verified by stored state.** `npm run smoke:gaps` creates gear with a backdated `firstUseDate`, links it, READS IT BACK via `getActivityGear`, unlinks, reads back again to confirm it is gone, then deletes the gear. - **Gear can be deleted — `deleteGear` (see section 3).** Garmin's web client exposes both Retire and Delete, and `DELETE /gear-service/gear/v2/{hyphenated-uuid}` returns 204. The bare 32-char UUID form `getGear` returns 404s there; `deleteGear` re-inserts the hyphens for you. - **`createGear` converts both usage limits, and the read side names them differently.** The write sends `maxUsageDistanceMeters` (km x 1000) and `maxUsageDurationSeconds` (min x 60); both were verified by read-back — 5 km reads back as `maximumMeters: 5000` in `getGear`'s list entries, and 90 min as `maxUsageDurationSeconds: 5400` in the `/gear-service/gear/v2/{uuid}` record. `getGear`'s list entries carry no duration field at all. Also note `getGear`'s list entries store `uuid` WITHOUT hyphens, while `createGear`'s own response uses hyphens — strip hyphens before comparing. - **`filterGear` (the path `getGear`/`getActivityGear` use) is confirmed live, NOT deprecated.** Garmin's own web client calls `/gear-service/gear/v2/list` instead, but `filterGear` returns 200 with the gear list. - **`getGear`, `getGearDefaults` and `getActivityGear` return ARRAYS (`Foo[] | null`).** `filterGear` (both the `userProfilePk`-filtered and `activityId`-filtered variants) and `.../activityTypes` all return JSON arrays; each single-entry shape (`Gear`, `GearDefaults`, `ActivityGear`) is a type wrapped in `[]` at the call site. For a sibling endpoint, verify the shape live rather than assuming an object. - **`src/util/validate.ts` is the single home for the shared guards** — `validateSportKey`, `validateNonNegativeInteger`, `validatePositiveInteger`, `validateUuid` and `pathSegment`. `validateUuid` is applied at all 8 `gearUUID` call sites (see §7). `pathSegment` is the URL-path encoder every service must use for every interpolated id. - **`validateSportKey` lives in `src/util/validate.ts`, shared by `metrics.ts` and `gear.ts`.** A new service needing the same rule (upper-case, then `^[A-Z_]+$`) should import it from here rather than adding another copy. - **`getDailySteps` and `getSleepDaily` auto-chunk ranges over 28 days.** Garmin's underlying endpoints have a documented 28-day-per-request limit; both methods split a longer `(start, end)` range into ≤28-day windows internally and concatenate/de-duplicate the results — callers pass an arbitrary range and don't need to chunk themselves. Both throw `GarminError` if `start > end`. - **`addHydrationData` has no delete endpoint.** Unlike `addWeighIn`/`deleteWeighIn`, a hydration log entry cannot be undone. It IS live-verified (written on 2026-09-23 and read back via `getHydrationData`) — this note used to say it was not, which was stale — but verification here cost something permanent: the seeded entries are on the test account for good. Do not call it against an account whose data you care about. - **`addWeighIn` vs `getWeighIns` unit asymmetry is real and deliberate.** `addWeighIn(weight, unitKey, when?)` sends `weight` RAW, in whatever unit `unitKey` names (`"kg"` or `"lbs"`) — do NOT pre-convert to grams; Garmin converts server-side. But `getWeighIns` returns Garmin's stored values in GRAMS regardless of recording unit — divide by 1000 to get kg on read. Applying the same conversion to both directions is the single most common bug here (a prior version of this library shipped exactly that bug and silently multiplied a stored weight by 1000). - **`addWeighInWithTimestamps` follows the identical raw-value rule as `addWeighIn`** — do not pre-convert `weight` to grams or any other unit. Confirmed live from BOTH directions: a 72.55 kg write (`addWeighIn`) read back as exactly 72.55 kg (72550 g), and a 160 lbs write (`addWeighInWithTimestamps`, `unitKey: "lbs"`) read back as 72.574 kg (72574 g) — Garmin's own server-side lbs->kg conversion, not one performed by this library. Sending a pre-converted figure client-side would have doubled up on that conversion or produced the historical "value * 1000" corruption; neither happened. - **`addWeighInWithTimestamps` couples its two timestamps — never derive them independently.** Passing `dateTimestamp` alone to backdate an entry must still yield a `gmtTimestamp` describing that same instant. Deriving each from "now" independently looks equivalent and passes a test that only exercises both-given and both-omitted, but silently writes an entry whose local and GMT halves land on different days. The GMT half is always the resolved local instant converted to UTC. - **`addBodyComposition` keeps `file_id.time_created` at the real current instant**, even when `extra.timestamp` backdates the entry: that field is file metadata (when the `.fit` was produced), not health data. Only the device-info and weight-scale messages take the caller's instant. - **`deleteWeighIns` is irreversible and deletes EVERY weigh-in on the given date.** It was live-verified only against weigh-ins created by the same probe run in `scripts/smoke-writes.ts` (never against pre-existing data) — create, confirm present, delete via `deleteWeighIns`, confirm the account has zero weigh-ins left. Do not call it against a date you did not populate yourself. - **`addBodyComposition` / `src/util/fit.ts`: the FIT `weight_scale` message scales `weight` by 100.** `weight` is packed as `uint16(round(weight_kg * 100))` — a property of the FIT format itself (a field defined with 1/100 resolution), not an application-level unit conversion like `addWeighIn`'s historical bug. `weight` is kilograms; nothing in this path converts kg<->lbs. `src/util/fit.ts` is a hand-rolled internal encoder (header, CRC and message blocks, plus the file-id, file-creator, device-info and weight-scale messages); only the `WeightScaleFields` type is public. Live-verified end-to-end: 69.42 kg uploaded and read back as 69.42 kg via `getBodyComposition`. - **Devices: RESOLVED 2026-09-24 — `getDeviceSettings`, `getDeviceSolarData` and the `getDeviceAlarms` fan-out are live-verified.** They needed a real `deviceId`, which only exists on an account with a paired device, and no endpoint can manufacture one — so the dedicated test account could never close them. A READ-ONLY probe against a real account (`npm run smoke:real`, section 8) did, on a transport that refuses any non-GET. The finding that mattered: **`DeviceSettings` is an OBJECT, not an array.** The open worry was that if it were an array, `getDeviceAlarms` would silently return `[]` forever, since it does `.get("alarms")` on each entry. It is an object of ~135 keys with `alarms` among them, so that whole branch of the concern is closed. What remains unverified is narrow and honest: both paired devices had `alarms: []`, so concatenating NON-empty alarm lists across devices has still never run. - **`createGear`'s default `notes = ""` is REJECTED by Garmin with a 400.** The message is `'createGear.arg1.notes' size must be between 1 and 2000 (provided value: )`. So `createGear` is unusable at its documented defaults. **Always pass a non-empty `notes`.** Discovered live on 2026-09-23 while linking gear to an activity. - **`addGearToActivity` requires the gear's `firstUseDate` to be EARLIER than the activity's start.** Otherwise Garmin returns 400 `"Gear's start date must be before activity's start date"`. This is undocumented and invisible from the method signature: linking recently-created gear to an older activity always fails. Create the gear with a backdated `firstUseDate`, or link only to activities that started after the gear did. The success path (link -> read back via `getActivityGear` -> unlink -> confirm gone) is live-verified under that precondition. - **`setGearDefault` was REMOVED on 2026-09-24 — its endpoint is dead. Use `setGearActivityDefaults`.** Garmin's own client has no dedicated default-gear endpoint any more: saving "Activities for This Gear" issues a full-record `PUT /gear-service/gear/v2/{uuid}` carrying `associatedActivityTypes`, the same array shape `createGear` posts. Note the keys are **lowercase** there (`"running"`), while the old dedicated endpoint took upper-cased keys. Four investigations failed to make it work; the fourth was decisive. The probe created gear WITH `activityTypeKeys: ["running"]`, linked it to a real activity, and set its defaults via `setGearActivityDefaults` — all succeeding against the same UUID, seconds apart — while `setGearDefault` still answered 404 "gear not found". The gear demonstrably existed, so the message was not describing reality. Earlier rounds had tried every spelling (`running`, `RUNNING`, `Running`, and the numeric `activityTypePk`, which 400s instead of 404ing). `tests/services/gear.test.ts` asserts the method stays gone and the replacement stays. - **Courses: HTTP 429 "not yet ready" is NOT rate limiting — and a course in open water never becomes ready.** Garmin processes a new course in the background; until it finishes, `PUT` and `DELETE` on it answer `429 {"message":"The course [id] is not yet ready to delete or update", "error":"TooManyRequestsException"}`, which this library surfaces as `GarminRateLimitError`. For a track on land it clears within seconds (a rename 5s after creation succeeded). For the first probe's synthetic track at 0°N 0°E — the "Null Island" coordinates other upload probes here use — the PUT was still refused five minutes later, across four runs, while `DELETE` got through. That was misread as "updating a course does not work" until Garmin's own web editor was watched saving a rename: it sends the very same full-record `PUT /course-service/course/{id}` this library does, and that PUT worked at once on an on-land course. So: test courses belong on land, and a caller creating then immediately editing should retry a 429 after a short wait. Creation is also two calls, not one: `importCourseGpx` only PARSES (`courseId: null`, nothing saved); `createCourse` saves. `listCourses` returns an envelope — the rows are under `coursesForUser`. - **Women's-health writes need MCT (menstrual cycle tracking) settings, and ONLY Garmin's own first-run wizard creates them — RESOLVED 2026-09-23, all five writes now verified.** The wizard at `connect.garmin.com/app/menstrual-cycle-tracking` was driven manually in a browser while recording traffic. It POSTs `/periodichealth-service/menstrualcycle/initCycleSetup` — **the exact path `initMenstrualCycleSetup` already uses** — so our URL was never wrong. What differs is the BODY: the wizard also sends the cycle type and contraception, producing `userMenstrualCycleSettings = {contraception: "NONE", menstrualCycleType: "REGULAR", avgCycleLength, avgPeriodLength, periodPrediction, ovulationPrediction, flowTracking, …}`. A three-field body (`periodStartDate`, `periodLength`, `cycleLength`) returns 200 but does not create that block. Once the block exists, **all five writes and all six reads succeed** through this library unchanged. If you are starting from a fresh account, run the wizard once in the web UI first; there is no API-only path to it. The failure modes on an unconfigured account, for recognition: `updateMenstrualDailyLog` -> 400 "MCT settings were not found"; `confirmMenstrualPeriodStart` -> 400 "Can't add a new MCT cycle in any mode other than REGULAR, IRREGULAR, SEMI_REGULAR, or SEMI_IRREGULAR"; `updateMenstrualCalendar` and `getMenstrualCalendarData` -> 500 `NullPointerException` (note: a READ that 500s); `updateMenstrualSettings` -> 2xx and a silent no-op. Setting the profile `gender` to `FEMALE` is necessary but NOT sufficient. - **`goal-service` is 1-INDEXED, and `start=0` silently returns an empty array.** This is the worst failure shape there is: no error, no 404, just `[]`, indistinguishable from "you have no goals". Confirmed live on an account holding exactly one active goal — `start=0` -> `[]`, `start=1` -> `[goal]`. A 0-based default would report nothing on an account that has goals, so `getGoals` defaults to **1**. `start=0` is still accepted, it just returns nothing, exactly as Garmin behaves. The quirk is specific to goal-service: the same probe confirmed `activitylist-service`'s activity search and `workout-service/workouts` return identical results at `start=0` and `start=1`, so their 0-based defaults are correct and were left alone. Compare the badge-challenge endpoints, which reject `start=0` with a loud 400 — same 1-indexing, far friendlier symptom. - **`createGear`'s `usageType` enum is `NONE` / `DISTANCE` / `DURATION` / `DATE`.** Read live from `/gear-service/gear/v2/usagetypes`. Note `"TIME"` is NOT valid and is rejected with `400 Invalid value 'TIME' for usageType`; the duration option is spelled `DURATION`. - **Creating a goal is `POST /goal-service/goal/goal` — SINGULAR.** Not wrapped by this library. The read path is plural (`/goal/goals`), and POSTing to the plural form returns **405**, which is how this was found. Captured from Garmin's own UI; body shape: `{name, activityType, period, type, caloriesInKiloCalories, distanceInMeters, durationInSeconds, numberOfActivities, startDate, privacy}` — e.g. `period: "thirty_days"`, `type: "distance_accumulation"`, `privacy: "public"`. Reachable today via `connectapi`. - **Training plans: BOTH endpoints are verified, and the "needs a PHASED plan" rule was wrong.** `trainingPlanCategory` has at least three values, not two. A Garmin Coach plan is `STATIC`; enrolling a plan from Training & Planning -> Training Plans produced `ITP`; `PHASED` has never actually been seen on an account. Against the ITP plan, `getTrainingPlanById` (`/phased/{id}`, 36 keys) and `getAdaptiveTrainingPlanById` (`/fbt-adaptive/{id}`, 35 keys) BOTH succeed. So `/phased/{id}` is not restricted to category `PHASED` — it rejects `STATIC` and accepts `ITP`, and its 400 `"Not a phased plan."` describes the PATH, not a required category. The earlier reading inverted that, and the probe written from it filtered for `PHASED` and would have skipped forever on an account that could already answer the question. A probe that only looks for what you expect cannot show you that you expected the wrong thing; `npm run smoke:gaps` now tries every enrolled plan against both paths and prints the category alongside each result. **The plan id field is `trainingPlanId`** — not `planId`, not `id`. `/trainingplan-service/trainingplan/plans` returns ONLY the account's own enrolled plans (probed with and without owner and paging params), so a plan must be enrolled through the UI before either endpoint can be exercised at all. - **There is no walking or hiking workout sport type, and the two helpers that pretended otherwise were REMOVED on 2026-09-24.** They sent `sportTypeId` 17 and 18, which look like Garmin ACTIVITY-type ids mistaken for workout sport types. Garmin's own enum (`GET /workout-service/workout/types` -> `workoutSportTypes`) contains only 1,2,3,4,5,6,7,8,9,10,11,13. The POST is accepted, the workout is created, and the stored value is `{sportTypeId: 0, sportTypeKey: null, displayOrder: 0}` — confirmed live by creating one with each helper and reading it back. A method whose only possible outcome is a broken record is worse than no method. `WORKOUT_SPORT_TYPE_ID` does not carry 17/18 either. **For a walk or hike template, call `uploadWorkout` with `OTHER` (3) or `CARDIO_TRAINING` (6).** `tests/services/workouts.test.ts` asserts the ids stay out of the map and the helpers stay off the prototype. This was also the case where "the write returned 2xx" had been recorded as verification; only the read-back exposed it. - **`GET /workout-service/workout/types` returns every workout enum Garmin uses** — nine of them: `workoutSportTypes`, `workoutStepTypes`, `workoutConditionTypes` (24), `workoutTargetTypes` (27), `workoutIntensityTypes`, `workoutStrokeTypes` (10), `workoutDrillTypes` (3), `workoutEquipmentTypes` (5), `workoutSwimInstructionTypes` (10). Not wrapped by this library, but it is the authoritative source, and it confirmed every id this library already had. The enums it revealed are now exported as `WORKOUT_STROKE_TYPE_ID`, `WORKOUT_DRILL_TYPE_ID`, `WORKOUT_EQUIPMENT_TYPE_ID`, `WORKOUT_SWIM_INSTRUCTION_TYPE_ID` and `WORKOUT_INTENSITY_TYPE_ID`; `RUCKING: 13` was added to the sport types, and the condition/target sets extended to the full 24 and 27. - **ZONE targets use `zoneNumber`; RANGE targets use `targetValueOne`/`Two`.** A `power.zone` step built in the designer stored `zoneNumber: 1` with both target values `null`, while its secondary `cadence` target stored `secondaryTargetValueOne: 80, secondaryTargetValueTwo: 90`. Steps also support a SECONDARY target (`secondaryTargetType` + values + `secondaryZoneNumber`) — the bike designer exposes it as "Secondary Target". - **Swim steps carry `strokeType`, `drillType` and `equipmentType`**, and a swim workout carries top-level `poolLength` + `poolLengthUnit` (`{unitId: 1, unitKey: "meter", factor: 100}`). Rest steps use `endCondition: fixed.rest` with seconds in `endConditionValue`. Confirmed by building one in the designer and reading it back. - **`pace.zone` workout targets are SPEEDS IN m/s, and `targetValueOne` is the FASTER (larger) number.** Garmin's designer turned "8:30 to 9:30 min/mile" into `targetValueOne: 3.1556, targetValueTwo: 2.8234` — that is 1609.344 m divided by 510 s and by 570 s. A caller who passes minutes-per-km, or who puts the slower bound first, gets a silently wrong target with no error from Garmin. Verified by round-trip. - **A repeat can be TIME-BASED, and then `numberOfIterations` is `null`.** Garmin's HIIT designer offers "Repeat Until Time Is", which stores `endCondition: time` with the seconds in `endConditionValue` and `numberOfIterations: null`. `RepeatWorkoutGroup.numberOfIterations` is therefore `number | null`, so the time-based form is expressible. Both forms round-tripped live. - **Workout weights: `weightValue` + `weightUnit`, stored in kilograms, with round-trip drift.** "20 lbs" entered in the designer stored as `19.998…` with `unitKey: "pound"`; sending `9.0718474` kg stored `9.071`. Never assert exact equality on a weight you sent. - **Two exercise-naming traps.** The display name is not the stored key — "Barbell Overhead Press" stores as `category: "SHOULDER_PRESS", exerciseName: "OVERHEAD_BARBELL_PRESS"`. And adding a weight can change the exercise outright: a "Push-up" with a manual weight stored as `exerciseName: "WEIGHTED_PUSH_UP"`, swapped in by Garmin. - **Cardio, HIIT, Yoga, Pilates and Mobility all share one model** — warm-up / rounds / cool-down with an exercise picker per step, the same `category` + `exerciseName` + `reps` vocabulary as strength. "Custom" in the designer's type list is not a distinct sport type; it maps to `OTHER` (3). - **Repeat blocks: `stepOrder` continues THROUGH the nested children.** The designer's own payload numbered warmup=1, interval=2, repeat=3, the repeat's two children=4 and 5, cooldown=6. Nested steps share the single global sequence; they do NOT restart at 1 inside the block. The block itself is `{type: "RepeatGroupDTO", stepType: repeat (6), numberOfIterations, smartRepeat, childStepId, endCondition: iterations (7), workoutSteps: [...]}` — exactly what `RepeatWorkoutGroup` models, confirmed against real output. - **Strength steps carry an exercise pair**: `category` + `exerciseName`, both SCREAMING_SNAKE_CASE (`"SQUAT"` / `"BARBELL_BACK_SQUAT"`), with `endCondition: reps` (id 10) and the rep count in `endConditionValue`. Garmin's catalogue holds 1830 verified names across 51 categories, shipped as the `garminconnect-js/exercises` subpath. Same category vocabulary as `setActivityExerciseSets`. - **`displayOrder` inside a `sportType`/`stepType`/`endCondition` ref is COSMETIC.** A strength workout was accepted identically with `displayOrder: 4` (what Garmin's own client sends) and `5` (what `uploadStrengthWorkout` sends). Do not treat a mismatch there as a bug. - **MULTI-SPORT workouts work through `uploadWorkout`, and `stepOrder` is GLOBAL across segments.** Built one in Garmin's own workout designer, captured the POST, and round-tripped the same shape through this library: created, read back with both segments AND `isSessionTransitionEnabled` intact, deleted. A multi-sport workout is one workout whose top-level `sportType` is `multi_sport` (id 10) and whose `workoutSegments` each carry their own `sportType` — the array this library already models, so no new method was needed. The trap: **numbering each segment's steps from 1 is rejected** with `400 "The workout steps need to have unique step orders, or all be unset and rely on submission structure order."` Step orders are unique across the WHOLE workout (1, then 2, …), or omit them entirely. See `uploadWorkout`'s JSDoc for a worked example. - **The designer offers eight workout types this library has no helper for** — Cardio, HIIT, Yoga, Pilates, Mobility, Rucking, Custom and Multisport. The four `uploadWorkout` helpers cover only Running/Cycling/Swimming/Strength. All the rest are reachable through `uploadWorkout` by passing `sportType` explicitly; `WORKOUT_SPORT_TYPE_ID` exports the ids and **every id in it was confirmed against a real designer-produced payload** on 2026-09-23 (`multi_sport: 10`, step `interval: 3`, condition `distance: 3` / `time: 2`, target `no.target: 1`). - **`setSleepWindow`: the sleep record's `id` changes on every edit, and the stages are never recomputed.** The `id` in the PUT path and body is the record's CURRENT `sleepStartTimestampGMT` (epoch ms); after a save it becomes the new start, so a cached id targets a record that no longer exists. The method reads the night right before each PUT for that reason. Moving bedtime about two hours earlier on a real account (2026-11-11, through Garmin's web client) counted all the added time as LIGHT sleep: the stages were not recomputed, even after a device sync, while the duration and the sleep score updated at once. Garmin also recomputes `sleepTimeSeconds` itself (it subtracts awake time), so the value read back is not the `end - start` that was sent. Setting the old window back returned the window and duration as before, but the night stays `sleepWindowConfirmationType: "manually_confirmed"`. On a night with no record (`id: null`, every night on an account without a device) it POSTs instead, which creates the window, and nothing can delete it again (DELETE is a 405). Pass `sleepStart`/`sleepEnd` with a UTC offset: through the MCP server a time without one is read in the SERVER's time zone (UTC when hosted), which moves the user's bedtime by their offset. - **`getSleepData` returns `SleepData | null`** — `null` is Garmin's ordinary "no data for that date" response, not an error; do not wrap it in a try/catch expecting a throw. `getHrvData` is the same. Other methods differ per endpoint (e.g. `getUserSummary`, `getHeartRates`, `getActivity` throw `GarminError` on a missing/empty response instead of returning `null`) — check the table in section 3, don't assume one policy applies to all methods. - **Dates are UTC calendar dates.** `formatDate` accepts `"YYYY-MM-DD"` or a `Date`; a `Date` is formatted with `toISOString().slice(0,10)` (UTC), and a malformed or impossible string (`"2026-02-30"`, non-ISO shapes) throws `GarminError`. A caller in a negative UTC offset who passes `new Date()` late in their local day will get the following day's data, because it's already tomorrow in UTC. - **POST is not retried automatically.** The HTTP layer retries GET/HEAD/PUT/DELETE/OPTIONS on a retryable status or network error; POST is skipped by default to avoid duplicating a write (e.g. double-submitting `addWeighIn`). There is an internal `retry: true` opt-in on the transport's `RequestOptions`, but it is not currently exposed on the public `ApiOptions` type that `connectapi`/`download`/`upload` accept — as of this version you cannot opt a public POST call into retries. - **`Garmin` caches the user profile (and user settings) per instance**, not globally. Every date-scoped method resolves `displayName()` first, which triggers a `socialProfile` fetch on first use and reuses it afterward. Constructing a new `Garmin` per call costs an extra profile fetch every time; build one `Garmin` per authenticated user and reuse it across calls/requests. - **`download()` and `upload()` default to a 60s timeout**, not the 10s default that ordinary `connectapi` calls use, because they move binary payloads. Both accept `timeoutMs` per call to override it. - **Error taxonomy** (src/errors.ts): `GarminError` (base, also thrown directly for malformed responses) → `GarminAuthError` (401/403, failed SSO, expired tokens) / `GarminRateLimitError` (429, carries `retryAfter?: number` in seconds from `Retry-After` when present) / `GarminConnectionError` (network failure or timeout after retries — **and**, as a deliberate exception, a few SEMANTIC HTTP statuses: EVERY HTTP error from `importActivity`, not only its 409 "Activity already exists" — it wraps any `GarminHttpError` as `Import error: ...`, so a 400 or 413 arrives as this class too — and the 404 "gear not found (likely retired/removed)" from `addGearToActivity` and `removeGearFromActivity`, plus `getDeviceSolarData`'s missing-`deviceSolarInput` case. Those are PERMANENT — a blanket `if (e instanceof GarminConnectionError) retryWithBackoff()` spins forever on them; check the message or the `cause`, which carries the original `GarminHttpError`. A dedicated error class would be a behaviour change and is recorded as a post-0.1.0 candidate) / `GarminHttpError` (any other non-2xx, carries `status: number`, `url: string`, `body: string`) / `GarminConnectPlusRequiredError` (carries `method`; the account lacks Garmin Connect+, which the methods in `CONNECT_PLUS_METHODS` need — Garmin's bare 403 is turned into this only after `hasConnectPlus()` confirms the subscription is missing, so it never means "sign in again" and is NOT a `GarminAuthError`). Error messages have query strings redacted — a Garmin service ticket rides in the query string, so don't expect the full URL in a caught error's message. - **`getLactateThreshold` has two branches with genuinely different response shapes** — the `latest` boolean picks between them, not a variant of the same call. `latest=true` (default) fires two GETs (`.../latestLactateThreshold` and `.../powerToWeight/latest/{today}`) and returns `{speed_and_heart_rate, power}`; `latest=false` fires three GETs (speed range, heart-rate range, and a delegated `getFunctionalThresholdPowerRange` call) and returns `{speed, heart_rate, power}` — and it throws unless `startDate` is supplied. The two branches also use DIFFERENT sport literals: the latest-branch power call sends the literal `sport=Running` (mixed case, not run through sport-key normalization, deliberately), while the range branch sends fully-uppercase `sport=RUNNING`. Do not "clean up" the mixed-case one to match the other. - **`getEnduranceScore` and `getHillScore` both branch on whether `enddate` is supplied**, and both hard-code an `aggregation` value on their range branch that is NOT the same value: endurance score's range branch hard-codes `"weekly"`; hill score's hard-codes `"daily"`. Conflating the two silently sends the wrong aggregation to Garmin. - **`getRacePredictions` is all-or-nothing on `startdate`/`enddate`/`type`** — supplying one or two of the three throws; supplying none hits the `.../latest/{displayName}` endpoint; supplying all three hits `.../{type}/{displayName}` and additionally caps the span at 366 days. `type` must be `"daily"` or `"monthly"`. - **The per-sport workout upload helpers share one body shape** — `workoutName`, `estimatedDurationInSecs`, `workoutSegments`, `author`, `description` — and differ only in their default `sportType` `{sportTypeId, sportTypeKey, displayOrder}` triple, which is why one `buildSportWorkout` helper backs all four thin wrappers. They do minimal required-field validation (`workoutName`/`estimatedDurationInSecs`/`workoutSegments` present) on a plain object. - **`pushWorkoutToDevice` is CLOSED (2026-09-24), and closing it found a wrong return type.** It resisted verification for the whole project: the test account has no paired device, so `mylastused` yields nothing and the POST goes out with device id 0 (`404 "Device id 0 is not registered."`). It was finally run against a real account with a paired Forerunner, by `scripts/push-workout-real.ts` — a separate, confirmation-flag-gated script, deliberately NOT part of `npm run smoke:real`, whose GET-only transport is the thing that makes it safe to point at a real account at all. **The method returned `DeviceMessage[]`, not the declared `WorkoutRecord | null`** — an array, and of device-messaging records rather than workouts, so the type was wrong twice over. It had survived every review and 600+ tests because the call had never once succeeded: a declared shape that is never observed is never contradicted. Pushing the same workout again returns `[]`, which means "already queued for that device" and is a success, not an error. - **`getFunctionalThresholdPowerRange` and `getPowerZonesForSport` both normalize `sport`** by upper-casing it and validating it matches `^[A-Z_]+$` (`"cycling"` becomes `"CYCLING"`; `"run1"` throws) — this is the one sport-key validation shared across methods; the literal `sport=Running` in `getLactateThreshold`'s latest branch (above) deliberately bypasses it. - **`getGoals`'s `Sec-Fetch-Site: same-origin` header is load-bearing and silent when dropped.** Without it, `goal-service` returns HTTP 200 with an empty `[]` for newer custom accumulation-goal types — no error, no 404 — so a client that drops the header passes every smoke check on an account whose goals happen to be older types, and only breaks for some users. `src/services/goals.ts` sends it on every page of the pagination loop, not just the first request. - **`getUserProfile()` and `getUserSettings()` hit different endpoints.** `getUserProfile()` reads `/userprofile-service/socialProfile`; `getUserSettings()` reads `/userprofile-service/userprofile/user-settings` (and backs `unitSystem()`). Do not repoint `getUserProfile()` at `user-settings`: it is cached, live-verified, and every date-scoped method depends on its current shape via `displayName()`/`fullName()`/`userName()`. - **Golf response shapes are not what their names suggest.** `getGolfSummary` returns a single pagination OBJECT (`{pageNumber, rowsPerPage, totalRows}`), not a list; `getGolfClubStats` returns a JSON ARRAY of club entries, not an object. Trust the live-verified shapes in section 3 for every golf method. - **`getGolfShotData` against a nonexistent scorecard id returned HTTP 410 (Gone), not 404 — and the reason is genuinely unresolved.** Every other "no such resource" read in this library returns `null`/`[]`/`{}` or a plain 404; this is the only observed 410 anywhere. No sibling endpoint shares the `/shot/scorecard/{id}/hole` segment — only the `/gcs-golfcommunity/api/v2` prefix. 410 is the conventional status for a removed endpoint, which is why it is worth more suspicion than a 404 would be. Two hypotheses remain — "no such scorecard" and "endpoint retired" — and no evidence available on an account with no golf data can separate them. A single real scorecard id settles it in one call. As of 2026-09-24 that id still does not exist: a second, real account was probed read-only and has 17 clubs under `getGolfClubStats` but zero scorecards, so it could not settle this either. - **EU upload consent: every write can 412 on an EU account until consent is granted.** Garmin returns `412 PreconditionFailedException: "The user is from EU location, but upload consent is not yet granted or revoked"` for EVERY write endpoint (`addWeighIn`, `importActivity`, `createManualActivity`, workout uploads, gear writes, all of them) on an EU-region account that has not granted upload consent in Garmin Connect's UI. This was hit live during development. It is an **account-state precondition, not a library bug** — the request shape and URL are correct, Garmin is refusing the write until the account owner clicks through a consent flow. It is fixed by granting consent in Garmin Connect (Settings), not by changing any code here. Garmin's own web client reads this flag from `GET /gc-api/gdprconsent-service/feature/ UPLOAD`, which this library does not wrap; a caller hitting an opaque 412 on their first write can check it directly via `client.connectapi( "/gdprconsent-service/feature/UPLOAD")` (see section 4's worked `connectapi` example, which uses this exact endpoint) before assuming their code is wrong. - **`uploadActivity` vs `importActivity` — do not conflate the two.** Both are multipart uploads to an `/upload-service/upload...` path, and it is easy to assume they're the same call with a different name. They are not: `importActivity` hits `/upload-service/upload/{ext}` WITH the load-bearing `NK`/`origin`/custom `User-Agent` headers that make Garmin treat it as an import (spoofing a different client); `uploadActivity` hits the plain `/upload-service/upload` path (no extension suffix) with NONE of those headers — Garmin's ordinary device-sync-shaped upload. Swapping which one gets the special headers would change which code path Garmin's backend takes for the upload, silently. - **`queryGarminGraphql`'s leading slash is load-bearing — and so is every `connectapi` path's.** `GarminClient#apiRequest` composes the request URL by plain string concatenation (`` `https://connectapi.${domain}${path}` ``), not `URL`-relative joining, so a path without a leading slash (`"graphql-gateway/graphql"`) silently produces `https://connectapi.garmin.comgraphql-gateway/graphql` — a malformed hostname, which fails as a DNS/connection error, NOT as a 404. `src/services/misc.ts` hardcodes the leading slash (`"/graphql-gateway/graphql"`); `tests/services/misc.test.ts` pins the literal composed URL; a live probe (`npm run smoke -- misc`) confirmed a real HTTP response (not a connection failure) from `https://connectapi.garmin.com/graphql-gateway/graphql`. - **`logout()` clears the configured `TokenStore` only — it does NOT clear the in-memory tokens already held by the calling `GarminClient` instance.** There is no public `clearTokens()` on `GarminClient` (only `setTokens`/`getTokens`), and `logout`'s host interface (`MiscHost`) is deliberately scoped to `{ client }`, with no access to `GarminClient`'s private `#tokens` field. A process that calls `logout()` and then keeps issuing requests through the SAME `GarminClient`/`Garmin` instance will keep succeeding until the in-memory OAuth2 token expires (~27h) or a refresh is attempted after the OAuth1 token also goes stale. Discard the `GarminClient` instance after `logout()`; do not keep using it. - **`logout()` is never run against `./tokens`** (the test harness's live session). It is unit-tested against `MemoryTokenStore` and a temp-dir `FileTokenStore` only. `scripts/smoke-reads.ts`'s `misc` probe list does not include it, and never should. ## 7. Anti-patterns - Never import this package in a Client Component or an Edge route (`export const runtime = "edge"` or no runtime export in an environment that defaults to Edge). It will fail at runtime on `node:crypto`/`Buffer` usage, and even if it didn't, credentials/tokens must not reach the browser. - Never put credentials, or a raw OAuth2 access token (~2 KB), in client-side code, local storage, or a cookie readable by the browser. - Never write to a real Garmin account from an automated test. There is no sandbox environment; `addWeighIn` in a test run writes an entry into someone's real health record on connect.garmin.com. Live-hitting tests belong behind `npm run test:live`, gated on the developer explicitly having real tokens, never in the default `npm test` suite. - Never interpolate a caller-supplied value into a request path without `pathSegment()` from `src/util/validate.ts`. `Fetcher.request` hands the composed URL to `new URL()`, which NORMALIZES `..` segments, so a raw id is a request-redirection primitive, not just a cosmetic issue: `deleteWorkout("../../weight-service/weight/2026-01-01/byversion/999")` used to resolve to `DELETE /weight-service/weight/...` — a different Garmin service, a write, carrying the user's bearer token — and `getActivity("1?x=y#frag")` used to smuggle a query string and fragment into the request. The headline use case for this library is a Next.js route handler, so `garmin.getActivity(params.id)` with a user-controlled route param is the DEFAULT shape of consuming code. Every id (`activityId`, `workoutId`, `gearUUID`, `planId`, `scorecardId`, `weightPk`, `userProfileNumber`, `scheduledWorkoutId`, `deviceId`, …) and every interpolated `displayName()` now goes through `pathSegment`; a new service must do the same. Encoding is behaviour-preserving for legitimate values — numeric ids, hex UUIDs, `YYYY-MM-DD` dates and Garmin display names all round-trip unchanged; a display name is percent-encoded with no safe characters. `gearUUID` additionally goes through `validateUuid` (also in `src/util/validate.ts`) at **all eight** call sites, in `gear.ts` and in the three gear methods in `activities.ts`; it used to be applied at one (`getGearStats`). - Never build the transport's own headers with object spread. `Authorization` and `authorization` are distinct OBJECT keys, and `new Headers()` then COMBINES the duplicates with `", "` — so `{ ...callerHeaders, authorization: real }` sent `Bearer CALLER, Bearer REAL` when the caller passed a capital-A `Authorization`. `GarminClient` builds a `Headers` and uses `.set()`, which is case-insensitive and genuinely replaces. (`upload` still lets a caller override `User-Agent` — `importActivity` depends on that — but never `authorization`.) - Do not construct a new `GarminClient` (or a new `Garmin`) inside a loop or per-request when one instance can be reused — each `login`/`loadTokens` and each fresh `Garmin`'s first profile fetch is a real network round trip. ## 8. Testing against this library Mock at the `fetch` layer, not by stubbing internals: `GarminClientOptions.fetchImpl` accepts `typeof fetch`, so inject a mock/msw handler there and let the real `GarminClient`/`Garmin` code run against it. This exercises the actual request construction, auth headers, retry logic, and response parsing instead of a hand-rolled stand-in for them. **Verifying against a real account, read-only.** A dozen rows in section 3 are marked `no` or `partially` for one reason: the dedicated test account structurally cannot produce the data — no paired device, no personal records, no golf rounds, no health snapshot, no PHASED training plan. `scripts/smoke-readonly-real.ts` (`npm run smoke:real`) probes exactly those against a real personal account, and three things make that safe: 1. **The transport refuses non-GET.** `createReadOnlyFetch` (`scripts/readonly-fetch.ts`) throws before any write leaves the process, with one exception for the OAuth2 refresh POST. The probe list is the intent; this is the guarantee, and `tests/readonly-fetch.test.ts` drives a real `GarminClient` through it to prove it is actually wired in. Note the failure SHAPE: the refusal is thrown from inside `fetch`, so the transport wraps it in `GarminConnectionError` and the reason lands on `error.cause`, not the top-level message. 2. **Its own token directory** (`./tokens-real`), so the test-account session in `./tokens` is never touched and neither harness can pick up the other's tokens. 3. **An INVERSE safety gate.** Every other script here refuses unless the profile matches `GARMIN_TEST_PROFILE_ID`; this one refuses if it DOES. Its output is shapes only — key names, types, array lengths, never field values — so a report can be pasted anywhere without carrying personal health data. Each run ends with an audit line of every request it actually sent, by method (`Requests sent: GET=21. Refused as writes: 0.`), so the read-only claim is evidence rather than an assertion; the OAuth refresh POST is counted under its own label so a real write to that path cannot hide inside the GET total. Credentials come from `GARMIN_REAL_EMAIL`/`GARMIN_REAL_PASSWORD` (their own key names, so both accounts coexist in one `.env`), and `npm run login:real` does the one-time login. `npm run smoke:gaps` (`scripts/smoke-writes-gaps.ts`) is the WRITE counterpart, test-account only, for the rows `smoke-writes.ts` does not reach: gear linking and defaults, courses, activity event type/RPE/feel, heart-rate zones, calendar events, daily stats, the schedule reads and `setSleepWindow`. Every probe writes, reads the stored value back, and restores or deletes what it created, with one exception: a sleep window cannot be deleted, so the sleep probe works on one fixed night (2026-10-16), creating its window on the first run only. `npm run smoke:personal-api` (`scripts/smoke-personal-api-real.ts`) checks Garmin's official Personal API OpenAPI spec against what OUR host returns, read-only, on the real account (same three safeguards). The spec is not linked from its Swagger page: it is inlined in the page's JS bundle, and the script extracts it at run time rather than committing it (the developer site's redistribution terms are unknown). First run, 2026-10-23: all 26 spec paths answer on `connectapi.garmin.com` with our OAuth tokens; key names match the schemas, with fields Garmin leaves out when empty, so treat every spec field as optional. Spec errors seen on our host: `fitnessage/v2/{date}` is an OBJECT (spec: array); `LocalTime`/`ConnectDate`/`ConnectTimestamp` fields are plain strings; `weight/all/{s}/{e}` 400s on 7- and 90-day spans ("Number of weeks should be 52 weeks…") and answers a 364-day one. `scripts/smoke-nutrition-real.ts` is the one place food logging can be verified: it needs Garmin Connect+, which the test account can never have (Connect+ needs a paired device). It writes to a REAL account, so it is gated like `push-workout-real.ts` — the `--yes-write-to-real-account` flag, a refusal to run on the test account, no npm alias — and it leaves nothing behind: it deletes every log entry that did not exist before it started (never the account's own) and the custom food it created. Output is shapes and booleans only. `scripts/smoke-sleep-real.ts` verifies `setSleepWindow`, which needs a night a paired device recorded. Gated the same way (`--yes-write-to-real-account`, refuses the test account, no npm alias), it moves one night's bedtime (`--date`, the wake-up day) a minute earlier, reads it back, then restores the original window and compares the stored window, duration and stages with the snapshot it took first. Output is booleans only. **First run, 2026-09-24 — what it closed**, all previously `no`, `partially` or UNCONFIRMED: `getDeviceSettings` (an OBJECT of ~135 keys, which also retires the `getDeviceAlarms` `[]`-forever risk), `getDeviceSolarData`, the `getDeviceAlarms` fan-out across two real devices, `getPersonalRecord`'s 17-key row shape, `getUserSettings` fetched directly, and `downloadHealthSnapshot` — which returned 15,352 bytes and had never once succeeded anywhere in this project before. Still open for want of the data: `getGolfShotData`'s 410 (no scorecard on either account), `getTrainingPlanById` (no PHASED plan), and gear stats (no gear). **A mock written from an assumption proves nothing.** Three real bugs in this library shipped with green tests because the implementation, the mock, and the assertion all encoded the same wrong assumption: a wrong SSO URL prefix, a wrong field name for the user profile, and the weigh-in unit conversion described in section 6 that stored a 93 kg weigh-in as 93,000 kg. A passing test suite built entirely on assumed request/response shapes does not confirm the shapes are correct. For write endpoints (`addWeighIn`, `deleteWeighIn`, `login`, `upload`) a live round-trip against a real (non-production-critical) account is what actually verifies behavior — a request-shape assertion against a mock only verifies internal consistency. ## 9. The MCP server (`mcp/`, npm `@dynamicsninja/garminconnect-mcp`) A workspace package that exposes this library to Claude over MCP (stdio, for Claude Desktop). It holds NO Garmin knowledge of its own: - **Workout tools** (`preview_workout`, `create_workout`, `update_workout`, `search_exercises`) wrap `workoutFromSpec` + `WORKOUT_SPEC_JSON_SCHEMA` + `garminconnect-js/exercises`. - **Every other tool is generated** from `garminconnect-js/manifest` (one per `Garmin` method, snake_case), minus the exclusions in `mcp/src/method-tools.ts` (`EXCLUDED`, `OPT_IN`), each with a reason. Annotations come from the manifest's safety class. Arguments are checked against the tool's input schema (`mcp/src/validate.ts`, ajv) before any Garmin call, because the low-level MCP `Server` does not; a wrong type or an unknown argument is a tool error. - It imports the library ONLY through `garminconnect-js`, `/exercises` and `/manifest`, resolved to `../src` by `mcp/tsconfig.json` `paths` and INLINED by tsup. An ESLint rule forbids `../../src` imports. So after changing a `Garmin` method, run `npm run manifest`; the MCP tools follow automatically, and `npm run check` (which runs `check -w mcp`) fails if they break. - Released in lockstep: `npm run bump -- X.Y.Z` bumps both versions, and one `vX.Y.Z` tag publishes the library, then the server (`publish.yml`). `tests/mcp-package.test.ts` pins that. - `npm run smoke:mcp` round-trips a workout through the real server against the TEST account only (same `GARMIN_TEST_PROFILE_ID` gate as every write script). - stdout is reserved for MCP frames; the server logs to stderr only. - **Ships as a Claude Desktop Extension too**: `npm run build:mcpb -w mcp` (needs `npm run build -w mcp` first, which produces `dist-bundle/`) stages a self-contained `dist-mcpb/stage` — the built server as `server/index.mjs`, an icon rasterized from `SERVER_ICON_SVG`, and `manifest.json` built from `extension/manifest.template.json` plus `package.json`'s `version`/`description` — validates it and packs `dist-mcpb/garminconnect-mcp-.mcpb` with Anthropic's `mcpb` CLI. `npm run check -w mcp` runs both steps, so the `.mcpb` is rebuilt on every check. `publish.yml` attaches it to the tag's GitHub Release after publishing to npm. - **`sign_in_to_garmin`** (`mcp/src/signin-tool.ts` + `mcp/src/signin-page.ts`) signs a user in without a terminal, for the Desktop Extension. It serves a one-off HTML form on `127.0.0.1` at a random port, opens it in the default browser, and closes it after a successful sign-in or 15 minutes idle. Safety rules baked into the page: only `127.0.0.1` (the exact `Host` header is checked, refusing DNS rebinding), a random 32-byte one-time key in the URL and re-checked on every POST, and the password is read once by `GarminClient#login` and never stored, logged, or returned to the MCP client — only the resulting tokens are (via the configured `FileTokenStore`). MFA state is held in memory for that one page instance only. - **`loadConfig`** (`mcp/src/session.ts`) treats an empty string, or any value that still CONTAINS `${` (an unsubstituted `${user_config.*}` template, or a manifest default like `${HOME}/Downloads/garmin` that mcpb never expands because it has no `${user_config...}` in it to trigger that substitution pass), as unset, falling back to the default. `download_dir` additionally falls back on a non-absolute value. `GARMIN_MCP_ENABLE_GRAPHQL` accepts `"true"` as well as `"1"`. - **Unattended sign-in from config** (`fileSession` in `mcp/src/session.ts`): the extension's `garmin_email` / `garmin_password` settings (`sensitive: true`) reach the server as `GARMIN_EMAIL` / `GARMIN_PASSWORD`; `loadConfig` sets `credentials` only when BOTH are set (the password is not trimmed). Saved tokens always win. Credentials are used only when there are none or after `server.ts` resets the session with `{ rejected: true }` on a `GarminAuthError`, and at most ONCE per process � a wrong password retried per tool call would get the account locked. A failed attempt (or MFA, which config cannot complete) surfaces as `NotLoggedInError.reason` and still points at `sign_in_to_garmin`. A plain `reset()` (e.g. after `sign_in_to_garmin`) clears the rejected flag. - **`GARMIN_MCP_GROUPS`** (`mcp/src/method-tools.ts`) is matched against the manifest's categories case-insensitively and trimmed. An unknown name is dropped and reported on stderr rather than thrown — this is a free-text extension setting, and a typo used to stop the whole server before any tool was registered. If nothing requested resolves, every group loads. - **The hosted server** is `mcp/src/http/`, bundled to `mcp/dist-http/http.mjs`. It is not published to npm: `mcp/Dockerfile` builds the image Dokploy deploys, and `mcp/HOSTING.md` is the runbook. It needs Node >= 22.13 (`node:sqlite`, loaded through `createRequire`; its SQLite tests skip on older Node). OAuth endpoints are the SDK's handlers mounted under `/mcp/oauth/*`, with request bodies capped at 16 KB. - **`FilesStrategy`** (`mcp/src/files.ts`) is how tools touch files: `localFiles` (stdio, reads and writes your disk) or `inlineFiles` (hosted: uploads take `filename` + `content` + `encoding`, 10 MB cap; downloads return an embedded resource, 5 MB cap). **`HOSTED_TOOL_FACTORIES`** (`mcp/src/tools.ts`) is the tool set the hosted server registers. It has no `sign_in_to_garmin`. - **Garmin tokens never touch disk on the hosted server:** they live sealed inside the OAuth tokens the MCP client holds. The grant table holds none. - `npm run smoke:http` drives the hosted server with an in-memory grant store against the TEST account. It needs `GARMIN_EMAIL`, `GARMIN_PASSWORD` and `GARMIN_TEST_PROFILE_ID`.