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?
-
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.
-
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. -
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.
-
Unofficial API tradeoff: The official Garmin API requires developer partnership. The
garminconnectlibrary 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_dailyis currently listed indata/scheduler/disabled-tasks.json(alongsideexecutive_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 > 1095exits with an error;days < 1is 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
- Sleep data appears next morning - Sleep for one night appears in the next day's file
- HRV is a 7-day rolling average - Not a daily point-in-time value
- Weight requires a smart scale - Only syncs if a connected Garmin scale is used
- Body Battery / Training Readiness - Require compatible devices (Fenix, Forerunner, Venu)
API Stability
- Unofficial library - May break with Garmin web changes
- No official OAuth - Username/password used for initial auth; session tokens cached to
~/.garthand reused on subsequent runs - Session expiry - If the saved session expires or is incompatible, the script falls back to credential-based login automatically
Feature Gaps
- No activity details - Only activity metadata for matching, not GPS tracks or HR zones
- API schema out of sync -
api/src/schemas/garmin.pydoes 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