Mirror your Garmin Connect and Strava running data to local JSON. Built for
runners who want their own data on disk for analysis in notebooks, jq
queries, or LLM prompts.
The tool only fetches. It doesn't analyze your training, predict races, or send
anything to the cloud. The output is plain JSON files (plus raw .fit files)
you can read with any tool you like.
It's a single self-contained Go binary. There's no .env file. It prompts for
credentials when it needs them and caches the resulting tokens so later syncs
run unattended.
- Downloads every running activity from your Garmin Connect account (summary,
details, splits, raw
.fitfiles) plus daily wellness data (HRV, sleep, body battery, training readiness, training status). - Downloads every Run activity from your Strava account (detail and per-activity streams).
- Writes a unified
runs.jsonthat merges both sources into one list, each row tagged with its source, so an analysis can load your whole history at once. - Re-running fetches only what's missing. The on-disk layout is stable so downstream analysis can build on it.
go install github.com/joelsouza/runs/cmd/runs@latestOr build from a clone:
go build -o runs ./cmd/runsRequires Go 1.25 or newer. Tested on macOS and Linux.
There's no config file to edit. The first time a command needs credentials, it asks.
Garmin. Run runs garmin login (or just runs garmin sync, which logs in
when no token is cached). It prompts for your email, password, and an MFA code
if your account uses one, then caches OAuth tokens at ~/.garminconnect/ for
about a year.
A caveat worth knowing: Garmin actively blocks scripted logins, so the
from-scratch login can fail with a rate-limit or a non-JSON challenge. If that
happens, the reliable path is the token cache. As long as a valid token sits in
~/.garminconnect/garmin_tokens.json, runs refreshes it on its own and never
needs the password again. The same file is produced by the Python
garminconnect library, so an existing cache from that tool works directly.
Strava. Run runs strava login. It asks for the CLIENT_ID and
CLIENT_SECRET of an API app (create one at
https://www.strava.com/settings/api), then walks you through a paste-the-code
OAuth flow: the browser opens the authorization page, redirects to a
localhost URL that fails to load, and you copy the code= value from the
address bar back to the prompt. It saves tokens to ~/.config/runs/strava.json
and refreshes them automatically.
# Pull everything, then rebuild the unified index
runs sync
# Pull just one source
runs garmin sync
runs strava sync
# Skip Garmin wellness (much faster if you only want activities)
runs garmin sync --skip-wellness
# Backfill Garmin wellness from your earliest run (slow, ~1 API call/day)
runs garmin sync --full-wellness
# Limit to recent activities
runs sync --since 2025-01-01
# Refetch everything from scratch (rarely needed)
runs sync --forceRe-running with no flags is fast. Cached files are validated (JSON parses, FIT header looks valid) and skipped.
By default both sources write to your current working directory:
./garmin/
├── index.json # one row per Garmin run
├── activities/
│ └── <id>/
│ ├── summary.json # headline metrics
│ ├── details.json # running dynamics, HR zones
│ ├── splits.json # per-km splits
│ └── activity.fit # raw FIT file
├── wellness/
│ └── YYYY-MM-DD.json # daily HRV/sleep/etc
└── .last_wellness_sync.json # anchor for incremental backfill
./strava/
├── index.json # one row per Strava run
└── activities/
└── <id>/
├── detail.json # full activity object
└── streams.json # time-series GPS/HR/cadence
./runs.json # both sources merged, sorted by date
Override the location with --data-dir <path>, or set RUNS_DATA_DIR=<path> in
your environment.
The unified index is the fastest way in:
import json
runs = json.load(open("runs.json"))["runs"]
garmin = [r for r in runs if r["source"] == "garmin"]
print(f"{len(runs)} runs total, {len(garmin)} from Garmin")Per-source index files (garmin/index.json, strava/index.json) carry the
richer source-specific fields when you need them.
runs doctorPrints [OK] / [WARN] / [FAIL] lines for the Garmin token cache, Strava
config, token freshness, data-dir writability, and index row counts. Useful
when something doesn't look right and you can't tell which layer is broken.
- A training-analysis tool. Use the JSON output with any analyzer you like.
- A multi-user service. Runs locally with your own credentials.
- A real-time syncer. Run on demand.
MIT. See LICENSE.