Skip to main content
Glama
trail-factors

trail-factors

Trail Factors

Trail and ultra running analysis from your own Garmin FIT files, served to Claude through a local MCP server.

CI License: MIT

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 demo

Register 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 list

Then 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 (.pre-commit-config.yaml)

Blocks a commit whose staged changes contain a secret.

ruff (lint + format)

pre-commit (.pre-commit-config.yaml)

Keeps style and common errors out of commits.

ruff and pytest

CI (.github/workflows/ci.yml), every push and pull request

Installs from the lockfile, checks lint and formatting, runs the tests. Corpus tests skip because no database is set.

TruffleHog

.github/workflows/secrets.yml, every push

Scans the full git history and fails on any verified or unknown finding.

Dependabot

.github/dependabot.yml

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 sonar CLI.

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 pytest

Counts 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 in data/athlete/, outside the rebuild path, because nothing can regenerate it. It is gitignored with the rest of data/, 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-tile refuses 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

LinkedIn

License

MIT. See LICENSE.

Available Tools

29 tools
basic_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

    B3.3/5.0

    Scored across 29 tools

    Disambiguation4/5

    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.

    Naming Consistency4/5

    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.

    Tool Count3/5

    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.

    Completeness4/5

    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

    ActivityNo data
    ResponsivenessOutdated data

    Related MCP Connectors

    Related MCP Servers

    • F
      license
      Not graded
      quality
      B
      maintenance
      Connects Claude Desktop to your Garmin Connect running data stored locally in SQLite, enabling querying, syncing, and AI analysis of fitness activities.
      1
      -
    • A
      license
      A
      quality
      B
      maintenance
      Provides 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.
      15
      164 PyPI
      9
      MIT
    • A
      license
      Not graded
      quality
      Not graded
      maintenance
      Lets 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
    • A
      license
      Not graded
      quality
      Not graded
      maintenance
      Connects 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