ching-kuo

garmin-cli

Extract health, activity, workout, and performance data from Garmin Connect. Use when users need Garmin fitness data including sleep, HRV, weight, stress, activities, workouts, and performance metrics.

ching-kuo 1 Updated 2mo ago

Resources

8
GitHub

Install

npx skillscat add ching-kuo/garmin-cli

Install via the SkillsCat registry.

SKILL.md

garmin-cli

Extract health, activity, workout, and performance data from Garmin Connect.

Prerequisites

  • Python 3.10+
  • Install: pip install . (from repo root)
  • Authentication: run garmin-cli login, or use a saved session at ~/.garminconnect, or set env vars GARMIN_EMAIL / GARMIN_PASSWORD

Agent Usage

Always use --json for machine-readable output. The tool returns a JSON envelope on stdout with exit code 0 (success) or 1 (error).

Success envelope

{
  "ok": true,
  "command": "<group> <subcommand>",
  "date_range": null,
  "count": 7,
  "data": [...]
}

date_range is null for commands that are not scoped to a date range. When a command is date-scoped, it is an object with from and to ISO dates.

Error envelope

{
  "ok": false,
  "command": "<group> <subcommand>",
  "error": "Human-readable message",
  "error_code": "RATE_LIMITED"
}

Error codes

Code Meaning Retry?
AUTH_MISSING No credentials or session found No -- configure auth
AUTH_FAILED Credentials rejected No -- fix credentials
NOT_FOUND Endpoint returned 404 No
RATE_LIMITED 429 after 3 retries Yes -- wait and retry
SERVER_ERROR 5xx after 3 retries Yes -- wait and retry
INVALID_INPUT Bad arguments or conflicting options No -- fix arguments
INTERNAL_ERROR Unexpected error, including uncategorized connection/timeout failures Maybe

Commands

Date range options (health range commands + workout calendar)

Option Effect Example
--date YYYY-MM-DD Single day --date 2026-03-11
--days N Past N days (inclusive of today) --days 7
--from YYYY-MM-DD --to YYYY-MM-DD Explicit range (both inclusive) --from 2026-03-01 --to 2026-03-07
--ahead N Next N days (workout calendar only) --ahead 7
(none) Defaults to today

Max range: 90 days. Conflicting options produce INVALID_INPUT.


Health

All health commands except health status accept the shared date range options. health status only accepts --date and defaults to today.

# Sleep -- fields: date, duration_hours, deep_min, light_min, rem_min, awake_min, score
garmin-cli --json health sleep --days 7

# HRV -- fields: date, weekly_avg, last_night, status
garmin-cli --json health hrv --date 2026-03-11

# Weight -- fields: date, weight_kg, bmi, body_fat_pct
garmin-cli --json health weight --days 30

# Body battery -- fields: date, start_level, end_level
garmin-cli --json health body-battery --days 7

# Stress -- fields: date, avg_stress, max_stress
garmin-cli --json health stress --days 7

# SpO2 -- fields: date, avg_spo2, lowest_spo2
garmin-cli --json health spo2 --days 7

# Resting heart rate -- fields: date, resting_hr
garmin-cli --json health resting-hr --days 7

# Training readiness -- fields: date, score, level
garmin-cli --json health readiness --days 7

# Training status (single day only) -- fields: date, training_status, load_type
garmin-cli --json health status --date 2026-03-11

Activities

# List recent activities -- fields: id, date, name, type, distance_km, duration_min, avg_hr
garmin-cli --json activity list --limit 10
garmin-cli --json activity list --limit 10 --type running
garmin-cli --json activity list --limit 10 --search "morning run"
garmin-cli --json activity list --from 2026-03-01 --to 2026-03-31
garmin-cli --json activity list --days 7

# Get a single activity by ID -- same fields as list
garmin-cli --json activity get 12345678901

# Sport-aware detail. Cycling rides surface power suite (avg/max/normalized,
# TSS, IF); running activities surface cadence_spm, GCT, vertical
# oscillation/ratio, stride length, training effect; pool swims surface
# SWOLF, total strokes, average stroke rate, distance per stroke. JSON
# uses a stable union schema -- every key present (null for sport-
# inapplicable). Includes a top-level `unavailable` array when any
# registry-known metric is not produced.
garmin-cli --json activity get 12345678901 --detail

# Detail + lap data in one envelope (--laps appends `laps` array).
# Pool-swim activities auto-route to per-pool-length rows.
garmin-cli --json activity get 12345678901 --detail --laps

# Lap-by-lap data (run/bike) or per-pool-length data (lap_swimming)
garmin-cli --json activity laps 12345678901

# HR time-in-zone breakdown -- fields: zone, zone_low_bpm, zone_high_bpm, seconds_in_zone, minutes_in_zone
garmin-cli --json activity zones 12345678901

# Weather for an activity -- fields: temperature, weatherIconCode, windSpeed, windDirectionDegrees, humidity, precipProbability
garmin-cli --json activity weather 12345678901

--limit defaults to 20, max 100. --type filters by Garmin activity type key (e.g., running, cycling, swimming).

Detailed sport-specific metrics glossary

Metric (output key) Unit Sport applicability What it is
norm_power_w W Cycling Normalized Power -- weighted average that accounts for variable effort
tss -- Cycling Training Stress Score (depends on FTP being set in Garmin Connect)
intensity_factor -- Cycling NP / FTP ratio for the ride
avg_cadence_rpm rpm Cycling Pedal cadence
avg_cadence_spm spm Running Step cadence
avg_ground_contact_time ms Running Time foot is in contact with the ground per stride (requires HRM-Pro/Run/Tri or RD pod)
avg_vertical_oscillation cm Running Vertical "bounce" per stride
avg_vertical_ratio % Running Vertical oscillation as a percentage of stride length
avg_stride_length cm Running Average stride length
swolf -- Lap swimming Strokes + seconds per pool length (lower is better)
total_strokes -- Lap swimming Total stroke count
avg_stroke_rate strokes/min Lap swimming Average stroke rate
distance_per_stroke m Lap swimming Average distance covered per stroke
aerobic_training_effect 0.0-5.0 Run, Bike Aerobic load impact score
anaerobic_training_effect 0.0-5.0 Run, Bike Anaerobic load impact score
vo2max mL/kg/min Run, Bike Sport-specific vO2max estimate
recovery_time_h h Run, Bike Recommended recovery hours after the activity

Capability manifest

activity_get(detail=True) returns an unavailable array (omitted when empty) describing why a metric isn't in the response:

Reason Meaning What an LLM agent should do
not_applicable_to_sport The metric doesn't apply to this sport (e.g. SWOLF on a bike ride) Skip this metric for cross-sport rollups
absent_in_response The metric applies to this sport but isn't in the payload (typically: missing FTP/LTHR profile, hardware that doesn't track it, or recent firmware change) Mention the gap; do not infer a value

Multisport parents (triathlon, duathlon) union per-child manifests with leg_index (0-based) attached to each entry.

Workouts

# List saved workouts -- fields: id, name, sport, duration_min, description
garmin-cli --json workout list --limit 10

# Get workout detail -- fields: id, name, sport, duration_min, description, steps_summary, steps[]
garmin-cli --json workout get 12345678901

# Workout calendar (planned workouts) -- fields: date, id, name, type, duration_min, description
garmin-cli --json workout calendar --ahead 7
garmin-cli --json workout calendar --from 2026-03-01 --to 2026-03-14
garmin-cli --json workout calendar --days 7

# Create a workout from JSON file -- fields: id, name, sport, duration_min, status
garmin-cli --json workout create workout.json

# Create from stdin (pipe from LLM output)
echo '{"name":"Easy Run","sport":"running","steps":[{"type":"warmup","duration":{"type":"time","value":300},"target":{"type":"no.target"}}]}' | garmin-cli --json workout create --stdin

# Create from YAML file (requires: pip install pyyaml)
garmin-cli --json workout create workout.yaml

# Update an existing workout (partial -- only fields provided are changed)
garmin-cli --json workout update 12345678901 changes.json

# Delete a workout (--confirm skips interactive prompt)
garmin-cli --json workout delete 12345678901 --confirm

# Schedule a workout to a calendar date
garmin-cli --json workout schedule 12345678901 2026-04-01

workout get includes a steps array with structured step data (step_order, step_type, duration_type, duration_value, target_type, target_value_low, target_value_high) and a steps_summary string.

workout create / workout update output fields: id, name, sport, duration_min, status

workout delete output fields: id, status

workout schedule output fields: workoutScheduleId, date, status


Workout JSON Schema Reference

The simplified input schema for workout create and workout update. All fields use human-readable string keys — no numeric IDs needed.

Top-level structure

{
  "name": "string (required on create, max 256 chars)",
  "sport": "string (required on create, see sport types)",
  "description": "string (optional)",
  "steps": [ "...step objects (required on create, non-empty)" ]
}

Sport types

Key Description
running Running / jogging
cycling Road / mountain cycling
swimming Pool / open water swimming
walking Walking
hiking Hiking
fitness_equipment Gym / strength / treadmill
multi_sport Triathlon / duathlon
other Any other sport

Step types

Type Purpose Example
warmup Warm-up period {"type":"warmup","duration":{"type":"time","value":600},"target":{"type":"heart.rate.zone","zone":1}}
interval High-intensity work {"type":"interval","duration":{"type":"distance","value":400},"target":{"type":"speed.zone","min":3.5,"max":4.0}}
recovery Low-intensity recovery {"type":"recovery","duration":{"type":"time","value":90},"target":{"type":"no.target"}}
rest Complete rest {"type":"rest","duration":{"type":"time","value":60}}
cooldown Cool-down {"type":"cooldown","duration":{"type":"time","value":300},"target":{"type":"open"}}
repeat Repeat group N times {"type":"repeat","count":4,"steps":[...]}

Max nesting: 2 levels (no repeats inside repeats).

Duration types

Type Value unit Example
time seconds {"type":"time","value":600} — 600 = 10 min
distance meters {"type":"distance","value":1000} — 1000 = 1 km

Target types

Type Fields Example
no.target (none) {"type":"no.target"}
open (none) {"type":"open"}
heart.rate.zone zone (1-5) {"type":"heart.rate.zone","zone":3}
speed.zone min, max (m/s) {"type":"speed.zone","min":3.33,"max":4.17}
power.zone zone (1-7) {"type":"power.zone","zone":4}
cadence.zone min, max (spm) {"type":"cadence.zone","min":170,"max":180}

Pace/speed reference (running)

Pace (min/km) Speed (m/s) Use
7:00 2.38 Easy / recovery
6:00 2.78 Easy / base
5:30 3.03 Tempo
5:00 3.33 Threshold
4:30 3.70 Interval / VO2max
4:00 4.17 Fast interval
3:30 4.76 Sprint

Formula: speed_ms = 1000 / (pace_min * 60)


Complete Workout Examples

Easy 30-minute run

{
  "name": "Easy 30min Run",
  "sport": "running",
  "description": "Recovery run at easy pace",
  "steps": [
    {"type":"warmup","duration":{"type":"time","value":300},"target":{"type":"heart.rate.zone","zone":1}},
    {"type":"interval","duration":{"type":"time","value":1500},"target":{"type":"heart.rate.zone","zone":2}},
    {"type":"cooldown","duration":{"type":"time","value":300},"target":{"type":"heart.rate.zone","zone":1}}
  ]
}

5x1km intervals

{
  "name": "5x1km Intervals",
  "sport": "running",
  "description": "VO2max interval session",
  "steps": [
    {"type":"warmup","duration":{"type":"time","value":600},"target":{"type":"heart.rate.zone","zone":1}},
    {
      "type":"repeat","count":5,
      "steps": [
        {"type":"interval","duration":{"type":"distance","value":1000},"target":{"type":"speed.zone","min":3.70,"max":4.17}},
        {"type":"recovery","duration":{"type":"time","value":120},"target":{"type":"no.target"}}
      ]
    },
    {"type":"cooldown","duration":{"type":"time","value":600},"target":{"type":"heart.rate.zone","zone":1}}
  ]
}

Cycling power workout

{
  "name": "Sweet Spot Cycling",
  "sport": "cycling",
  "description": "Sweet spot intervals at power zone 3-4",
  "steps": [
    {"type":"warmup","duration":{"type":"time","value":600},"target":{"type":"power.zone","zone":2}},
    {
      "type":"repeat","count":3,
      "steps": [
        {"type":"interval","duration":{"type":"time","value":600},"target":{"type":"power.zone","zone":4}},
        {"type":"recovery","duration":{"type":"time","value":300},"target":{"type":"power.zone","zone":1}}
      ]
    },
    {"type":"cooldown","duration":{"type":"time","value":300},"target":{"type":"open"}}
  ]
}

Workflow for LLM agents

1. Check auth:      garmin-cli --json login status
2. Create workout:  echo '<json>' | garmin-cli --json workout create --stdin
3. Parse response:  extract "id" from response data[0]
4. Schedule:        garmin-cli --json workout schedule <id> 2026-04-01
5. Verify:          garmin-cli --json workout calendar --from 2026-04-01 --to 2026-04-01

Write-specific error codes

Code Context Meaning
INVALID_INPUT workout create/update JSON schema validation failed — check required fields and enum values
NOT_FOUND workout update/delete/schedule Workout ID does not exist
AUTH_FAILED Any write command Session expired (401) or insufficient permissions (403) — re-login

Performance

# Thresholds -- fields: sport, lt_hr_bpm, lt_pace, ftp_watts, weight_kg
garmin-cli --json performance thresholds

# VO2 max -- fields: date, vo2max, sport
garmin-cli --json performance vo2max
garmin-cli --json performance vo2max --date 2026-03-11

# Lactate threshold zones -- fields: sport, lt_hr_bpm, lt_pace
garmin-cli --json performance zones

Patterns for agents

Login and check authentication

# Interactive login (saves session to ~/.garminconnect)
garmin-cli login

# Non-interactive login (for scripting)
garmin-cli login --email user@example.com --password secret

# Check if a session is active
garmin-cli --json login status

login status returns {"ok": true, ..., "data": [{"authenticated": true, "garmin_home": "..."}]} when a session is present and "authenticated": false when not. Exit code is always 0 (it is an informational command).

After login, verify the session is usable:

garmin-cli --json health status

If this returns ok: true, the session is valid. If AUTH_MISSING or AUTH_FAILED, run garmin-cli login first.

Parse JSON output

import json
import subprocess

result = subprocess.run(
    ["garmin-cli", "--json", "health", "sleep", "--days", "7"],
    capture_output=True, text=True
)
envelope = json.loads(result.stdout)
if envelope["ok"]:
    for row in envelope["data"]:
        print(row["date"], row["score"])
else:
    print(f"Error: {envelope['error_code']} - {envelope['error']}")

Rate limiting

Garmin Connect enforces rate limits. The CLI retries 3 times with backoff (2s, 4s, 8s) for 429/5xx. If you still get RATE_LIMITED, wait 30-60 seconds before retrying. Space sequential calls by at least 1 second.

CSV output

Use --format csv instead of --json when piping to data tools:

garmin-cli --format csv health sleep --days 30 > sleep.csv

Output format reference

Flag Format Stdout content
(default) Table Human-readable table
--json JSON Envelope with ok, command, count, data, date_range
--format csv CSV Header row + data rows
--format json JSON Same as --json

MCP Server (Alternative)

For Claude Code/Desktop integration via MCP protocol instead of shell commands. Uses MCP Python SDK v2 (MCPServer).

Install

pip install "garmin-cli[mcp]"

Register with Claude Code (stdio)

claude mcp add --transport stdio garmin -- garmin-cli mcp-server

Or with a custom session directory:

claude mcp add --transport stdio garmin -- garmin-cli --garmin-home /path/to/.garminconnect mcp-server

HTTP transports (SSE / streamable-http)

--host defaults to 127.0.0.1. Non-loopback binds require the GARMIN_MCP_BEARER_TOKEN environment variable; when set, the MCP SDK gates every tool call (read and write) through Authorization: Bearer <token>.

garmin-cli mcp-server --transport streamable-http --port 8000
garmin-cli mcp-server --transport sse --port 8000

# Non-loopback (requires bearer token):
export GARMIN_MCP_BEARER_TOKEN="<long-random-token>"
garmin-cli mcp-server --transport streamable-http --host 0.0.0.0 --port 8000

See README.md for full HTTP transport options and authentication notes.

Available tools

Read tools and four workout write tools (workout_create, workout_schedule, workout_update, workout_delete) are exposed. Write tools carry the SDK destructive_hint annotation for schedule/update/delete; create and update accept dry_run=True for a no-write preview.

Tool Parameters Returns
health_sleep start_date, end_date {count, rows}
health_hrv start_date, end_date {count, rows}
health_weight start_date, end_date {count, rows}
health_daily_summary start_date, end_date {count, rows} — steps, floors, intensity minutes, calories, resting HR (one API call per day)
health_steps start_date, end_date {count, rows} — steps, distance, step goal (native range API)
health_intensity_minutes start_date, end_date {count, rows} — moderate/vigorous minutes, weekly goal (native range API)
health_body_battery start_date, end_date {count, rows}
health_stress start_date, end_date {count, rows}
health_spo2 start_date, end_date {count, rows}
health_resting_hr start_date, end_date {count, rows}
health_readiness start_date, end_date {count, rows}
health_training_status date {count, rows}
activity_list limit?, start?, activity_type?, search?, start_date?, end_date? {count, rows}start_date/end_date (YYYY-MM-DD) must be provided together
activity_get activity_id, detail? {count, rows, children?, unavailable?}detail=true projects sport-aware metrics (running dynamics, cycling power, swim aggregates, training response) under a stable union schema and adds an unavailable[] capability manifest when non-empty
activity_weather activity_id {count, rows}
activity_laps activity_id {count, rows} — per-lap rows (run/bike) or per-pool-length rows (lap_swimming) with sport-aware fields. Auto-routes pool swim to typed_splits backend method. Multisport parents fan out to each child leg's laps with a 0-based leg_index stamped on every row
activity_hr_zones activity_id {count, rows} — one row per HR zone with zone, zone_low_bpm, zone_high_bpm, seconds_in_zone, minutes_in_zone
activity_metrics_describe activity_id {count, rows} — descriptors for the metric stream: key, unit, metricsIndex. Use to discover what metrics a watch recorded for a specific activity
workout_list limit? {count, rows}
workout_get workout_id {count, rows}
workout_calendar start_date, end_date {count, rows}
workout_create workout, dry_run? {count, rows}ok: true, action: "created", workout_id on success; ok: true, dry_run: true, wire_payload, validation_report on dry-run; ok: false, error_code: "INVALID_INPUT", errors on validation failure. Dry-run skips all Garmin contact.
workout_schedule workout_id, date {count, rows}ok: true, action: "scheduled", workout_id, workout_schedule_id, date. Destructive.
workout_update workout_id, workout, dry_run? {count, rows}ok: true, action: "updated", workout_id on success; dry-run returns the merged wire payload (one Garmin read, no write). Merge semantics preserve workoutId/ownerId/createdDate/atpPlanId. Destructive.
workout_delete workout_id {count, rows}ok: true, action: "deleted", workout_id. Destructive.
performance_race_predictions (none) {count, rows} — predicted times for 5K, 10K, half marathon, marathon
performance_endurance_score start_date, end_date {count, rows} — endurance score and classification (one API call per day)
performance_hill_score start_date, end_date {count, rows} — hill score, endurance score, strength score (one API call per day)
performance_thresholds (none) {count, rows}
performance_vo2max date? {count, rows}
performance_zones (none) {count, rows}
device_list (none) {count, rows} — registered devices with type and last sync
login_status (none) {authenticated, garmin_home}

Dates use YYYY-MM-DD format. Max date range: 90 days. Errors surface as MCP ToolError with the original error message.

activity_get(detail=true) may carry an unavailable[] array (omitted when empty) annotating registry-known metrics with not_applicable_to_sport (the metric isn't meaningful for the sport) or absent_in_response (the metric applies but the upstream payload didn't include it). Multisport parents union per-child manifests with a 0-based leg_index attached. activity_laps, activity_hr_zones, and activity_metrics_describe do not carry the manifest in this release; use activity_get(detail=true) for sport-applicability checks.

Planned but not yet implemented: activity_swim_lengths (covered by activity_laps for lap_swimming) and activity_metrics_series (down-sampling policy pending). Hardware/profile manifest reasons (requires_hardware, requires_profile_config) will land alongside a future profile/threshold fetch.

SKILL.md remains the default and simpler integration method. Use MCP when you want native tool semantics without shell command parsing.