AI Training Coach
A local-first AI coach that syncs workouts from Garmin and Strava, embeds session notes into pgvector, and answers training questions through a LangGraph agent running on a local Ollama model.
I wanted a training coach that actually knows my history — not a generic fitness chatbot, but something grounded in my own Garmin and Strava data, running entirely on my machine, with no third-party API seeing my health numbers. So I built one. This post is a walkthrough of the architecture and the decisions that shaped it.
What it is
A single-user, local-first AI training coach. It syncs your workouts from Strava and Garmin Connect every two hours, stores them in PostgreSQL, and exposes a chat interface where you ask questions like “how did my long runs trend last month?” or “when did I last run a sub-5:00 pace?”. The model reasoning happens entirely on your own hardware via Ollama — no data leaves your machine.
The stack in brief:
| Layer | Technology |
|---|---|
| AI orchestration | LangGraph + LangChain (Python) |
| Local LLM | Ollama — configurable model (default llama3.1:8b) |
| Embeddings | Ollama nomic-embed-text (768 dim) |
| Backend | FastAPI + asyncpg (no ORM) |
| Migrations | Alembic |
| Database | PostgreSQL 16 + pgvector |
| Data sync | stravalib (OAuth) + garminconnect |
| Scheduler | APScheduler (2-hour cadence) |
| Frontend | React + Vite (TypeScript) |
| Container | Docker Compose |
Architecture
Browser
↓ HTTP / SSE (streaming)
FastAPI
├── /api/chat → LangGraph pipeline
├── /api/profile → CRUD physical profile
├── /api/goals → CRUD race goals
├── /api/plan → active training plan
├── /api/workouts → session list + notes
├── /api/documents → document upload + extraction
└── /api/sync/status → last sync metadata
LangGraph pipeline (per chat message)
┌─ load_profile ──────────────────────┐
├─ load_goals │
├─ load_plan │ (parallel fan-out)
├─ load_recent_workouts │
├─ load_sport_history │
├─ load_wellness │
├─ rag_notes (pgvector similarity) │
├─ load_documents │
└─ load_pending_checkins ───────────────────┘
↓
assemble_context → llm_call (Ollama, streaming)
↓
should_use_tools?
yes ↙ ↘ no → SSE → browser
execute_tools
mark_docs_processed
↓ (loop, max 3 iterations)
llm_call
Tool loop write-back: save_goal · save_workout_note
save_training_plan · save_profile_measurement
APScheduler (background, 2-hour cadence)
→ sync_strava() + sync_garmin()
On startup, Alembic migrations run first, then sync kicks off in a non-blocking background task so the UI is immediately usable while the first import runs.
The agent pipeline
The most interesting engineering decision was how to structure the LangGraph
graph. The naive approach — run each context loader sequentially — adds up fast
when you have nine of them. Instead, all loaders fan out in parallel and the
results are merged in a single assemble_context node before the LLM call.
parallel_nodes = [
("load_profile", load_profile),
("load_goals", load_goals),
("load_plan", load_plan),
("load_recent_workouts", load_recent_workouts),
("load_sport_history", load_sport_history),
("load_wellness", load_wellness),
("rag_notes", rag_notes),
("load_documents", load_documents),
("load_pending_checkins", load_pending_checkins),
]
for name, fn in parallel_nodes:
builder.add_node(name, fn)
builder.add_edge(START, name)
builder.add_edge(name, "assemble_context")
builder.add_edge("assemble_context", "llm_call")
builder.add_conditional_edges("llm_call", should_use_tools)
builder.add_edge("execute_tools", "mark_docs_processed")
builder.add_edge("mark_docs_processed", "llm_call")
All nine loaders run concurrently; assemble_context waits for the last one,
then builds the prompt. This keeps latency close to max(individual_query)
rather than sum(all_queries).
After the LLM call the graph doesn’t stop — there’s a bounded tool-calling loop that lets the agent write back to the database. That’s covered in the next section.
Grounding the model — no invented data
Giving a language model a rich context isn’t enough; you also have to ensure it
doesn’t fill gaps by hallucinating. The rule I enforced: if a node finds no
data, it returns None. assemble_context silently omits that section. The
system prompt reinforces this with an explicit instruction.
def assemble_context(state: CoachState) -> CoachState:
sections: list[str] = []
if state.profile:
sections.append(f"## Athlete profile\n{state.profile}")
if state.goals:
sections.append(f"## Active goals\n{state.goals}")
if state.recent_workouts:
sections.append(f"## Recent workouts\n{state.recent_workouts}")
if state.wellness:
sections.append(f"## Wellness data (last 14 days)\n{state.wellness}")
if state.rag_notes:
sections.append(f"## Relevant session notes\n{state.rag_notes}")
state.context = "\n\n".join(sections) if sections else ""
return state
An empty sections list means the LLM receives a prompt that explicitly says
“no data is available for this period” — it never gets a placeholder string or a
null value to rationalise around.
The agent can act, not just answer
Most chatbot architectures stop at generation — the model produces text and the user takes action manually. This agent goes further: the LLM is bound to four structured tools that write directly to the database.
tools = [save_profile_measurement, save_goal, save_workout_note, save_training_plan]
llm = ChatOllama(model=ollama_model, base_url=ollama_base).bind_tools(tools)
After the LLM responds, a conditional edge inspects the message for tool calls:
MAX_TOOL_ITERATIONS = 3
def should_use_tools(state: GraphState) -> str:
last = state["messages"][-1]
if hasattr(last, "tool_calls") and last.tool_calls:
_iteration[0] += 1
if _iteration[0] <= MAX_TOOL_ITERATIONS:
return "execute_tools"
_iteration[0] = 0
return END
If tool calls are present the graph routes to LangGraph’s ToolNode, executes
them, then loops back into llm_call so the model can reason over the tool
results before finalising its reply. The loop is capped at three iterations —
enough for realistic multi-step actions, but bounded to prevent unbounded
latency or runaway execution.
A few deliberate choices behind this design:
- Structured tool calls over free-text parsing. The model emits a typed JSON
payload (
save_goalreceivessport,data_target,distanza_km, etc.); the tool validates the schema before touching the database. No regex, no prompt engineering to coerce output format. - Same chat surface for reads and writes. The user doesn’t context-switch to a separate form to log a goal or save a note — they say it in the chat and the agent persists it.
- Profile history is immutable.
save_profile_measurementalways inserts a new timestamped row; it never overwrites. This gives the model a trend to reason against (“your body-fat percentage dropped 2 points since March”) rather than a single snapshot.
The SSE stream emits a {"reset": true} frame when a write-back tool fires, so
the frontend knows to invalidate its local state and refetch the relevant list.
Document ingestion → structured data
A common pattern in personal performance tracking is receiving structured data in unstructured containers: a coach sends a PDF training plan, a gym outputs a BIA body-composition scan as a spreadsheet, a lab report comes as a Word doc.
Rather than building a bespoke parser per format, the project routes all of these through the same agent loop:
- Upload via
POST /api/documents(PDF, DOCX, XLSX, TXT, CSV, or MD). - Server-side text extraction —
pdfplumberfor PDFs,python-docxfor Word,openpyxlfor spreadsheets — reduces every format to plain text. - The extracted text lands in the
documentstable and is picked up by theload_documentscontext node on the next chat turn. - The model reads the document in context and, if it contains recognisable data (measurements, goals, a week-by-week training plan), calls the appropriate write-back tool to persist the structured values.
The result: drop a PDF and ask “import the goals from this plan” — the agent parses intent, extracts the structured fields, validates them against the tool schema, and inserts the rows. No custom extraction pipeline; the same tool loop that handles conversational writes handles document-triggered writes.
Data sync and deduplication
Each sync run pulls activities from both sources, normalises them into a common schema, and upserts into PostgreSQL. The deduplication logic handles the fact that the same workout often appears in both Garmin and Strava:
- Primary key:
external_idper source (Strava activity ID, Garmin activity ID) — first line of defence. - Cross-source dedup: match on
(date, sport, duration ± 60 s)— catches the same workout synced from a watch to both platforms.
All DB access is raw SQL via asyncpg — no ORM, always parameterised:
async def upsert_workout(pool: asyncpg.Pool, activity: dict) -> None:
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO workouts (
external_id, source, sport, started_at,
duration_s, distance_m, avg_hr
) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (external_id, source) DO UPDATE SET
duration_s = EXCLUDED.duration_s,
distance_m = EXCLUDED.distance_m,
avg_hr = EXCLUDED.avg_hr,
synced_at = now()
""",
activity["external_id"],
activity["source"],
activity["sport"],
activity["started_at"],
activity["duration_s"],
activity["distance_m"],
activity["avg_hr"],
)
Schema changes go through Alembic — no hand-written DDL, migrations apply automatically at container startup.
Session notes and RAG
Every workout can have a free-text note — what hurt, what felt good, what the
conditions were. These notes are embedded with nomic-embed-text (768-dim) and
stored in pgvector. The rag_notes context node runs a similarity search against
the user’s question, so when you ask “when did my achilles start bothering me?”,
the agent retrieves the specific session notes where you wrote about it.
The embedding step runs at note-save time, not at query time, so retrieval
latency stays predictable. At query time the user’s message is embedded and a
cosine similarity search (1 - (embedding <=> $1::vector), LIMIT 5) runs
against the notes table — the top results are injected into the context before
assemble_context builds the prompt.
Streaming uses LangGraph’s astream_events(version="v2") API, filtered to
on_chat_model_stream events, so tokens are forwarded to the browser as SSE
frames the moment they leave Ollama — no buffering, no waiting for the full
response.
What I’d do next
- Eval harness — right now I validate answers by feel. A small question/answer dataset with expected outputs and a cosine-similarity threshold would make model changes testable rather than anecdotal.
- Retrieval quality feedback — the RAG node returns the top-5 notes by cosine similarity but there’s no signal on whether those results were actually useful. Logging which retrievals preceded a tool call vs. a plain answer would surface patterns for tuning the similarity threshold and chunk strategy.
- Fine-tuning — once enough Q&A pairs accumulate, fine-tuning a small model on my own activity history and notation style could sharpen the specificity of the answers considerably over a general-purpose base model.
- Lightweight chat UI — the React frontend covers the basics; a proper chat experience (message history, suggested questions, streamed markdown rendering, visible tool-call progress) would make it more useful day-to-day.