trail-factors
Query and analyze your own Garmin trail/ultra-running corpus through Claude via local MCP tools with fixed, gated computations rather than direct SQL access.
Load the standing spec context and discover runs or the slope/segment library.
Summarize runs with basic metrics, mile/quarter splits, hill paces, 30 s session series, cadence by grade, run/walk cycle economics, and post-run reports.
Analyze heart rate: max HR, excursions above thresholds, time above HR ladder, HR-zone time split by gait, drift onset, matched-terrain drift, segment-effort drift, delta HR, and efficiency factor.
Compare selections across runs: grade-binned pace, segment history, session load, and resistance/time-in-resistance histories.
Inspect stationary ride blocks and save athlete-recalled ride-block structure; this is the only tool that writes athlete input.
Trigger asynchronous Garmin syncs, poll sync status, and check server health/lock state.
Expect results labeled ok, gated, refused, or unavailable, with applied filters/exclusions stated; the server is local, stdio-only, one-athlete, and terrain analysis requires US DEM tiles.
Ingests and syncs Garmin FIT running activities, including from Garmin Connect, to analyze trail and ultra performance such as running pace by grade, heart-rate drift on matched terrain, mile splits, elevation profiles, and session load.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@trail-factors Show my running pace per mile and HR drift on my last long run"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Trail Factors
Trail and ultra running analysis from your own Garmin FIT files, served to Claude through a local MCP server.
Why I built this
I'm training for my first 50-mile trail race. Garmin Connect reports averages, and on rolling trail terrain averaging destroys the signal: an average heart rate across eight climbs can't tell me whether I was tired or the trail was hard. Trails add a second problem. Under ridges and in creek drainages, GPS coverage drops out, and elevation read from a drifting position turns into noise.
So I built my own analytics layer and connected it to the AI chat I use for coaching. Trail Factors takes FIT files from my watch, loads them into a local DuckDB database, refines them through a medallion architecture (raw bronze, enriched silver, summarized gold), and exposes the gold layer to Claude through an MCP server. Elevation comes from a digital elevation model instead of the watch, so the same hill returns the same profile every time. I ask questions in plain English and get answers computed from my own data: running-only pace by grade, heart rate drift on matched terrain, the same hill compared across months.
Two design rules shaped it:
The tools carry the filters. The chat never queries the database directly. Every number comes through a tool that applies the gates and exclusions that make it trustworthy, and if a tool can't answer a question, it says so.
The athlete outranks the instrument. When the data and my account of a run disagree, the first question is what the measurement couldn't see, like a GPS dead spot in a creek drainage or a watch that died mid-run.
I built it in VS Code with the Claude Code extension. I designed the system and wrote the specs; Claude Code wrote most of the code. I use it after nearly every run.
Related MCP server: runcoach
How it works
flowchart LR
FIT["FIT files"] --> B["Bronze: parsed as recorded"]
B --> S["Silver: enriched records"]
DEM["DEM elevation tiles"] --> S
WX["Weather"] --> S
S --> G["Gold: per-run and per-segment summaries"]
G --> T["MCP tools"]
T --> C["Claude"]Raw FIT files are the immutable source of truth. The database is derived from them, with athlete-supplied state re-applied, and can be rebuilt. Elevation is looked up locally from downloaded USGS 3DEP tiles rather than taken from the watch, so no per-coordinate query leaves the machine and the same trail returns the identical profile on every traversal. The MCP server is a thin wrapper over the same functions the CLI calls: it runs over stdio only and opens every database connection read-only.
More detail: docs/ARCHITECTURE.md, docs/tools.md (all 29 MCP tools), docs/HISTORY.md, and SPEC.md (the full design).
What you can ask
Each clip is a question asked out loud and answered from my own data.
Try it yourself with the demo data
These answers come from the bundled demo database (data/demo.duckdb, built by uv run trail-factors demo), not from a live account.
"On my most recent long run, show my running pace per mile and when my heart rate started to drift." → mile_splits() and drift_onset()
The most recent long run in the demo is 2026-10-11: 7.62 mi in 106 minutes. Pace counts running time only; walk breaks are left out of the pace and shown in their own column.
Mile | Running pace | HR (running) | Walking |
1 | 11:12/mi | 129 | 0:00 |
2 | 13:26/mi | 162 | 1:48 |
3 | 13:53/mi | 154 | 8:02 |
4 | 12:57/mi | 162 | 7:14 |
5 | 11:49/mi | 168 | 6:55 |
6 | 10:53/mi | 164 | 2:14 |
7 | 12:12/mi | 168 | 5:56 |
8 (0.62 mi) | 11:46/mi | 175 | 3:11 |
Garmin's own lap for mile 2 reads 14:05/mi because it includes the walking; it is returned beside these figures for cross-checking and never substituted for them.
On drift, the tool finds an onset at 85 minutes: the first 5-minute bin where heart rate on matched ground drifts upward and keeps drifting upward in every later bin (dew point 63–66 °F). It also refuses to call that a finding. Only 1 of 7 demo runs recorded every second produces an onset, and 8 at a comparable dew point are needed before anyone can tell whether the onset repeats. So the 85 minutes is reported as a diagnostic, with the reason attached.
"Is my heart rate drifting on the same ground?" → matched_terrain_drift()
"matched_terrain_drift: median 9.79 bpm/hour over 9 of 10 runs." It pairs records on the same ground (within 20 m, at least 600 s apart, both running, speed matched within 0.35 m/s). The tenth run had no qualifying pairs, and the result says so instead of reporting a number.
"What training load have I done recently?" → session_load(last_n=11)
"6 of 11 sessions priced, 2625 AU (a lower bound: 5 unpriced)." Load is Garmin's post-run RPE × timer minutes. The five runs with no Garmin self-evaluation are left out rather than counted as zero, so the total is labelled a lower bound.
Quick start
Requires Python 3.12, uv and Claude Code. The demo needs no Garmin account and no network.
git clone https://github.com/cambroussard9/trail-factors.git && cd trail-factors
uv sync
uv run trail-factors demoRegister the MCP server with Claude Code, from the clone root:
claude mcp add trail-factors -e TRAIL_FACTORS_DB=data/demo.duckdb -- uv run --directory "$PWD" trail-factors-mcp
claude mcp get trail-factors # or: claude mcp listThen ask Claude one of the demo-data questions above.
Using Codex instead? See SETUP.md, step 11 (from OpenAI's docs, untested).
Use your own Garmin data
Copy config/athlete.example.yaml to config/athlete.yaml, log in once with uv run trail-factors garmin-login, and run uv run trail-factors sync to download, ingest and validate your activities. Your data stays in the gitignored data/ directory on your machine. The full walkthrough is in SETUP.md.
Security and quality
Control | Where | Why it exists |
gitleaks | pre-commit ( | Blocks a commit whose staged changes contain a secret. |
ruff (lint + format) | pre-commit ( | Keeps style and common errors out of commits. |
ruff and pytest | CI ( | Installs from the lockfile, checks lint and formatting, runs the tests. Corpus tests skip because no database is set. |
TruffleHog |
| Scans the full git history and fails on any verified or unknown finding. |
Dependabot |
| Proposes weekly updates for GitHub Actions and the uv-managed Python dependencies. |
Secrets-blocking hooks | The author's own Claude Code user settings (not shipped in this repo) | SonarQube secrets hooks run on prompt submit and before file reads. They need the |
More in SECURITY.md.
Testing
Tests built on synthetic fixtures run anywhere. Corpus tests read a real database named by TRAIL_FACTORS_DB and skip when none is set, so the same suite gives different counts depending on what it runs against.
Run against | Result | What it shows |
CI, every push | 726 pass, 430 skip | Everything that needs no private data passes. The 430 corpus tests skip because they need real runs. |
The demo database | 1,064 pass, 92 skip | The corpus tests run against the bundled demo runs. Reproduce it with the commands below. |
The author's private corpus | 1,132 pass, 24 skip | The analysis holds up on real recordings (GPS gaps, watch and phone duplicates, DEM elevation), which synthetic fixtures can't cover. |
Trail Factors was extracted from a private build; tests for features not published here stayed there.
To reproduce the demo-database run:
uv run trail-factors demo # builds data/demo.duckdb
TRAIL_FACTORS_DB=data/demo.duckdb uv run pytestCounts as of 2026-11-03.
Design principles
Tools carry the filters. MCP tools are parameterised primitives that never take SQL: the selection is free, the computation is fixed, and every result states its applied filters and what it excluded. Every report section carries one of four statuses (
ok,gated,refused,unavailable), never a bare null.The athlete outranks the instrument. A file the validator rejects names its own fix, and the athlete confirms the valid window with
confirm-file; the window is applied before validation, so a car ride at the end of a recording no longer sinks the run.The database rebuilds from raw FIT. Files in
data/raw/are immutable and never edited. The database is derived from them, with athlete-supplied state re-applied, so deleting it and re-running ingest is safe. Athlete-supplied state (window confirmations, segment names, notes) lives indata/athlete/, outside the rebuild path, because nothing can regenerate it. It is gitignored with the rest ofdata/, so back it up yourself.
Known limitations
Known issues and limitations are tracked in GitHub issues. The constraints built into the design:
One athlete by design. No multi-tenancy, no app, no web UI.
Terrain needs a DEM tile. Grade and elevation come from USGS 3DEP tiles, which cover the United States only;
fetch-tilerefuses a cell outside US territory before sending any request. A run with no tile on disk gets no terrain numbers, and the report says which tile is missing.Garmin download uses an unofficial library (
garminconnect). Garmin can throttle its login, and a failed login locks further attempts locally for 30 minutes.The MCP server runs over stdio only.
Built with
VS Code and the Claude Code extension.
About me
License
MIT. See LICENSE.
Available Tools
29 toolsbasic_metricsB
§6.0 the run's own description: average HR, three paces, windowed bests, run/walk/stand time, and cadence.
Args:
run: run_id, activity_id or Garmin file id. Empty means the latest run.
THESE ARE ANCHORS, NOT SIGNALS. For a run/walk athlete holding an HR cap,
average HR barely moves from run to run because HR is held nearly flat
across grade — quote it as the level that drift and efficiency are
deviations FROM, not as a finding.
Paces are never pooled: `overall` is Garmin's own basis (distance over
timer time), `running` and `walking` come from the rwd_* splits and are
separate quantities. A class below the minimum time is `gated` with its
time stated, not silently absent.
The bests are SLIDING WINDOWS, never an instantaneous maximum, and they are
NOT comparable across sampling regimes. Read each window's
`walk_time_fraction` and `flagged_record_fraction` before quoting it: a
maximum selects for positive measurement error in a way a mean does not.
Cadence is steps per minute. It is running-only and non-zero-only, so it
does NOT equal Garmin's own averageRunningCadence, which includes standing.
Inputs: run, last_n, cluster, end_date, +4 more. Returns: result
cadence_by_gradeB
§6.0 cadence per 5% grade bin over a selection, split by split_type.
Never pooled across run and walk — running and walking cadence sit tens of
spm apart, so a pooled bin reports a number neither mode took.
Needs a DEM tile for the grade, so it sees only DEM-covered runs;
`n_runs_without_grade` says how many of the selection had none.
Inputs: last_n, cluster, end_date, run_type, +3 more. Returns: result
delta_hrC
§6.3 HR excursion across an effort, by direction, over a selection.
Ascent and descent are NOT comparable on this metric: the excursion
differs systematically by direction. Tau is never returned by this tool;
whether it is reportable at all is §6.3's corpus-level gate.
Inputs: last_n, cluster, end_date, run_type, +3 more. Returns: result
drift_onsetB
§6.9 WHEN in a run cardiac drift begins — a VIEW of §6.7, currently REFUSED.
Args:
run: run_id, activity_id or Garmin file id. Empty means the latest run.
The result carries `gate`, which is computed from the live corpus. While
`gate.reportable` is false the curve is a DIAGNOSTIC and must not be quoted
as a finding — the same standing as tau (§6.3). Read `gate.reason`: it says
exactly what would make it start reporting.
Onset is confounded with run length and its earliest representable value is
§6.7's own 600 s minimum pair separation. Dew point travels with every
value because onset is uninterpretable without it.
Inputs: run, last_n, cluster, end_date, +4 more. Returns: result
efficiency_factorC
§6.8 speed per heartbeat on segment-matched terrain, over a selection.
Matched by segment identity, NOT by GAP — GAP is unvalidated (§12.2).
Inputs: last_n, cluster, end_date, run_type, +3 more. Returns: result
grade_binned_paceB
§6.2 pace by grade bin, split by split_type, over a selection.
Run and walk are never pooled: pooling reads as "pace does not vary with
grade" purely because each bin mixes the two modes.
Inputs: last_n, cluster, end_date, run_type, +3 more. Returns: result
hill_paceB
§6.0g pace on every slope effort of one run, split by direction.
Args:
run: run_id, activity_id or Garmin file id. Empty means the latest run.
Each effort carries mean pace (whole effort, summit to col, moving time)
AND best 30 s (the part actually run fast). They are different quantities —
a slow mean beside a much faster best 30 s is a description of how the
effort was run (walked in part, run in part), not a contradiction.
READ `comparable_ground.length_ratio` BEFORE COMPARING THE DIRECTIONS.
Ascents and descents need not cover the same ground — plateau absorption
can glue flat ground onto one direction (§14k) — so part of
any ascent-vs-descent difference is terrain rather than the athlete.
Inputs: run, last_n, cluster, end_date, +4 more. Returns: result
hr_aboveA
Every contiguous excursion at or above a heart-rate threshold (§14e-ter).
THE UNIT IS THE EXCURSION, NOT THE RECORD. A sustained bout would otherwise
arrive as hundreds of rows and a single spike as one, with nothing in the
shape to tell them apart — which is the whole question when deciding
whether a ceiling is demonstrated or merely recorded.
Each excursion carries the run and date, start offset, span, peak with its
timestamp, mean, the 30 s window CENTRED on the peak, record count,
§15.4 flag count, sampling regime, and pace / grade / cadence. **The same
peak reached while climbing at a walking pace and while running fast on
the flat are different facts about the same heart.**
RAW heart rate, never lag-shifted. FLAGS ARE REPORTED, NOT APPLIED: at the
top of the range a flag may mark a real artifact or a real spike and cannot
settle which. **A run whose peak has unresolved provenance (§14e-bis;
`unresolved_peak_run` in config/athlete.yaml, when set) is MARKED and never
dropped, and every total is reported a second time without it** under
`without_unresolved_peak`.
Args:
threshold: bpm. 0 (the default) means the configured threshold,
`hr_above_default_bpm` in config/athlete.yaml -- set it near the
top of your own HR distribution. `threshold_bpm` in the result
says which value was used.
Inputs: last_n, cluster, end_date, run_type, +4 more. Returns: result
list_runsA
Corpus listing, newest first. All filters optional and AND-ed.
Args:
start_date: inclusive YYYY-MM-DD.
end_date: inclusive YYYY-MM-DD.
run_type: REJECTED when non-empty -- this build has no run_type
source. Each row reports run_type null, labelled "not collected";
a later version re-enables it through user-defined run types.
cluster: a cluster name from config/athlete.yaml, or NO_GPS / OTHER.
limit: maximum rows.
sport: empty means running. "any" lists every sport; otherwise one
sport by name (cycling, hiking, ...).
Each row carries `local_date`, the day the athlete would call it.
Inputs: limit, sport, cluster, end_date, +2 more. Returns: result
list_segmentsC
The slope library: every segment, with where the reference run meets it.
Inputs: no named parameters. Returns: result
matched_terrain_driftA
§6.7 HR drift on the SAME ground, bpm/hour, over a selection of runs.
Pairs RECORDS within 20 m of each other, at least 10 min apart, BOTH
rwd_run, with speed matched to 0.35 m/s. Walking is not paired. Read
`applied_filters` — those tolerances are what the number means.
Effort-matched by construction, so it is the substitute when §6.4's
steady-effort gate fails. Read `excluded` too — a drift figure over "five
long runs" means something different if two were dropped for coverage.
`segment_effort_drift` is a DIFFERENT quantity, usually several times
larger. Neither is "the" drift.
Inputs: last_n, cluster, end_date, run_type, +3 more. Returns: result
max_hr_overA
The highest heart rate in a selection, and the run that produced it (§6.0).
TWO CEILINGS, and the difference between them is the point.
`max_hr_recorded` is the single highest sample -- what the watch displayed,
and a SPIKE CANDIDATE, because a maximum selects for positive measurement
error. `max_hr_30s` is the highest 30 s rolling mean with §15.4-flagged
records excluded, and it is the defensible figure. When the two come from
DIFFERENT runs, the corpus's highest spike and its highest sustained effort
are not the same session, and `same_run` says so.
EACH WINNER CARRIES ITS CIRCUMSTANCES: date, run_id and dew point. **A
maximum found on a hot run is a different fact from one found on a hard
effort** -- neither disqualifies a peak, and both change what anchoring a
zone scale to that peak would mean. run_type is null and labelled "not
collected": this build has no source for it.
`declared_ceiling` compares the corpus peak against the max HR the athlete's
zones are actually anchored to (§14e). It is REPORTED, never corrected:
re-anchoring zones is an athlete decision with a coach behind it.
THE COMPARISON IS GATED ON THE SELECTION ITSELF. It is reported only when
a run IN THIS SELECTION reached within the ceiling band of the
demonstrated 30 s ceiling; otherwise it is REFUSED, naming the
selection's best 30 s value and the band. Over easy runs the gap measures
the selection, not the heart.
Inputs: last_n, cluster, end_date, run_type, +3 more. Returns: result
mile_splitsA
Per mile (or unit="quarter"): running-only pace, HR, DEM gain/loss
and time by gait, with Garmin's own laps beside it for cross-check.
Args:
run: run_id, activity_id or Garmin file id. Empty means the latest run.
unit: "mile" (default) or "quarter".
BOUNDARIES from the device odometer, interpolated exactly at each mile
(the windowed-best rule), never from 30 s bins; across a standing stretch
the LAST instant at that distance. The final unit is a PARTIAL with its
real length. A unit crossing a recording gap over 20 s is FLAGGED
(`contains_gap` / `boundary_in_gap`), not refused.
RUNNING-ONLY PACE: rwd_run splits intersected with the unit, stopped time
removed. A walk break inside a mile is excluded from pace and counted in
`time_walking_s`. `hr_running` (PRIMARY) is time-weighted over the same
running overlap; `hr_all_gaits` covers the whole unit. Both RAW, no lag.
Gain/loss from the smoothed DEM profile, in m and ft; with no DEM they are
null and `elevation_reason` says why. `garmin_laps` are Garmin's laps AS
RECORDED -- only distance-triggered ones are miles, and their pace
includes walking -- and are never substituted for ours.
Inputs: run, unit. Returns: result
pingA
Is the server alive, which code is it running, is the database locked.
NEVER TOUCHES THE DATABASE, so it answers during a sync. A `broken` from
any other tool while `write_locked` is true is the lock, not the corpus.
Inputs: no named parameters. Returns: result
reportC
The §16.2 post-run report for one run, against its recent history.
Args:
run: run_id, activity_id or Garmin file id. Empty means the latest run.
Presents only — it does not judge. Eliminating causes is your work.
Read `notable` first, then `unenforced`.
Inputs: run, last_n, cluster, end_date, +4 more. Returns: result
resistance_historyB
§18.1 HR at matched resistance over CALENDAR TIME — the longitudinal cut.
EFFECTIVE n IS THE NUMBER OF BLOCKS, NEVER THE NUMBER OF HR RECORDS. A
block is one exposure to one resistance; its several hundred HR samples are
one observation of it. This project has made the opposite mistake twice
(§6.9, §2.6) and both are recorded as the same disease.
A group with fewer than three blocks reports `trend_status: insufficient`
and says how many it has, rather than drawing a line through two points.
Blocks with no stated RPM are grouped SEPARATELY from blocks that have one:
no stated cadence is not a cadence that happens to match.
Inputs: match_rpm, resistance. Returns: result
ride_blocksB
§18 the stationary ride's blocks, each with its HR summary.
The watch records HEART RATE ONLY for these -- measured on the indoor
cycling activities in the corpus: no cadence, no speed, no position, no power,
and distance_m is a constant 0.0 rather than null. So resistance, RPM and
the block boundaries are ATHLETE-RECALLED, and `provenance` says so on
every payload. `machine_totals` is the console readout -- machine-reported,
a different reliability, and the machine is the only source for it.
A block containing an off-bike event (water bottle, restroom) is FLAGGED:
the clock keeps running, so the break lands inside a block and its HR is
recovery rather than work at that resistance.
An unannotated ride returns `unavailable` WITH A REASON, never an empty
list -- nothing logged is a different fact from a ride with no structure.
Inputs: run. Returns: result
run_walk_cyclesB
M3 run/walk cycle economics — does the walk buy anything? (§17.2)
Args:
run: run_id, activity_id or Garmin file id. Empty means the latest run.
CHOICE, NOT CAPACITY (§6.6). This is how the athlete elected to structure
the run; it says nothing about what they could have done.
`purchase` pairs each walk with the NEXT run block's pace; `decay` is run
pace by cycle index. Both are PAIRED SERIES, never a fitted slope — time
into the run confounds both, and a coefficient over four points is wider
than itself. Read `elapsed_at_*` before concluding the walk caused anything.
Run blocks under the 120 s pace gate report duration but no pace.
WHAT GOVERNED THE WALKS DECIDES WHAT THIS MEASURES, and this build does
not know. If a rule (an HR cap, a clock) decided them, purchase and decay
measure what the STRUCTURE delivered; if the athlete walked when they felt
like it, identical arithmetic measures WILLINGNESS. `walk_protocol` is
"not collected" here, so `poolable_with_other_runs` is false on every run:
structure that has not been established cannot be assumed. Read each run
on its own.
Inputs: run, last_n, cluster, end_date, +4 more. Returns: result
save_ride_blocksA
Store one stationary ride's block structure. THE ONE tool that writes athlete input.
A writer is a deliberate act: the watch cannot record resistance or RPM,
and nothing else in this system does either, so there is no existing
channel this competes with. Perceived effort is NOT a field here --
Garmin's own self-evaluation, where the device asked for one, is the
effort channel (see session_load), and a second would be two places for
one answer.
Args:
payload: a JSON object. Required: run_id (or activity_id), raw_text,
blocks. Optional: events, machine_totals.
supersede: replace an existing log for this ride, AS AN ARGUMENT
BESIDE the payload and never a field inside it. The previous
revision is KEPT.
BLOCKS ARE END-ANCHORED: each entry is {resistance, end_min, rpm?}, and
start_min is the previous entry's end (0 for the first). Supplying
start_min is REJECTED -- one way to state a boundary means no way for two
statements to disagree. Storage is explicit [start_min, end_min).
resistance is DECIMAL knob-turns from closed (quarter turns such as 4.25,
4.5, 4.75 are ordinary values), validated against [0, MAX]. That ceiling is
DECLARED, not
measured, until the athlete tests where the knob stops.
rpm is a number or [low, high] and is stored as [low, high], NEVER
collapsed to a midpoint -- a midpoint invents precision the recollection
does not have. Absent is absent; a log with no RPM at all is complete.
raw_text is REQUIRED and stored VERBATIM through every revision. The
parsed blocks are an interpretation of it; the text is the record, for the
same reason bronze keeps the raw file.
AMBIGUITY IS REFUSED, NOT RESOLVED. This tool takes already-structured
blocks; overlapping or contradictory entries are rejected naming both.
Parsing prose is the chat's job, at save time, with the athlete present.
Athlete-recalled average HR is NOT a field here -- the watch's HR is
authoritative and already in the corpus. It survives in raw_text only.
Inputs: payload (required), supersede. Returns: result
segment_effort_driftA
Effort-level HR drift on a matched slope. NOT §6.7 — a DIFFERENT quantity.
Pairs whole effort averages with no speed match and no split_type filter,
and normalises by the gap between effort starts, so a short gap amplifies:
+10 bpm over 10 min reads as +60 bpm/hour. On the same run it can read
several times larger than matched_terrain_drift.
Use `matched_terrain_drift` unless you specifically want the slope-matched
measure. NEVER present either as "the" drift. Read `applied_filters`.
Inputs: last_n, cluster, end_date, run_type, +3 more. Returns: result
segment_historyC
§6.6 one slope, every traversal, over time — the trending primitive.
Use `report` to find segment ids; they are unrecognisable on their own, so
the report lists them in the order the reference run meets them.
Inputs: segment_id (required), last_n, cluster, end_date, +3 more. Returns: result
session_loadA
Foster session load: Garmin's own post-run RPE x timer minutes, in AU.
Args:
run: run_id, activity_id or Garmin file id -- ONE activity. Empty,
with no selection filter, means the latest run.
start_date, end_date, cluster, last_n: a SELECTION instead of one
activity -- of EVERY SPORT, since load is a dose and a rated
ride is load the legs carried (`summary.n_by_sport`). Name a run
OR a selection, never both.
run_type: REJECTED when non-empty -- this build has no run_type source.
THE RPE IS GARMIN'S. Some watches ask "how hard was that?" when an
activity is saved; an activity where the device did not ask, or the
rating was declined, is `unavailable` with that reason -- NEVER priced at
0 and never filled from another activity. `garmin_feel` is SHOWN and
never used: it is a different question, and its 1-5 direction is
unverified.
TIMER TIME, NOT MOVING TIME: load is a session DOSE, not a rate, so
standing time counts. Read `filters` for the formula and its basis.
OVER A SELECTION, `summary.sum_foster_load_au_priced` sums the priced
sessions. When any session is unpriced -- unrated (`n_unrated`), or rated
with no timer time (`n_rated_unpriced`, each with its reason) -- it is a
LOWER BOUND and `is_lower_bound` says so; unpriced sessions are left out,
never counted as zero. No GPS coverage gate applies -- load is volume,
not a terrain comparison.
Inputs: run, last_n, cluster, end_date, +4 more. Returns: result
session_seriesA
§17.1b the session as a 30 s binned series — for "over the run" reasoning.
Args:
run: run_id, activity_id or Garmin file id. Empty means the latest run.
include_dynamics: add the per-bin running-dynamics block (below).
Default false: it is the bulk of the payload, and when false it is
absent from EVERY bin, with `dynamics_included: false` saying so.
`dynamics_available` is reported either way.
NULL-DISTANCE BINS (D4). A bin with fewer than two records that carry a
distance -- a GPS gap, an indoor stretch -- reports only the DISTANCE-
DERIVED fields null (distance_start_m/end_m, mps, pace_per_mile), with
`distance_reason` saying how many distance records it had. avg_hr,
steps_per_min, elevation_m, n_records and coverage are still reported
from the records the bin does hold. A bin with NO records at all (past
the last record, inside a long gap) is null throughout with n_records 0.
(A stationary ride is the opposite case: its odometer is a constant 0.0,
not null, so its bins report mps 0.0 -- a known defect, CLAUDE.md §18.)
Pace, HR, elevation, cadence and run/walk/stand state per bin, indexed by
BOTH elapsed seconds and path distance, so "at minute 40" and "at mile 4"
are both answerable from one payload.
STATE IS SECONDS, NOT A LABEL. Each bin carries `run_s`, `walk_s` and
`stand_s`. Many run/walk walks are shorter than a 30 s bin, so a majority
label would erase much of the walking — and on a run/walk session a large
share of bins contain both running and walking.
Elevation is DEM-gated and absent on more than half the corpus; read
`elevation_available`. No position is ever emitted.
RUNNING DYNAMICS PER BIN (§17.1b-bis): stance_time_ms, step_length_mm,
vertical_oscillation_mm and vertical_ratio_pct, each {value, n, unit}.
Garmin's units, unchanged -- millimetres, milliseconds, percent -- and
vertical_ratio is the DEVICE'S OWN field, never recomputed from the other
two.
rwd_run RECORDS ONLY, which is a DIFFERENT RECORD SET from the one behind
`steps_per_min` in the same bin (all records with cadence > 0, running and
walking pooled). `filters.gait_conventions` states each field's, because
reading them as one gait is the mistake the two conventions invite.
n IS PER FIELD. The device drops the four independently -- one field can
sit on a handful of records where another sits on thousands -- so a
per-bin n would misstate the evidence behind a field by orders of
magnitude.
THREE ABSENCES, NEVER 0.0, all distinguishable: `dynamics_reason`
"no_device_channel" (this activity carries none -- FR245, phone file, or a
non-running sport), "no_running_in_bin" (the device records them, this bin
has no running), or null with n 0 (running present, device wrote nothing
for that field here).
`flags_available` says whether §15.4 flags could be applied. Where
records_enriched covers the run, flagged records are excluded; where it
does not, every record is included -- the same figure with and without
flags is NOT the same quantity, so the payload says which it is rather
than refusing the run.
Inputs: run, last_n, cluster, end_date, +5 more. Returns: result
spec_contextA
The standing brief: how to read results, and the measured findings that make a number interpretable. Load this ONCE at the start of a conversation.
Stable across runs — refetch only if told the spec changed.
Inputs: no named parameters. Returns: result
syncA
Fetch new Garmin activities and process them. Returns a JOB ID at once.
This takes minutes and holds the database's exclusive write lock while it
runs, so it cannot be awaited: a synchronous version would make every other
tool call fail for the duration. Poll `sync_status` instead.
NEW FILES ONLY. Downloads APPEND to data/raw/; nothing in this path
modifies, moves, renames or deletes an existing raw file (§3).
A FILE THAT DID NOT INGEST IS REPORTED BY NAME. A finished job with
`clean: false` lists each rejected file under `rejected` with the reason
and a `next_step` carrying the exact command: a recording with a drive or
a forgotten stop at one end is cleared by confirming the window that is
the run (`confirm-file <garmin-id> --end '...'`) and syncing again.
Confirming a window is CLI-ONLY -- no tool here does it -- so relay the
command to the athlete rather than looking for one.
A STEP THAT FAILS COSTS ONLY ITSELF. Each step's outcome is in `steps`
(done / skipped / failed); a failure is in `step_errors` and `problems`
by step name and makes the job `clean: false`, and the rejections are
still reported. With no DEM tiles on disk the terrain steps (dem, silver,
match, recovery) are SKIPPED with the reason in `skipped_steps` -- the
normal state of a fresh install, not a failure.
Inputs: no named parameters. Returns: result
sync_statusA
Progress of a sync job. Empty job_id returns the most recent.
States: queued, running (with the current step), done, failed (with which
step failed -- a Garmin outage and a corrupt file are different problems).
A job fails outright only when the DOWNLOAD fails; a later step that
raises is reported inside a `done` job's result, by name, under
`step_errors` with `clean: false`, so the rejections still arrive.
Inputs: job_id. Returns: result
time_above_hrB
Time at or above each rung of the configured HR ladder
(time_above_ladder_bpm in config/athlete.yaml), per run and corpus-wide.
thresholds_bpm in the result lists the rungs used.
THE QUESTION THE EXCURSION LIST CANNOT ANSWER: is the top of the range a
place this athlete has been REPEATEDLY, or twice? One run contributing six
excursions and six runs contributing one each look identical at the top of
a list sorted by peak.
Timer-time weighting, capped per record: time at a heart rate is a DOSE,
not a rate, so stopped time is not subtracted — the heart does not stop at
a road crossing. Totals are also reported without the run whose provenance
is open (§14e-bis).
Inputs: last_n, cluster, end_date, run_type, +3 more. Returns: result
time_in_resistanceB
§18.2 minutes per resistance value and per 0.5-turn band, per LOCAL week.
The same shape as time-in-zones, and the only ride read that rests on NO assumption beyond the blocks having been logged correctly. Pure aggregation of stored blocks.
Inputs: no named parameters. Returns: result
zone_by_gaitC
M7 time in HR zone split by GAIT — running vs walking vs standing.
Zone-2 minutes are the headline, per run and rolled up per LOCAL ISO week.
WHY THE SPLIT MATTERS: a pooled zone-2 total mixes a walk up a climb
with running on the flat, and those are different training. On a hilly
run/walk session the walking share of zone 2 can exceed the running share
— invisible in the pooled figure, and the whole point of the run/walk
structure.
Boundaries come from EACH ACTIVITY'S OWN FILE where present (§14e); a
corpus spanning zone changes holds several distinct sets, and
`boundary_set` says which ruler a
run was measured with, and a week spanning two is reported as SPLIT rather
than summed into a number that is on no scale.
Do not compare this against Garmin's hrTimeInZone: ours excludes stops and
flagged records and applies the 40 s HR lag, so it reads lower by design.
Inputs: last_n, cluster, end_date, run_type, +3 more. Returns: result
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 29 tools
Most tools target distinct analytical questions, and descriptions explicitly contrast near-duplicates such as matched_terrain_drift vs segment_effort_drift and max_hr_over vs hr_above. However, the density of HR, pace, and drift metrics means an agent must read carefully; a few pairs could be confused without the explicit warnings.
All names are snake_case and follow a predictable domain vocabulary, with action tools using verbs (list_, save_, sync) and analysis tools using metric nouns. The mix of verb-led and noun-phrase names is readable but not a single verb_noun convention throughout.
29 tools is heavy, but the scope spans run analysis, HR metrics, segments, sync, and stationary rides, so many tools represent distinct analyses. It is borderline: each earns its place, yet the surface is large for an agent to navigate.
The set covers core lifecycle: sync, list, per-run metrics, segment analysis, drift, zones, and ride logging with supersede. Minor gaps exist (e.g., no explicit delete for ride logs, config editing is external), but agents can work around them.
Maintenance
Related MCP Connectors
Garmin data in Claude: 135 tools — activities, sleep, HRV, training, workouts. Free, open source.
tri-train is a training platform for triathletes that syncs activities from Strava and Garmin and exposes them to Claude through a remote MCP server. Ask Claude how your last block went, why your run pace dropped, or whether you are fresh enough for Sunday's race, and it answers from your real data: activities with laps and grade-adjusted pace, weekly load and the performance management chart (CTL/ATL/TSB), sleep, HRV and resting heart rate, and your race calendar with predictions. Claude can also act: create and adjust planned workouts with structured intervals, push them to your Garmin watch, add races and write notes. Every change is journaled and can be undone. Read and write access are separate OAuth scopes, and the server enforces them on every call. Built and hosted in Switzerland by a triathlete; data stays on our own servers (Swiss revDSG / GDPR).
Connect Claude to your Intervals.icu watch data for fitness, workout review, and plan writing.
Ask your AI about your fitness: activities and data from Garmin, COROS, Strava, GPX and more
Related MCP Servers
- FlicenseNot gradedqualityBmaintenanceConnects Claude Desktop to your Garmin Connect running data stored locally in SQLite, enabling querying, syncing, and AI analysis of fitness activities.1-
- AlicenseAqualityBmaintenanceProvides Claude Code and Claude Desktop with safe, read-only access to your local Garmin training data, readiness decisions, run details, and trends so you can get coaching advice in natural language.15164 PyPI9MIT
- AlicenseNot gradedqualityNot gradedmaintenanceLets Claude read a runner's recent activities and wellness signals — sleep, HRV, resting heart rate — and then design, schedule, edit, or delete structured workouts. Planned sessions are written to an intervals.icu calendar that syncs through Garmin Connect onto the user's watch, without exposing Garmin credentials.MIT
- AlicenseNot gradedqualityNot gradedmaintenanceConnects Garmin training data to Claude so it can read recent activities, wellness and recovery signals, and upcoming calendar events. It also lets Claude create, update, and delete structured workouts that sync through intervals.icu to Garmin Connect and onto the user's watch.MIT