Skip to content

Garmin Integration

Vita syncs health metrics from Garmin Connect using the unofficial garminconnect Python library. Data flows from wearable sensors through Garmin's cloud to local JSON files, where it powers health views, the daily brief, and proactive coaching.

System Architecture

+-------------------+           +------------------------+
|   Garmin Device   |           |  Scheduler (every 6h)  |
|  (Fenix, Venu,    |  ------>  |  _garmin_sync_loop     |
|   Forerunner)     |           |  subprocess.run()      |
+-------------------+           +------------------------+
         |                                 |
         |                                 v
         |                    +------------------------+
         v                    |  scripts/garmin-sync.py|
+-------------------+         |  (928 lines)           |
|  Garmin Connect   |         +------------------------+
|  (Cloud API)      |                      |
|                   |   <---  12 API calls per day
+-------------------+                      |
                                          v
                              +------------------------+
                              |   atomic_write()       |
                              |   data/garmin/         |
                              |   YYYY-MM-DD.json      |
                              +------------------------+
                                          |
                              +-----------+-----------+
                              |           |           |
                              v           v           v
                          +-------+  +-------+  +-------+
                          |health-|  | week- |  |train-|
                          |snapshot| |summary|  | ing- |
                          |.json  |  |.json  |  |status|
                          +-------+  +-------+  +-------+
                              |           |           |
                              +-----------+-----------+
                                          |
                                          v
                              +------------------------+
                              |     API Layer          |
                              |  /api/v1/garmin/*      |
                              +------------------------+
                                          |
                                          v
                              +------------------------+
                              |      Web UI            |
                              |  Dashboard, Charts     |
                              +------------------------+

Why This Architecture?

  1. File-based storage over database: Health data is personal and sensitive. JSON files are portable, version-controllable, and easy to inspect. No database setup or migration concerns.

  2. Atomic writes: The atomic_write() pattern (temp file + rename) prevents data corruption if the sync is interrupted. Health data is too valuable to risk partial writes.

  3. View staleness system: Rather than recomputing views on every request, we mark them stale when source data changes. Views rebuild lazily, saving compute while ensuring freshness.

  4. Unofficial API tradeoff: The official Garmin API requires developer partnership. The garminconnect library reverse-engineers the web API, which means simpler auth but potential breakage on Garmin web changes.

Trigger Points

Trigger Method When Used
/sync-garmin Manual CLI command On-demand after workout
/sync-garmin 30 Manual CLI command Backfill past 30 days
_garmin_sync_loop (every 6h) Automatic scheduler api/src/scheduler.py runs run_garmin_sync on a 6-hour cadence (registered in loop_engine/registry.py)
uv run python scripts/garmin-sync.py Direct script CI/automation
--sync-activities flag Optional Sync activities to unified format (data/activities/unified/)

Note: The legacy VITA Daily Brief (VDB) also calls the sync as a subprocess. That path is currently disabled β€” see VDB / Living Briefing below.

Data Flow Detail

1. Credential Loading

Location: scripts/garmin-sync.py:load_credentials() (line 89)

Credentials stored in secrets.toml at project root:

[garmin]
email = "your@email.com"
password = "your-password"

The script validates: - File exists - Valid TOML format - Both email and password present

Note: The command file at .claude/commands/sync-garmin.md references ~/.config/vita/garmin.json which is outdated. The actual implementation uses secrets.toml.

2. Authentication

Location: scripts/garmin-sync.py:connect_to_garmin() (line 133)

The script first attempts to restore a saved OAuth/garth session from ~/.garth (GARTH_HOME). If that fails or the session is expired, it falls back to email/password login and saves the resulting session tokens for future use via garth.dump. The session-save path is compatible with both garminconnect 0.2.x (garth-based, client.garth.dump) and 0.3.x (native, client.client.dump); a _has_garth_api() helper picks the right one.

from garminconnect import Garmin
# Try saved session first, fall back to credentials
client = Garmin(email, password)
client.login()  # passed GARTH_HOME if a saved session exists

Common failures: - Incorrect credentials - 2FA enabled (requires app password or disable 2FA) - Account temporarily locked (rate limiting)

3. Per-Day Data Retrieval

Location: scripts/garmin-sync.py:sync_date() (line 481)

For each date in range (newest to oldest), 12 independent API calls:

Metric Function API Method Returns
Sleep get_sleep_data() client.get_sleep_data() Duration, stages, score, sleep window timestamps
HRV get_hrv_data() client.get_hrv_data() Weekly avg, overnight avg, 5min high, status, baseline range
Heart Rate get_heart_rate_data() client.get_heart_rates() Resting, min, max, 7-day avg
Stress get_stress_data() client.get_stress_data() Average and max stress levels
Respiration get_respiration_data() client.get_respiration_data() Avg waking/sleep, low, high
SpO2 get_spo2_data() client.get_spo2_data() Average and lowest blood oxygen
Training Status get_training_status_data() client.get_training_status() VO2max, ACWR, load balance, fitness trend
Fitness Age get_fitness_age_data() client.get_fitnessage_data() Chronological, fitness, achievable ages
Weight get_weight_data() client.get_weigh_ins() Pounds, body fat %
Steps get_steps() client.get_steps_data() Sum of hourly buckets
Body Battery get_body_battery() client.get_body_battery() High/low energy values
Training Readiness get_training_readiness() client.get_training_readiness() Score 0-100

Each function returns None on failure, allowing partial syncs. The script continues even if individual metrics fail.

An optional sleep override mechanism reads data/garmin/sleep_overrides.json after each day's sync (main(), lines ~833-844). If a manual override exists for that date, the original Garmin sleep block is preserved as _original_sleep and the override is applied with a reason and timestamp. This allows correcting Garmin sleep detection errors (e.g., when Garmin misclassifies restful reading as sleep).

4. Data Persistence

Location: scripts/garmin-sync.py:main() -> scripts/utils.py:atomic_write()

Files written to: data/garmin/{YYYY-MM-DD}.json

Example record (values below are illustrative placeholders, not real readings):

{
  "date": "2026-06-21",
  "synced_at": "2026-06-21T10:31:53.289838",
  "sleep": {
    "duration_hours": 7.0,
    "deep_hours": 2.0,
    "light_hours": 3.5,
    "rem_hours": 1.5,
    "awake_hours": 0.0,
    "score": 80
  },
  "hrv": {
    "weekly_avg_ms": 60,
    "overnight_avg_ms": 65,
    "overnight_5min_high_ms": 100,
    "status": "BALANCED",
    "baseline_low": 50,
    "baseline_balanced_low": 55,
    "baseline_balanced_high": 80
  },
  "heart_rate": {
    "resting": 50,
    "min": 48,
    "max": 110,
    "seven_day_avg": 50
  },
  "stress": { "avg": 30, "max": 90 },
  "respiration": {
    "avg_waking": 14.0,
    "avg_sleep": 13.0,
    "low": 8.0,
    "high": 20.0
  },
  "training": {
    "vo2max_generic": 55.0,
    "vo2max_cycling": 55.0,
    "status": 6,
    "fitness_trend": 3,
    "feedback": "PEAKING_1",
    "acwr": 0.8,
    "acwr_status": "LOW",
    "acute_load": 100,
    "chronic_load": 200,
    "load_aerobic_low": 200.0,
    "load_aerobic_high": 200.0,
    "load_anaerobic": 60.0,
    "load_balance_feedback": "AEROBIC_HIGH_SHORTAGE"
  },
  "fitness_age": {
    "chronological": "NN",
    "fitness": "NN",
    "achievable": "NN"
  },
  "steps": 5000
}

Fields are only present when data exists. A day without a smart scale weighing has no weight or body_fat fields.

5. View Staleness Propagation

Location: scripts/garmin-sync.py:mark_views_stale() (line 75)

After sync, affected views are marked stale in memory/_meta/staleness.json:

{
  "version": 1,
  "views": {
    "health-snapshot": {
      "status": "stale",
      "stale_at": "2026-06-21T07:14:16.123456",
      "reason": "garmin data updated"
    }
  }
}

Views marked stale based on memory/_meta/view-registry.json dependencies:

View Dependency Pattern Purpose
health-snapshot data/garmin/*.json Daily health summary for morning message
week-summary data/garmin/*.json 7-day trends for /week command
training-status data/garmin/*.json Recovery metrics for /now command

6. Activity Syncing and Auto-Matching (Optional)

The --auto-log flag has been removed (it no longer appears in garmin-sync.py). Activity-to-goal matching now happens through a separate pathway: scripts/scheduling/activity_matcher.py is called from other flows (not directly from garmin-sync.py). See Activities System for details on how Garmin activities map to training goal components.

7. Unified Activity Sync (Optional)

Location: scripts/garmin-sync.py:sync_garmin_activities_to_unified() (line 669)

When --sync-activities is passed, Garmin activities are converted to a unified format and appended to data/activities/unified/YYYY.jsonl. This provides a cross-source activity store that deduplicates against RWGPS and other sources.

Activity types are mapped via GARMIN_TYPE_MAP (line 51 β€” cycling, running, walking, strength, yoga, swimming, bouldering, skating, etc.). Cross-source deduplication uses scripts/activities/dedup.py to link matching activities (e.g., the same ride logged by both Garmin and RWGPS).

Sync status is tracked in data/activities/sync_status.json.

API Layer

The Garmin data is also exposed via the FastAPI backend (./run api, port 33800; public read-only mirror on 33802):

Endpoints

Endpoint Method Description
/api/v1/garmin/today GET Today's data
/api/v1/garmin/{YYYY-MM-DD} GET Specific date
/api/v1/garmin/range?start=...&end=... GET Date range (max 365 days)

Note: Route order matters β€” the static routes (/today, /range) are declared before the parameterized /{date_str} route so they aren't shadowed.

Architecture

Router (api/src/routers/garmin.py)
    |
    v
Service (api/src/services/garmin.py)
    |-- MAX_RANGE_DAYS = 365 range limit enforcement
    |
    v
Repository (api/src/repositories/garmin.py)
    |-- Async file operations
    |-- Schema validation
    |
    v
Files (data/garmin/*.json)

The repository uses the GarminDailyData schema from api/src/schemas/garmin.py, which makes all fields optional except date to handle missing data gracefully.

Note: The API schema has not yet been updated to match the expanded sync output. The sync script produces fields like heart_rate, stress, respiration, spo2, training, and fitness_age that are not represented in the Pydantic model. The HRV schema has been updated, but the top-level GarminDailyData still uses legacy field names β€” resting_hr (vs the sync's heart_rate.resting) and body_fat_pct (vs the sync's body_fat).

Data Structures

Complete Daily Health Record

All possible fields (from scripts/garmin-sync.py:sync_date()). Values are illustrative placeholders, not real readings:

{
  "date": "2026-06-21",
  "synced_at": "2026-06-21T10:31:53.289838",
  "sleep": {
    "duration_hours": 7.0,
    "deep_hours": 2.0,
    "light_hours": 3.5,
    "rem_hours": 1.5,
    "awake_hours": 0.0,
    "score": 80
  },
  "hrv": {
    "weekly_avg_ms": 60,
    "overnight_avg_ms": 65,
    "overnight_5min_high_ms": 100,
    "status": "BALANCED",
    "baseline_low": 50,
    "baseline_balanced_low": 55,
    "baseline_balanced_high": 80
  },
  "heart_rate": {
    "resting": 50,
    "min": 48,
    "max": 110,
    "seven_day_avg": 50
  },
  "stress": {
    "avg": 30,
    "max": 90
  },
  "respiration": {
    "avg_waking": 14.0,
    "avg_sleep": 13.0,
    "low": 8.0,
    "high": 20.0
  },
  "spo2": {
    "avg": 96,
    "low": 92
  },
  "training": {
    "vo2max_generic": 55.0,
    "vo2max_cycling": 55.0,
    "status": 6,
    "fitness_trend": 3,
    "feedback": "PEAKING_1",
    "acwr": 0.8,
    "acwr_status": "LOW",
    "acute_load": 100,
    "chronic_load": 200,
    "load_aerobic_low": 200.0,
    "load_aerobic_high": 200.0,
    "load_anaerobic": 60.0,
    "load_balance_feedback": "AEROBIC_HIGH_SHORTAGE"
  },
  "fitness_age": {
    "chronological": "NN",
    "fitness": "NN",
    "achievable": "NN"
  },
  "weight": 170.0,
  "body_fat": 15.0,
  "steps": 5000,
  "body_battery": {
    "high": 90,
    "low": 20
  },
  "training_readiness": 75
}

Field Definitions

Field Type Range Source
date string YYYY-MM-DD Input parameter
synced_at string ISO timestamp datetime.now()
sleep.duration_hours float 0-24 sleepTimeSeconds / 3600
sleep.deep_hours float 0-24 deepSleepSeconds / 3600
sleep.light_hours float 0-24 lightSleepSeconds / 3600
sleep.rem_hours float 0-24 remSleepSeconds / 3600
sleep.awake_hours float 0-24 awakeSleepSeconds / 3600
sleep.score int 0-100 sleepScores.overall.value
hrv.weekly_avg_ms float typical 20-100 hrvSummary.weeklyAvg
hrv.overnight_avg_ms float typical 20-100 hrvSummary.lastNightAvg
hrv.overnight_5min_high_ms float typical 20-150 hrvSummary.lastNight5MinHigh
hrv.status string BALANCED, UNBALANCED, etc. hrvSummary.status
hrv.baseline_low int typical 20-80 hrvSummary.baseline.lowUpper
hrv.baseline_balanced_low int typical 30-80 hrvSummary.baseline.balancedLow
hrv.baseline_balanced_high int typical 50-120 hrvSummary.baseline.balancedUpper
heart_rate.resting int 20-200 get_heart_rates().restingHeartRate
heart_rate.min int 20-200 get_heart_rates().minHeartRate
heart_rate.max int 20-220 get_heart_rates().maxHeartRate
heart_rate.seven_day_avg int 20-200 get_heart_rates().lastSevenDaysAvg...
stress.avg int 0-100 get_stress_data().avgStressLevel
stress.max int 0-100 get_stress_data().maxStressLevel
respiration.avg_waking float 5-30 avgWakingRespirationValue
respiration.avg_sleep float 5-30 avgSleepRespirationValue
respiration.low float 5-30 lowestRespirationValue
respiration.high float 5-30 highestRespirationValue
spo2.avg int 80-100% averageSPO2 or avgSleepSpo2
spo2.low int 80-100% lowestSPO2 or minSleepSpo2
training.vo2max_generic float 20-80 mostRecentVO2Max.generic
training.vo2max_cycling float 20-80 mostRecentVO2Max.cycling
training.acwr float 0-3+ Acute:Chronic Workload Ratio
training.acute_load float 0+ Recent training load
training.chronic_load float 0+ Long-term training load
fitness_age.chronological int user's age chronologicalAge
fitness_age.fitness int computed age fitnessAge (rounded)
fitness_age.achievable float best possible achievableFitnessAge
weight float lbs weight / 453.592 (from grams)
body_fat float 0-100% bodyFat field
steps int 0+ Sum of hourly step buckets
body_battery.high int 0-100 Max chargedValue
body_battery.low int 0-100 Min chargedValue
training_readiness int 0-100 get_training_readiness().score

VDB and the Living Briefing

There are two daily-brief paths, and a reader should know which is live:

  • Living Briefing β€” the current real-time daily-intelligence system (API on port 33801; see Living Briefing). It is continuously updated through the day and is the active path for surfacing health/Garmin data.
  • VITA Daily Brief (VDB) β€” the older point-in-time email snapshot. Its scheduler task vdb_daily is currently listed in data/scheduler/disabled-tasks.json (alongside executive_loop), so the VDB Garmin trigger is not running. VDB is effectively superseded by the Living Briefing for daily delivery.

When VDB does run, its data module (scripts/vdb/data.py) provides a sync_garmin() helper (line 269) that runs garmin-sync.py directly as a subprocess with a timeout, then loads today's file β€” falling back to yesterday's data if today's sync fails or times out. VDB's documented trigger (scripts/vdb/README.md) is a single ~5:30 AM PT scheduler call to flows/vdb/generate_newsletter.py; the older "two-pass draft at 4:45am + poll every 5 min until 7:00am" description was not grounded in code and has been removed.

Garmin data populates the VDB health section: - Sleep hours and score - HRV average - Training status (VO2max, ACWR, load balance) - Used by the focus-text generator for personalized recommendations

Sleep Gate Integration

Garmin sleep data powers the sleep gate (scripts/scheduling/sleep_gate.py). Per Board Resolution RES-mtg-c9207983-agenda-1, high-intensity training is suspended when the 7-day sleep average drops below SLEEP_THRESHOLD = 6.5 hours. get_7day_sleep_average() reads the sleep.duration_hours field from the last 7 data/garmin/*.json files (skipping days with no data).

Matching behavior: the gate engages only for components in the HIGH_INTENSITY_COMPONENTS set, not a single component name:

HIGH_INTENSITY_COMPONENTS = {
    "max_effort_interval",  # legacy
    "bike_intervals",
    "intervals",
    "hiit",
    "sprints",
    "ftp",
    "max_effort",
}

check_sleep_gate(component) returns immediately (not blocked) if component is not in this set; otherwise it computes the rolling average and delegates to scripts/executive/gates.is_action_blocked() (falling back to a direct avg_sleep < SLEEP_THRESHOLD comparison if the gate framework is unavailable). See Sleep Guardian for the predictive layer that complements this hard gate.

File Locations

File Purpose Lines
scripts/garmin-sync.py Main sync script 928
scripts/scheduling/activity_matcher.py Activity-to-goal matching 145
scripts/scheduling/sleep_gate.py Sleep gate check using Garmin sleep data 149
scripts/activities/dedup.py Cross-source activity deduplication -
scripts/activities/aggregate.py Activity aggregate updates -
scripts/vdb/data.py VDB data module (sync_garmin() helper) -
api/src/routers/garmin.py API endpoints 59
api/src/services/garmin.py Business logic layer (range limit) 44
api/src/repositories/garmin.py Data access layer 52
api/src/schemas/garmin.py Pydantic schemas (partially outdated) 52
secrets.toml Garmin credentials (project root) -
data/garmin/*.json Daily health records -
data/activities/unified/*.jsonl Unified activity store (yearly JSONL) -
data/activities/sync_status.json Activity sync status per source -
data/garmin/sleep_overrides.json Manual sleep data corrections -
memory/_meta/staleness.json View staleness tracking -
memory/_meta/view-registry.json View dependencies -
.claude/commands/sync-garmin.md CLI command definition -

Error Handling

Authentication Errors

Authentication failed: ...
Possible causes:
  - Incorrect email or password
  - 2FA enabled (use app password or disable 2FA)
  - Account temporarily locked

Resolution: Check secrets.toml credentials. If 2FA enabled, create an app-specific password in Garmin settings.

Per-Day Failures

The script continues on individual day failures:

Synced 5/7 days (2 errors)

Common causes: - No data recorded (device not worn) - API timeout - Temporary Garmin outage

VDB Fallback

If today's sync fails, VDB falls back to yesterday's data:

if garmin_file.exists():
    return safe_read_json(garmin_file, None)
# Try yesterday's data as fallback
yesterday = (date.today() - timedelta(days=1)).isoformat()
yesterday_file = PROJECT_ROOT / "data" / "garmin" / f"{yesterday}.json"

Rate Limiting

The Garmin Connect API has undocumented rate limits:

  • Sequential API calls (not parallel)
  • No explicit delays between calls
  • Max sync range: 1095 days (3 years), enforced in main() (~line 802): days > 1095 exits with an error; days < 1 is also rejected.

For heavy backfills: - Run in smaller batches (30 days at a time) - Add delays between days if errors occur - Sync during off-peak hours (early morning US time)

Known Limitations

Data Availability

  1. Sleep data appears next morning - Sleep for one night appears in the next day's file
  2. HRV is a 7-day rolling average - Not a daily point-in-time value
  3. Weight requires a smart scale - Only syncs if a connected Garmin scale is used
  4. Body Battery / Training Readiness - Require compatible devices (Fenix, Forerunner, Venu)

API Stability

  1. Unofficial library - May break with Garmin web changes
  2. No official OAuth - Username/password used for initial auth; session tokens cached to ~/.garth and reused on subsequent runs
  3. Session expiry - If the saved session expires or is incompatible, the script falls back to credential-based login automatically

Feature Gaps

  1. No activity details - Only activity metadata for matching, not GPS tracks or HR zones
  2. API schema out of sync - api/src/schemas/garmin.py does not include the newer fields (heart_rate, stress, respiration, spo2, training, fitness_age) produced by the sync script

Usage Examples

Basic 7-Day Sync

uv run python scripts/garmin-sync.py

Output:

Connecting to Garmin Connect...
Connected as user@example.com

2026-06-21 (3 metrics)
2026-06-20 (7 metrics)
2026-06-19 (7 metrics)
...

Synced 7/7 days
   Marked health-snapshot as stale (depends on garmin)
   Marked week-summary as stale (depends on garmin)
   Marked training-status as stale (depends on garmin)

Extended Backfill

uv run python scripts/garmin-sync.py 30

Sync with Unified Activities

uv run python scripts/garmin-sync.py --sync-activities

Output:

Synced 7/7 days

Syncing activities to unified format...
   Added: 3
   Duplicates linked: 1
   Already synced: 2

Via Claude CLI

> /sync-garmin 14

Claude runs the script and reports results conversationally.

Via API

# Today's data
curl http://localhost:33800/api/v1/garmin/today

# Specific date
curl http://localhost:33800/api/v1/garmin/2026-06-21

# Date range
curl "http://localhost:33800/api/v1/garmin/range?start=2026-06-01&end=2026-06-21"

Historical Data

The system holds a multi-year Garmin archive:

  • Earliest file: data/garmin/2022-12-31.json
  • Most recent: data/garmin/2026-06-21.json
  • Total: ~1,269 daily files spanning roughly 3.5 years (Dec 2022 β†’ Jun 2026)

This historical data powers trend analysis in the week-summary and training-status views.

Where to go next

  • Activities System β€” how Garmin activities map to training goals and dedup against RWGPS
  • Sleep Guardian β€” predictive sleep risk layer that complements the hard sleep gate
  • Living Briefing β€” the live daily-intelligence path that consumes Garmin data
  • Check-in β€” where the sleep gate is surfaced interactively