- Single source of truth for all training data, regardless of which device captured it.
- Grounded reasoning — the AI coach should cite specific runs, never invent paces.
- Idempotent ingestion — re-running the sync should never duplicate or corrupt data.
- Extensible — adding a new source (Apple Watch, Whoop, manual entry) is a localized change.
- Resume-quality engineering — clean separation of concerns, typed contracts, real RAG.
┌─────────────────────────────────────────┐
│ DATA SOURCES │
├─────────────────────┬───────────────────┤
│ Strava REST API │ Garmin FIT files │
│ (OAuth 2.0) │ (local export) │
└──────────┬──────────┴─────────┬─────────┘
│ │
┌─────────────▼─────────┐ ┌───────▼──────────┐
│ strava_client.py │ │ garmin_client.py │
│ — auth + paginated │ │ — fitparse over │
│ fetch │ │ .fit binary │
│ — auto token refresh │ │ — cadence/HR/ │
│ │ │ stride extract │
└──────────┬────────────┘ └──────┬───────────┘
│ │
▼ ▼
┌──────────────────────────────────┐
│ pipeline.py (ETL) │
│ │
│ 1. Fetch raw activities │
│ 2. Normalize → Activity model │
│ 3. Dedupe (Strava↔Garmin pair) │
│ 4. Merge richer fields (HR, │
│ cadence wins from Garmin) │
│ 5. Persist to activities.json │
│ 6. Trigger reindex │
└──────────────┬───────────────────┘
│
┌──────────────────┴──────────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ analytics.py │ │ rag.py │
│ │ │ │
│ - weekly aggreg │ │ - chunk corpus │
│ - pace trends │ │ - embed via │
│ - HR zone time │ │ sentence- │
│ - ACWR / load │ │ transformers │
│ - PR detection │ │ - upsert to │
└────────┬────────┘ │ ChromaDB │
│ └────────┬────────┘
└─────────────┬──────────────────────┘
▼
┌─────────────────────────┐
│ query.py │
│ │
│ Hybrid retrieval: │
│ 1. Parse intent │
│ 2. Apply structured │
│ filters │
│ 3. Semantic search │
│ on remainder │
│ 4. Rerank + return │
│ top-K with metrics │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ generation layer │
│ │
│ chat.py CLI, or the │
│ Claude Code subagent, │
│ turns the query.py │
│ bundle into an answer │
│ via your chosen model │
│ (local / Claude / GPT) │
└─────────────────────────┘
Every activity normalizes to the same shape, whether it came from Strava or Garmin:
class Activity(BaseModel):
# Identity
id: str # canonical: f"{source}:{source_id}"
source: Literal["strava", "garmin", "manual"]
source_id: str
# When + what
start_time: datetime # UTC
local_date: date # local date of run
name: str
sport: str # "Run", "Walk", "Bike", etc.
# Distance + duration
distance_mi: float
moving_time_sec: int
elapsed_time_sec: int
# Pace
pace_per_mile_sec: float
# Terrain
elevation_gain_ft: int
# Physiological (may be None if device didn't record)
avg_heartrate: Optional[float]
max_heartrate: Optional[float]
avg_cadence: Optional[float] # Garmin only
avg_stride_length_m: Optional[float] # Garmin only
# Effort
suffer_score: Optional[float]
# Free-form
notes: Optional[str]
# Provenance
raw: dict # original payload (for debugging)When the same run appears in both Strava and Garmin (same start_time within ±60s, similar distance):
| Field | Winner | Reason |
|---|---|---|
distance_mi, pace_per_mile_sec |
Garmin | Better GPS |
avg_heartrate, max_heartrate |
Garmin | Direct sensor data |
avg_cadence, stride_length |
Garmin only | Strava doesn't have these |
name |
Strava | User-edited names live in Strava |
elevation_gain_ft |
Garmin | Barometric altimeter beats GPS |
The corpus mixes document types, so every retrievable unit carries metadata for filtering:
| Document type | Content | Metadata |
|---|---|---|
| Activity | One-paragraph description of the run | date, distance, pace, hr, type, elevation |
| Weekly summary | "Week of YYYY-MM-DD: X miles across N runs, longest Y mi..." | week_start, miles, runs |
| Progress note | The full markdown coaching note | date, tag |
| Plan section | Each phase of the current plan, as a chunk | phase, weeks, target_pace |
Pure vector search fails on questions like "my longest run last month" — embeddings don't know about dates and aggregates. Pure SQL fails on "runs where I struggled" — there's no struggled column.
The query flow:
- Intent extraction — parse the question for structured constraints (dates, distance ranges, run types).
- Filter — narrow the candidate pool by metadata.
- Semantic rank — embed the question, rank candidates by cosine similarity.
- Return — top-K documents + raw metrics for grounding.
query.py produces a model-agnostic bundle: the deterministic snapshot plus the retrieved docs, as JSON. Nothing about it assumes which model reads it. Two front-ends consume that same bundle:
chat.py, a provider-agnostic CLI. It folds the bundle into a prompt and sends it to whatever model you configure — a local model (Ollama, LM Studio, vLLM, Hermes) through the OpenAI-compatible API, or a hosted service (Claude, OpenAI).llm.pyholds the two backends: one native Anthropic client, one OpenAI-compatible client that covers hosted OpenAI and every local server.- The Claude Code subagent, which calls
query.pyitself and lets Claude Code supply the model.
Splitting retrieval (query.py) from generation (chat.py / the subagent) is what keeps the coach portable. Swapping models never touches the pipeline.
Inside Claude Code, semantic retrieval is close to redundant: the subagent has file tools and a large context window, so it could read activities.json directly. The vector store looks like overhead.
The moment you point the coach at a small-context local model, that inverts. Hermes can't swallow every run, note, and plan section — you have to choose what goes in the prompt. That choice is exactly what the snapshot + top-K retrieved bundle does: it fits the answer-relevant slice into a small window while keeping the coach grounded. Deterministic stats cover the arithmetic a model can't be trusted with; retrieval covers the "which runs matter" question. That is what makes running the coach on a modest local model possible at all.
The CLI is the portable front-end, but the Claude Code subagent earns its place beside it. It gets its own system prompt, its own tool list, and an explicit invocation (@running-coach):
- The coach has a fixed persona (knowledgeable, honest about limitations, evidence-grounded).
- The coach has scoped tools — read access to the data, ability to run
query.py, but no destructive write access by default. - The coach is discoverable — the main agent can delegate running-related questions to it.
- MCP server wrapper around
query.pyso other Claude Code projects can hit the coach as a tool - Apple Health import for sleep/HRV context
- Predicted race time using the Riegel formula on the longest recent run
- Auto-adjusting plan — if last week was missed, regenerate this week
- Web dashboard (FastAPI + minimal frontend) to visualize trends