running-coach-mcp
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., "@running-coach-mcpshow me my last 5 runs from the past 30 days"
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.
running-coach-mcp
A small Model Context Protocol (MCP) server that connects an AI client to a personal Intervals.icu account.
Milestone 1 exposed a single tool,
get_recent_runs, returning the athlete's most recent running activities as clean, normalized JSON.Milestone 2B protected
/api/mcpwith standards-compliant OAuth via WorkOS AuthKit, so the endpoint can be safely exposed on the public internet (e.g. on Vercel) while remaining accessible to exactly one person.Milestone 3A adds two more read-only tools,
get_run_detailsandget_run_streams, so an AI client can drill into a single run's detected intervals and time-series data (pace, heart rate, cadence, power, elevation) for real analysis — "did I fade in the last interval?", "how did my heart rate develop?", etc.Milestone 3B adds
get_wellness, exposing daily recovery/physiological data (resting heart rate, HRV, sleep, weight, VO2 max, CTL/ATL training load) — athlete-level, not tied to any single activity.Milestone 3C adds
get_running_progress, descriptive weekly-volume/pace/heart-rate/training-load/VO2 max trend analysis with recent-vs-previous period comparisons — built cheaply from the existing activity list and wellness data, with no per-run detail or stream fetches.Milestone 3D adds
get_calendar, exposing recent and upcoming calendar events / planned workouts from Intervals.icu — including the next planned running workout — so an AI client can answer "what's on my schedule?" questions. Still strictly read-only.Milestone 3E (this milestone) adds the server's first write tools —
create_running_workout,update_running_workout,delete_running_workout— so an AI client can schedule, change, or remove a planned running workout on the athlete's Intervals.icu calendar, when the user explicitly asks it to. Writes use Intervals.icu's own native workout-builder text syntax (placed in the eventdescription); Intervals.icu itself compiles that text into a structured, device-syncable workout. This project never constructs or sendsworkout_docdirectly — see Writing workouts: why text, notworkout_docbelow.
Six tools (get_recent_runs, get_run_details, get_run_streams, get_wellness, get_running_progress, get_calendar) remain strictly read-only. Three tools (create_running_workout, update_running_workout, delete_running_workout) are write tools that modify the athlete's Intervals.icu calendar — see Write tools below for exactly what they do and the safeguards around them.
Architecture
Garmin → Garmin Connect → Intervals.icu → running-coach-mcp → WorkOS OAuth protected MCP → ChatGPTGarmin / Garmin Connect — the athlete's watch and activity sync source.
Intervals.icu — source of truth for activity data, synced from Garmin. Accessed read-only via HTTP Basic Auth with a personal API key.
running-coach-mcp (this app,
src/lib/intervals/*) — a small server-only client and domain layer that fetches raw activities, activity detail + intervals, time-series streams, daily wellness data, and calendar events / planned workouts, and converts each into our own normalized models (src/types/activity.ts,src/types/interval.ts,src/types/stream.ts,src/types/wellness.ts,src/types/calendarEvent.ts).src/lib/intervals/progress.tsbuilds descriptive trend analysis (src/types/progress.ts) on top of the same activity list + wellness data — no extra upstream endpoints, no per-run detail/stream fetches.src/lib/intervals/workouts.ts(Milestone 3E) adds the write side: create/update/delete a planned running workout, generating native Intervals.icu workout-builder text (src/lib/running/workoutText.ts) from a validated input model (src/lib/running/workoutInput.ts) rather than ever buildingworkout_docitself. MCP code never touches the raw Intervals.icu response shape directly.WorkOS OAuth protected MCP (
src/app/api/mcp/route.ts+src/lib/auth/*) — exposes the domain layer as MCP tools over Streamable HTTP viamcp-handler, gated behind OAuth bearer-token verification.ChatGPT (or any MCP-compatible client — Claude Desktop, Cursor, MCP Inspector, etc.) — calls the six read tools plus, when the user explicitly authorizes it, the three write tools (
create_running_workout,update_running_workout,delete_running_workout) — after completing the OAuth flow against WorkOS.
OAuth roles
WorkOS AuthKit is the OAuth Authorization Server. It authenticates the user and issues access tokens. This app never issues tokens itself and never runs its own authorization server.
running-coach-mcp is the OAuth Resource Server. It cryptographically verifies WorkOS-issued access tokens (signature + issuer + audience, via JWKS) before allowing a request to reach any tool — all nine of them, read and write alike. The check wraps the whole
/api/mcphandler, so every tool registered on it — including future ones — inherits the same protection automatically. OAuth authenticates who is calling; it is not what decides whether a given write is authorized — see Write tools for that.Intervals.icu credentials remain completely separate and server-only. The Intervals.icu API key is never sent to, derived from, or exposed via WorkOS/MCP — the two auth systems never mix, and WorkOS tokens are never forwarded to Intervals.icu.
Access is currently limited to a single WorkOS user. After a token is verified, its
subclaim is compared againstMCP_ALLOWED_USER_ID. Any other (even otherwise valid) user is rejected with403. IfMCP_ALLOWED_USER_IDisn't configured, the server fails closed and allows no one.
src/
app/
.well-known/
oauth-protected-resource/route.ts # RFC 9728 metadata for /api/mcp (resource, authorization_servers, ...)
oauth-authorization-server/route.ts # RFC 8414 metadata, proxied from WorkOS for clients that probe the MCP host directly
api/
health/route.ts # GET /api/health — PUBLIC liveness + config check (no secrets, no upstream calls)
mcp/route.ts # MCP endpoint (Streamable HTTP), OAuth-protected — registers all nine tools (6 read + 3 write)
page.tsx # minimal info page (no UI framework needed)
lib/
auth/
config.ts # reads/validates WORKOS_AUTHKIT_DOMAIN / MCP_RESOURCE_URL / MCP_ALLOWED_USER_ID
verifyAccessToken.ts # cryptographic JWT verification against WorkOS JWKS (jose), issuer + audience checks
mcpAuth.ts # withMcpAuth wiring + single-user (sub) authorization -> 401 / 403
intervals/
auth.ts # reads INTERVALS_API_KEY / INTERVALS_ATHLETE_ID, builds Basic Auth header
client.ts # server-only fetch wrapper (GET + POST/PUT/DELETE, timeout, no-store, error + malformed-response mapping)
activities.ts # domain layer: getRunningActivitiesInRange() + getRecentRuns() — fetch, filter, sort, (optionally) limit
activityDetails.ts # domain layer: getRunDetails() — activity + intervals, running-type check
streams.ts # domain layer: getRunStreams() — full stream fetch + downsampling
wellness.ts # domain layer: getWellness() — daily wellness fetch + latest/latest-non-null summary
progress.ts # domain layer: getRunningProgress() — trend analysis, built on activities.ts + wellness.ts (no new upstream calls)
calendar.ts # domain layer: getCalendar() — calendar events/planned workouts fetch, normalize, classify, sort, next-planned-workout selection
workouts.ts # domain layer (Milestone 3E, WRITE): createRunningWorkout() / updateRunningWorkout() / deleteRunningWorkout() — text generation, fetch-before-write safety checks, POST/PUT/DELETE
normalizers.ts # raw Intervals.icu activity shapes -> RunningActivity / RunningInterval / stream point models
wellnessNormalizers.ts # raw Intervals.icu wellness shape -> DailyWellness model
calendarEventNormalizers.ts # raw Intervals.icu event shape -> CalendarEvent model, incl. planned-workout classification
running/
pace.ts # pure pace/speed calculation + formatting helpers
downsample.ts # pure, deterministic bucket-sampling helper (no randomness)
dates.ts # pure, timezone-aware date-only helpers (todayDateOnly, addDaysToDateOnly, isDateOnlyInRange, isValidDateOnly), shared by activities/wellness/progress/calendar/workouts
athleteTimeZone.ts # reads + validates ATHLETE_TIME_ZONE (defaults to Europe/Stockholm)
progressAggregation.ts # pure aggregation rules for progress analysis (pace/HR weighting, weekly buckets, HR bands, VO2 trend, comparisons)
workoutInput.ts # Milestone 3E: RunningWorkoutInput zod schema + bounds — single source of truth, reused as both domain validation AND the MCP tool inputSchema
workoutText.ts # Milestone 3E: pure RunningWorkoutInput -> native Intervals.icu workout-builder TEXT generator (never workout_doc)
types/
activity.ts # IntervalsActivity (raw) and RunningActivity / RunningActivityDetail (our models)
interval.ts # IntervalsInterval (raw) and RunningInterval (our model)
stream.ts # IntervalsStream (raw) and RunningStreamPoint / RunningStreamsResult (our models)
wellness.ts # IntervalsWellnessEntry (raw) and DailyWellness / WellnessResult (our models)
progress.ts # normalized RunningProgressResult and its nested types (no raw upstream shape here)
calendarEvent.ts # IntervalsEvent/IntervalsWorkoutDoc (raw) and CalendarEvent / CalendarResult / CalendarEventWorkout (our models)Related MCP server: Intervals.icu MCP Server
Environment variables
Variable | Description |
| Your personal Intervals.icu API key. Required. Never sent to the browser or logged. |
| Your Intervals.icu athlete id. Defaults to |
| Your WorkOS AuthKit domain, e.g. |
| The full MCP resource URL, e.g. |
| The single WorkOS user id ( |
| IANA timezone (e.g. |
See .env.example for a template with empty values — never commit real secrets or the real allowed user id.
Local setup
Install dependencies:
npm installCreate
.env.local(see.env.example):INTERVALS_API_KEY=<your personal Intervals API key> INTERVALS_ATHLETE_ID=0 WORKOS_AUTHKIT_DOMAIN=https://neat-comic-70-staging.authkit.app MCP_RESOURCE_URL=http://localhost:3000/api/mcp MCP_ALLOWED_USER_ID=<your WorkOS user id>Get your Intervals.icu API key under Settings → Developer Settings. For local testing,
MCP_RESOURCE_URLshould point at your local server; in production it must match the deployed/api/mcpURL exactly (see below).Run the dev server:
npm run dev
MCP endpoint:
http://localhost:3000/api/mcpHealth check (public, no auth):
http://localhost:3000/api/healthProtected resource metadata:
http://localhost:3000/.well-known/oauth-protected-resourceAuthorization server metadata (proxied):
http://localhost:3000/.well-known/oauth-authorization-server
Production endpoints
MCP endpoint:
https://running-coach-mcp.vercel.app/api/mcpProtected resource metadata:
https://running-coach-mcp.vercel.app/.well-known/oauth-protected-resourceAuthorization server metadata:
https://running-coach-mcp.vercel.app/.well-known/oauth-authorization-server
Testing with MCP Inspector
Calling /api/mcp now requires a valid WorkOS access token (see Auth architecture above), so a plain npx @modelcontextprotocol/inspector <url> connection will get a 401 until it completes the OAuth flow.
With the dev server running, in another terminal:
npx @modelcontextprotocol/inspectorThis opens the Inspector UI in your browser. Connect with:
Transport:
Streamable HTTPURL:
http://localhost:3000/api/mcp(or the production URL above)
Inspector supports the standard OAuth discovery flow: it will read /.well-known/oauth-protected-resource, follow the authorization_servers entry to WorkOS, and prompt you to sign in. Once authenticated as the allow-listed user, open the Tools tab, select a tool, and call it to see the result as JSON.
Tools
All nine tools require the same WorkOS OAuth bearer token and never expose Intervals.icu credentials or raw upstream payloads.
Read tools
get_recent_runs, get_run_details, get_run_streams, get_wellness, get_running_progress, and get_calendar are strictly read-only — none of them can create, modify, or delete anything in Intervals.icu.
get_recent_runs
Unchanged since Milestone 1:
limit (optional integer, 1–20, default 5) — max number of runs to return.
days (optional integer, 7–365, default 90) — how far back to search.
It fetches up to 100 recent activities from Intervals.icu within the date window, filters to running types (Run, TrailRun, VirtualRun), sorts newest first, and returns up to limit normalized runs. If fewer runs exist in the window, it returns whatever is available.
Example prompt: "Show my five latest runs."
get_run_details
Fetches one activity (GET /activity/{id}) plus its detected intervals/laps (GET /activity/{id}/intervals), and returns a single normalized object: summary metrics (distance, pace, heart rate, cadence, power, elevation, training load/intensity/fitness/fatigue, RPE/feel, decoupling), which streams are available (availableStreams), and every detected interval (each with its own pace, heart rate, cadence, power, elevation gain, grade-adjusted pace, and decoupling). It never includes raw time-series streams, to keep the response small.
activityId (required string) — an Intervals.icu activity id, e.g.
"i186254951"(as returned byget_recent_runs).
Only Run, TrailRun, and VirtualRun activities are accepted; anything else is rejected with "Activity is not a running activity.". An unknown id is rejected with "Running activity not found.".
Example prompt: "Analyze my latest run."
get_run_streams
Fetches the full time-series stream set for one activity (GET /activity/{id}/streams.json) and returns a normalized, size-bounded sample: elapsed time, distance, pace, heart rate, cadence, power, and altitude per point, plus which stream types were actually available.
activityId (required string) — an Intervals.icu activity id.
maxPoints (optional integer, 100–1000, default 600) — maximum number of points to return.
Raw activity streams are always normalized and downsampled before being sent through MCP. Intervals.icu records at up to 1Hz, so a one-hour run can have 3,000+ raw samples per stream — far too much to return through an MCP tool response. getRunStreams fetches the full raw dataset server-side, converts it into one array of per-instant points in our own shape (deriving pace from the velocity_smooth/speed stream, since Intervals.icu has no dedicated pace stream), and — only if the activity has more than maxPoints samples — reduces it to exactly maxPoints points using deterministic "nearest index" bucket sampling (src/lib/running/downsample.ts). This always keeps the first and last recorded instant and evenly spans the rest, so overall shape and short efforts are preserved reasonably well without any randomness. Activities with <= maxPoints samples are returned in full, unsampled. The response also reports originalPointCount, returnedPointCount, and the effective samplingIntervalSeconds so a client knows how much detail survived.
Speed-to-pace conversion (paceSecondsPerKmFromSpeed in src/lib/running/pace.ts) guards against zero/negative/non-finite speed, near-stationary samples (pauses/GPS drift while stopped), and unrealistically high speed (GPS spikes) — all of those return null pace for that point rather than a nonsensical value.
Example prompts:
"How did my heart rate develop during my latest interval session?"
"Did I fade during the final intervals?"
get_wellness
Fetches daily wellness/recovery data (GET /athlete/{id}/wellness.json) for a date range and returns it newest-first, along with the most recent value of each sparse metric. Wellness is athlete-level, per calendar day — it is never attached to a specific activity.
days (optional integer, 7–365, default 30) — how many days back to fetch.
Response shape:
{
"daysRequested": 30,
"entriesReturned": 15,
"latest": { "date": "...", "restingHeartRate": 56, "hrv": 49, "sleepSeconds": 24300, "sleepScore": 49, "sleepQuality": 4, "weightKg": null, "vo2Max": null, "fitnessCtl": 4.66, "fatigueAtl": 14.26 },
"latestNonNull": {
"restingHeartRate": { "value": 56, "date": "..." },
"hrv": { "value": 49, "date": "..." },
"sleepSeconds": { "value": 24300, "date": "..." },
"sleepScore": { "value": 49, "date": "..." },
"sleepQuality": { "value": 4, "date": "..." },
"weightKg": { "value": 88, "date": "..." },
"vo2Max": { "value": 47, "date": "..." }
},
"days": [ /* newest first, one entry per day Intervals.icu has data for */ ]
}entriesReturned is often smaller than daysRequested: Intervals.icu only returns a row for days it actually has some wellness data for, and most individual fields are sparse on top of that (they depend entirely on what Garmin happened to sync that day). latest is the most recent day exactly as recorded (its fields may be null); latestNonNull separately answers "what was the last known value of X", which is almost always the more useful question for a sparse metric like VO2 max or weight — e.g. the newest day might have vo2Max: null while latestNonNull.vo2Max correctly points to an actual value from a few days earlier.
Fields, units, and provenance (all confirmed via live read-only discovery against a real account — nothing here is guessed or invented):
Field | Unit | Notes |
| beats per minute | Raw field |
| milliseconds | Raw field |
| seconds | Raw field |
| ~0–100 | Raw field |
| provider-defined numeric value | Raw field |
| kilograms | Raw field |
| ml/kg/min | Raw field |
| unitless (CTL) | Raw field |
| unitless (ATL) | Raw field |
Fields Intervals.icu's wellness schema exposes but that were confirmed always null for a Garmin-only account (and are therefore not modeled): readiness, fatigue (the subjective self-report, distinct from atl/fatigueAtl), hrvSDNN, mood, motivation, soreness, stress, spO2, respiration, bodyFat, hydration, baevskySI, bloodGlucose, menstrualPhase, avgSleepingHR, kcalConsumed. If a future Garmin device or data source starts populating any of these, they can be added the same way.
VO2 max is never attached to individual runs — it only appears here, in get_wellness.
Example prompts:
"Show my latest VO2 max."
"How has my VO2 max changed over the last month?"
"How is my recovery looking?"
"Compare my recent sleep and HRV with my running load."
get_running_progress
Analyzes running progress and trends over time: weekly volume, aggregate pace, heart rate, training load, VO2 max, and a recent-vs-previous period comparison. This tool is descriptive, not predictive — it returns numbers derived directly from your activity list and wellness data; it does not calculate VO2 max itself, does not estimate lactate threshold or cardiac drift, does not claim physiological training zones, does not compute a proprietary "fitness score", and does not predict race times. Interpreting what the numbers mean is left to the AI client.
It is also deliberately cheap: it reuses the same activity-list fetch as get_recent_runs (via a shared getRunningActivitiesInRange helper) and the same wellness fetch as get_wellness — exactly one activities request and one wellness request per call, never a per-run detail or stream fetch.
days (optional integer, 14–365, default 90) — overall analysis window.
comparisonDays (optional integer, 7–56, default 14) — length of the recent/previous comparison windows.
recentPeriodis the lastcomparisonDayscalendar days;previousPeriodis thecomparisonDaysdays immediately before that.
Response shape:
{
"daysRequested": 90,
"comparisonDays": 14,
"period": { "startDate": "...", "endDate": "...", "runCount": 6, "distanceKm": 47.25, "movingTimeSeconds": 16935, "trainingLoad": 297, "averagePaceSecondsPerKm": 358.4, "averagePace": "5:58/km", "averageHeartRate": 152.0 },
"recentPeriod": { "...": "same shape, last 14 days" },
"previousPeriod": { "...": "same shape, the 14 days before that" },
"comparison": {
"distanceChangeKm": 28.3,
"distanceChangePercent": 300.0,
"runCountChange": 4,
"movingTimeChangeSeconds": 10563,
"trainingLoadChange": 163,
"paceChangeSecondsPerKm": 26.6,
"averageHeartRateChange": -9.8
},
"weekly": [ { "weekStart": "2026-09-07", "runCount": 3, "distanceKm": 22.5, "movingTimeSeconds": 8389, "trainingLoad": 136, "averagePaceSecondsPerKm": 372.7, "averagePace": "6:13/km", "averageHeartRate": 148.8 } ],
"paceByAverageHeartRateBand": [ { "minHeartRate": 150, "maxHeartRate": 154, "runCount": 2, "distanceKm": 15.7, "averagePaceSecondsPerKm": 360.1, "averagePace": "6:00/km" } ],
"vo2MaxTrend": { "latest": { "value": 47, "date": "2026-09-13" }, "earliest": { "value": 46, "date": "2026-08-31" }, "change": 1, "observations": [ { "date": "2026-08-31", "value": 46 } ] },
"dataQuality": { "enoughRunsForComparison": false, "recentRunCount": 5, "previousRunCount": 1, "notes": ["Trend confidence is limited: ..."] }
}Aggregation rules:
Aggregate pace (
period/recentPeriod/previousPeriod/weekly/HR bands) is alwaystotal moving time ÷ total distancefor the group — never an average of each run's individual pace — so longer runs correctly count more. BothaveragePaceSecondsPerKmand a formattedaveragePaceare returned.Average heart rate is weighted by moving time across only the runs that have a valid average HR; runs missing HR are excluded entirely, never treated as
0. It'snullif no run in the group has HR data.Training load is the sum of each run's
trainingLoad; a run missing it simply doesn't contribute to the sum.weeklybuckets runs Monday-to-Sunday (Monday is the week start), sorted oldest → newest. Only weeks that actually contain at least one run are included — empty weeks are deliberately not manufactured, to keep the response focused on real training signal rather than padding.paceByAverageHeartRateBandgroups runs into deterministic 5 bpm bands (e.g.145–149,150–154) based on each run's own recorded average heart rate — this is a descriptive grouping, not a physiological training zone, and it does not claim to measure cardiac drift or prove aerobic fitness by itself. It exists so a client can compare pace across runs performed at roughly similar average cardiovascular load (e.g. "am I running faster now at ~150 bpm than I was two months ago?"). Runs without a valid average HR are excluded.comparison.paceChangeSecondsPerKmfollows the convention negative = recent pace is faster, positive = recent pace is slower (e.g.-12means the recent aggregate pace is 12 sec/km faster than the previous period). All other*Change*fields arerecentPeriod − previousPeriod.distanceChangePercentisnullwheneverpreviousPeriod.distanceKmis0(never a divide-by-zero orInfinity).vo2MaxTrendis built entirely fromget_wellness'svo2Maxfield — never calculated by this project. Null wellness days are ignored, never interpolated;observationslists every non-null value in the period, oldest first.dataQuality.enoughRunsForComparisonistrueonly when bothrecentPeriodandpreviousPeriodhave at least 2 runs. Whenfalse,notesincludes a plain-text caveat that trend confidence is limited — the numbers are still returned as computed, this project just doesn't draw a coaching conclusion from thin data. That's left to the AI client.
Example prompts:
"Am I getting faster?"
"How has my running changed over the last two months?"
"Compare my last two weeks with the two weeks before."
"Is my pace improving at similar heart rates?"
"How has my VO2 max changed?"
"Am I progressing toward my 10K goal?" (the tool returns descriptive trend data only — it does not predict race times)
get_calendar
Fetches recent and upcoming calendar events / planned workouts (GET /athlete/{id}/events, confirmed via live read-only discovery against a real account) for an athlete-local date window, normalizes and classifies each event, and surfaces the next planned running workout for convenience. Strictly read-only — this tool never creates, updates, or deletes anything in Intervals.icu.
daysBefore (optional integer, 0–90, default 7) — how many days before today to include.
daysAfter (optional integer, 1–180, default 21) — how many days after today to include.
The date window is computed in ATHLETE_TIME_ZONE (see Environment variables) via the same todayDateOnly/addDaysToDateOnly helpers used by get_recent_runs/get_wellness/get_running_progress — never the server's own UTC date.
Response shape:
{
"startDate": "2026-09-07",
"endDate": "2026-10-14",
"eventsReturned": 1,
"plannedWorkoutCount": 1,
"nextPlannedWorkout": null,
"events": [
{
"id": "133599091",
"date": "2026-09-05T00:00:00",
"name": "6 × 1 min intervals",
"category": "WORKOUT",
"sportType": "Run",
"eventType": "planned_running_workout",
"isPlannedWorkout": true,
"isCompleted": true,
"completedActivityId": "i183474786",
"plannedDurationSeconds": 2400,
"plannedDistanceMeters": 0,
"description": "2km lugnt. 6x1min tryck / 1min jogg / 15+10 lugnt",
"workout": {
"structureAvailable": false,
"stepCount": 0,
"description": "2km lugnt. 6x1min tryck / 1min jogg / 15+10 lugnt"
}
}
]
}(The example above is real, live output from this project's own Milestone 3D smoke test — this account's only calendar event so far is a past, already-completed, free-text-only planned workout.)
Event classification (eventType) is deliberately conservative, based only on raw signals actually confirmed via live discovery — see the doc comments in src/lib/intervals/calendarEventNormalizers.ts for the exact reasoning:
"planned_running_workout"—category === "WORKOUT"and the sport is a running type (Run/TrailRun/VirtualRun)."planned_workout_other_sport"—category === "WORKOUT"but a non-running sport (e.g.Ride)."note"— the realshow_as_noteflag istrue."other"— anything else (including anycategoryvalue other than"WORKOUT", none of which this account has ever produced live). The rawcategorystring is always passed through unmodified onCalendarEvent.categoryregardless, so a client can still see it even wheneventTypedoesn't have a confident opinion about it.
Completion / linking: isCompleted and completedActivityId are derived from Intervals.icu's real paired_activity_id field (confirmed live) — never inferred from date alone. A planned workout that's already happened and been matched to a real activity is isCompleted: true.
Workout structure: kept deliberately conservative. workout is { structureAvailable, stepCount, description } — structureAvailable is true only when the raw workout_doc.steps array is genuinely non-empty, stepCount is that array's length, and description is the plan's free text (workout_doc.description, falling back to the event's own description). Structured workout step interpretation is intentionally deferred until a real populated Intervals workout_doc.steps response has been inspected — no per-step fields (duration, distance, target pace/HR/power, repetitions, ...) are parsed or exposed yet, and free text is never parsed into fabricated steps. This account's only real event had workout_doc.steps: [] (a free-text-only plan), so there has never been a populated example to normalize against; once one exists, this response can be safely extended without a breaking change (structureAvailable/stepCount will simply start reflecting real data).
nextPlannedWorkout is the earliest today-or-future "planned_running_workout" that isn't already completed — null if none qualifies. It deliberately never falls back to a non-running planned workout or a random event.
Example prompts:
"What do I have planned this week?"
"What is my next running workout?"
"Show me Tuesday's workout."
"Do I have a quality session planned in the next few days?"
"Compare my upcoming training with my recent recovery."
Write tools
create_running_workout, update_running_workout, and delete_running_workout modify the athlete's Intervals.icu calendar. Unlike the six read tools above, these have real side effects — see Safety model for the write tools for exactly when an AI client should (and shouldn't) call them, and Writing workouts: why text, not workout_doc for the architectural decision behind how they write.
All three share the same V1 workout model (deliberately narrow, per this milestone's spec): a running workout made of an optional warmup, repetitions × (a work interval + an optional recovery interval), and an optional cooldown — all steps time-based, with an optional absolute pace range target on the work interval only. Not yet supported: distance-based steps, HR targets, power targets, pace zones, threshold percentages, ramps, cadence, nested structures, multisport, or cycling workouts. The full validated shape (RunningWorkoutInput, src/lib/running/workoutInput.ts) is:
Field | Type | Validation |
| string | Required. |
| string | Required, 1-200 characters. |
| integer | 0-10800 (3h). Default |
| integer | Required, 1-30. |
| integer | Required, > 0, max 3600 (1h). |
| integer | 0-3600. Default |
|
| Optional. Each bound an integer 120-600 (≈2:00/km-10:00/km); |
| integer | 0-10800 (3h). Default |
| string | Optional, max 1000 characters. Accepted but currently NOT included in the generated workout text — see the note below. |
This schema (with per-field descriptions) is the single source of truth: it's used both for domain-layer validation and, reused as-is, as the MCP inputSchema for create_running_workout/update_running_workout — the limits an AI client sees always exactly match the limits actually enforced.
create_running_workout
Creates a new planned running workout (POST /athlete/{id}/events) from a RunningWorkoutInput. Builds the Intervals.icu workout-builder text via generateWorkoutText, sends a minimal event payload, and validates the response before returning it:
{
"category": "WORKOUT",
"type": "Run",
"start_date_local": "2026-09-16T00:00:00",
"name": "6 x 90s intervals",
"description": "Warmup\n- 12m intensity=warmup\n\n6x\n- 90s 4:35/km-4:45/km Pace intensity=active\n- 90s intensity=rest\n\nCooldown\n- 12m intensity=cooldown"
}No workout_doc, computed distance, computed duration, or computed training load is ever sent — Intervals.icu derives all of that itself from description. After the POST, the response is normalized (via the same normalizeCalendarEvent get_calendar uses) and checked: the returned event's category must be "WORKOUT", its sport must be "Run", and its date/name must match what was requested — any mismatch raises a clear error rather than silently returning something unexpected. No extra GET is performed.
Example prompt: "Create my 6 × 90 second interval workout for Tuesday."
update_running_workout
Replaces an existing planned running workout (PUT /athlete/{id}/events/{eventId}) — same fields as create_running_workout, plus a required eventId. This is a complete replacement, not a partial patch: the full workout must be re-specified every time.
Before writing, it fetches the existing event (GET /athlete/{id}/events/{eventId}) and refuses to proceed unless it is a not-yet-completed planned running workout:
rejects if
category !== "WORKOUT",rejects if the sport isn't a running type,
rejects if the event is already completed/linked to a real activity (
paired_activity_idis set) — checked reliably, not on a best-effort basis: the single-event endpoint (GET /athlete/{id}/events/{eventId}) was confirmed (Milestone 3D) to sometimes omitpaired_activity_identirely, so this guard never trusts that response for pairing. Instead, it re-fetches the event's own calendar day from the LIST endpoint (GET /athlete/{id}/events?oldest=<date>&newest=<date>, confirmed to reliably includepaired_activity_id), finds the matching event by numeric id, and inspectspaired_activity_idon that object. If the event can't be found again in that list response, the update fails closed (refused) rather than proceeding without a reliable pairing check.
The PUT payload is always built fresh from the new input only — never from the fetched event — so no stale calculated field (duration, distance, training load, the old workout_doc, ...) is ever echoed back.
Example prompt: "Move Tuesday's workout to Wednesday and make it 8 reps instead of 6."
delete_running_workout
Permanently deletes a planned running workout (DELETE /athlete/{id}/events/{eventId}) — takes only eventId. Applies the exact same fetch-first safety checks as update_running_workout (must be a not-yet-completed planned running workout, verified reliably via the events-list re-check described above) before deleting. Handles a 204 No Content response correctly (no body to parse) and returns a compact confirmation rather than a full event:
{ "deleted": true, "eventId": "133599091", "name": "6 x 90s intervals", "date": "2026-09-16" }Example prompt: "Delete workout 133599091."
Safety model for the write tools
These tools have real side effects, so their MCP tool descriptions are explicit about it and about when calling them is authorized:
create_running_workoutshould only be called when the user has explicitly asked to schedule/create/add a workout to their calendar (e.g. "Create Tuesday's workout in my calendar"). A request like "What should I run Tuesday?" is a request for coaching advice, not authorization to write to the calendar — the tool description tells the calling model this directly.update_running_workoutshould only be called when the user has explicitly asked to change/move/modify a specific scheduled workout.delete_running_workoutshould only be called when the user has explicitly asked to delete/remove/cancel a specific scheduled workout (e.g. "Delete workout 123" is itself sufficient authorization).
There is deliberately no artificial confirm: true parameter — that would just be another field the calling LLM fills in itself, adding a step without adding real safety. The real safeguards are: (1) tool descriptions that clearly instruct the calling model about when a write is authorized, (2) the fetch-before-write validation in update_running_workout/delete_running_workout (never blindly trusts an eventId), and (3) this milestone's own manual-write policy — see No live write test below.
Writing workouts: why text, not workout_doc
This project never constructs or sends workout_doc directly, for any of the three write tools. Instead, description is populated with Intervals.icu's own native workout-builder text syntax (src/lib/running/workoutText.ts's generateWorkoutText), and Intervals.icu's server parses that text into workout_doc itself. This is deliberate, not incidental:
It's the officially supported way to create/update structured workouts via the Intervals.icu API (confirmed via the API's own documentation and forum guidance — direct
workout_docsubmission is explicitly not supported for creating a workout from scratch).Current Intervals.icu guidance recommends workout-builder text for API-created events.
Direct
workout_docwrites have been reported to cause problems with Garmin/device export — writing text and letting Intervals.icu compile it avoids that entire class of bug.
Syntax specifics confirmed for V1 (not guessed):
Durations:
m= minutes,s= seconds (e.g.12m,90s,5m30s). Compact formatting: an exact multiple of 60 seconds becomesNm(720→"12m"); a duration under 120 seconds that isn't a whole number of minutes becomesNs(90→"90s"); anything else becomesAmBs(330→"5m30s").An absolute pace range target repeats the unit on both sides with a required trailing
Paceword:275/285(seconds/km) →"4:35/km-4:45/km Pace". A bare4:35/kmwith noPaceword is silently dropped by Intervals.icu's own parser — confirmed via its workout-builder syntax documentation.intensity=accepts exactlywarmup,active,rest,cooldown— there is nointerval/recoveryvalue. Work intervals are taggedintensity=active; recovery steps are taggedintensity=rest(the tag that actually exports as a real rest step on-device).Repeats use a bare
Nxline (no section title required) directly before the repeated steps.
Example — a 6 × 90s interval session with a 12-minute warmup/cooldown and a 4:35-4:45/km pace target on the work interval — generated exactly as sent in description:
Warmup
- 12m intensity=warmup
6x
- 90s 4:35/km-4:45/km Pace intensity=active
- 90s intensity=rest
Cooldown
- 12m intensity=cooldownnotes (an optional free-text field on RunningWorkoutInput) is accepted for forward compatibility but deliberately not yet included in the generated text — arbitrary free text risks interfering with the workout-builder parser's own rules (e.g. text placement affects device step-cue text), and there's no confirmed-safe placement syntax to rely on without live-testing it, which this milestone explicitly does not do. notes can be safely wired in once that's verified.
Quality checks
npm run lint # ESLint
npx tsc --noEmit # TypeScript type checking
npm run test # unit tests (vitest)
npm run build # production buildMilestone status
Milestone 1 was strictly read-only with no auth. Milestone 2B added WorkOS OAuth protection in front of the same read-only tool. Milestone 3A added two more read-only tools — get_run_details and get_run_streams — for per-activity analysis. Milestone 3B added get_wellness for daily recovery/physiological data (including VO2 max). Milestone 3C added get_running_progress for descriptive weekly-volume/pace/HR/training-load/VO2 max trend analysis and recent-vs-previous period comparisons. Milestone 3D added get_calendar for read-only calendar events / planned workouts, including the next planned running workout. Milestone 3E (this milestone) adds this project's first write capability: create_running_workout, update_running_workout, and delete_running_workout — see Write tools for the full behavior and safeguards. All nine tools inherit the same OAuth protection unchanged. Still no database and no automatic coaching logic. VO2 max is never calculated by this project — it's read verbatim from Intervals.icu's vo2max wellness field, which itself comes from Garmin. get_running_progress never predicts race times, estimates lactate threshold, calculates cardiac drift, claims training zones, or computes a proprietary fitness score. The write tools never construct workout_doc themselves — see Writing workouts: why text, not workout_doc.
No live write test during this milestone
Per this milestone's explicit policy, no real POST/PUT/DELETE request was made against the developer's real Intervals.icu account while building this feature — only mocked unit tests and read-only GET verification calls. The first live write is a deliberate manual step the developer performs after: code review → commit → deploy → MCP client rescans the tool list → the developer explicitly asks for a real write through Running Coach in their AI client.
Future public app note
This project is currently single-user, authenticated with one personal Intervals.icu API key (INTERVALS_API_KEY) shared by both read and write tools. A future multi-user version of this app would need real Intervals.icu OAuth (not a shared personal API key) so each user authorizes their own account — and that OAuth grant would need to explicitly include calendar write permission (CALENDAR:WRITE) for the three write tools to keep working per-user. Intervals.icu OAuth is explicitly out of scope for this milestone — this note exists only to flag the gap for whenever a multi-user version is actually planned.
Future milestones will build on this foundation to add:
Deeper training analytics (e.g. properly-designed performance/race-time modeling, if ever added, would be its own carefully-scoped milestone — not part of this one)
A broader workout model (distance-based steps, HR/power targets, pace zones, ramps, cadence, multisport) once there's real demand and each addition can be verified against confirmed workout-builder syntax the same way Milestone 3E's narrow V1 was.
Real Intervals.icu OAuth for a genuinely multi-user deployment (see the note above) — including
CALENDAR:WRITEscope for the write tools.
Notes / assumptions
Never commit
.env.localor any file containing a real API key, WorkOS domain secret, or your real WorkOS user id (.env*is git-ignored;.env.exampleis explicitly un-ignored so the template can be committed)./api/healthis intentionally public and never requires OAuth; it only reports whether config exists, never secret values.
Design assumptions (Milestone 3A)
Intervals.icu's raw
pace,gap, andaverage_speedfields (activity- and interval-level) are all meters/second-scaled, not seconds/km — confirmed against real data. We never treat them as pace directly; pace is always computed from distance ÷ moving time, andgapis separately converted from its raw speed value.A stream sample is only converted to a pace when its speed is between 0.3 m/s (~55 min/km — below this we assume "stopped/paused") and 8.5 m/s (~2:00/km — above this we assume a GPS/sensor spike). Outside that range,
paceisnullfor that point rather than a nonsensical value.getRunStreams's point count is driven by whichever available stream is longest (not strictly thetimestream), so a missingtimestream doesn't discard other available data.A 404 from Intervals.icu's
/activity/{id}/intervalsendpoint is treated as "no intervals detected" (empty array) rather than an error, since not every activity has analyzed intervals.Downsampling (
src/lib/running/downsample.ts) uses simple deterministic "nearest index" bucket sampling, as explicitly permitted for this milestone — not a min/max-preserving or interval-aware algorithm.
Design assumptions (Milestone 3B)
VO2 max (
vo2max) was confirmed, via live read-only discovery against a real account, to exist only in Intervals.icu's wellness data (/athlete/{id}/wellness.json) — it is not present in activity list or activity detail responses, at all. That's why it lives exclusively onget_wellnessand is never attached toget_recent_runs/get_run_details.weightKgis assumed to already be in kilograms because the athlete's profile hasweight_pref_lb: false(metric preference); this project does not perform any unit conversion on it.entriesReturnedcan be well belowdaysRequestedbecause Intervals.icu only returns a wellness row for a day at all if it has some data for that day — sparsity happens at both the day level and the individual-field level.
Design assumptions (Milestone 3C)
get_running_progressmakes exactly one activities request and one wellness request, both via the existinggetRunningActivitiesInRange(extracted from, and still used by,get_recent_runs) andgetWellnessdomain functions — no new upstream endpoints, no per-run detail/stream fetches (no N+1).recentPeriod+previousPeriodtogether can span up to2 × comparisonDaysdays, which may exceed the requesteddayswindow for smalldays+ largecomparisonDayscombinations (e.g.days=14,comparisonDays=56). In that case the tool transparently widens its single upstream fetch tomax(days, 2 × comparisonDays)days so the comparison is never silently truncated; the top-levelperiodobject still only aggregates over the originally requesteddays.Weekly buckets and
paceByAverageHeartRateBandare scoped to thedaysRequestedwindow, not the (possibly wider) internal fetch window.weeklydoes not manufacture zero-run weeks for gaps in training history — only weeks that actually contain at least one run appear. This was a deliberate simplicity/compactness choice (see the milestone's own design note) and is covered by a dedicated test.All aggregation math (pace/HR weighting, weekly bucketing, HR banding, VO2 trend, recent-vs-previous comparison, data-sufficiency) lives in pure, independently unit-tested functions in
src/lib/running/progressAggregation.ts—src/lib/intervals/progress.tsitself only handles fetching and date-window slicing."Today" is computed in the athlete's local timezone (
ATHLETE_TIME_ZONE,src/lib/running/athleteTimeZone.ts), never via a plainnew Date().toISOString()UTC conversion. A naive UTC conversion reports the wrong calendar date for part of every day in any non-UTC timezone (e.g. shortly after local midnight in a positive-UTC-offset zone likeEurope/Stockholm, or shortly before local midnight in a negative-UTC-offset zone) — which would silently shiftget_recent_runs/get_wellness/get_running_progress's date windows by a day.todayDateOnly(timeZone, date)(src/lib/running/dates.ts) resolves this viaIntl.DateTimeFormat; all other date-only arithmetic in this project (addDaysToDateOnly, weekly bucketing's Monday calculation) is pure UTC-component math on already-resolved"YYYY-MM-DD"strings, so it can never reintroduce this class of bug.
Design assumptions (Milestone 3D)
The calendar/planned-workout endpoint was found via live discovery, not assumed:
GET /api/v1/athlete/{id}/events?oldest=YYYY-MM-DD&newest=YYYY-MM-DD(same base client, same auth as every other endpoint). A/calendarpath was tried first and returned404—/eventsis the correct one. A single-event detail endpoint also exists (GET /athlete/{id}/events/{id}), but notably its response omitspaired_activity_id, which the list endpoint includes — sogetCalendaronly ever uses the list endpoint.This account had exactly one real calendar event across a 2-year discovery window (1 year back to 1 year forward): a past, already-completed, free-text-only planned running workout.
category="WORKOUT"and the confirmedshow_as_noteboolean are the only classification signals with real evidence behind them; queryingcategory=NOTEandcategory=RACE_Aboth returned200 [](accepted by the API, but with zero real examples to confirm what they actually look like for this account). Rather than hardcode meaning for category values never observed,classifyEventType(src/lib/intervals/calendarEventNormalizers.ts) only derives"planned_running_workout"/"planned_workout_other_sport"/"note"from confirmed signals and falls back to a neutral"other"— while still always passing the rawcategorystring through unmodified.Completion linking is real, not inferred: the one real event's
paired_activity_id("i183474786") pointed at an actual completed activity with a matching name — confirming Intervals.icu really does link planned events to completed activities this way.isCompleted/completedActivityIdare derived from that field only; a past event with nopaired_activity_idis stillisCompleted: false(never inferred from its date being in the past).plannedDurationSeconds/plannedDistanceMeters(rawmoving_time/distance) have an observed dual nature: on the one real (completed, paired) event, these matched the linked activity's actual moving time — so for an already-completed event they may reflect what happened rather than a pre-workout target. This account has no genuinely future, not-yet-completed event to confirm the field's meaning before completion; documented here rather than asserted as certain.Structured workout step interpretation is intentionally deferred until a real populated Intervals
workout_doc.stepsresponse has been inspected. This account's only real event hadworkout_doc.steps: [](a free-text-only plan), and neither its calendar nor its workout library folder (GET /athlete/{id}/folders, confirmed to exist but empty) contained a single populated example. Rather than ship speculative per-step parsing built on an unconfirmed schema,CalendarEventWorkoutonly reportsstructureAvailable(isstepsnon-empty?),stepCount(steps.length), anddescription(free text) — no per-step fields (duration, distance, target pace/HR/power, repetitions, nested steps, ...) are modeled or exposed at all. This is a safe, additive gap: once a real populated example exists, per-step fields can be added to the response without a breaking change.nextPlannedWorkoutonly ever considers"planned_running_workout"events, per the milestone's explicit spec — a planned strength/cycling workout never becomesnextPlannedWorkout, even if it's the only planned workout on the calendar (plannedWorkoutCountstill counts it, just not as "next").Calendar event ids are plain numbers in Intervals.icu's raw schema (e.g.
133599091), unlike activity ids ("i186254951");CalendarEvent.idstringifies them for consistency with how every other tool in this project exposes ids.
Design assumptions (Milestone 3E)
Workout-builder text syntax was confirmed via Intervals.icu's own community-maintained syntax references (the forum "Workout Builder Syntax Quick Guide", cross-checked against the independently-maintained
intervals-icu-workout-parserspec) rather than live-tested against a real account — this milestone's explicit policy prohibits any realPOST/PUT/DELETEwrite during development. Two syntax details are worth calling out because they deliberately diverge from this milestone's own illustrative example text:intensity=only acceptswarmup/active/rest/cooldown(there is nointerval/recoveryvalue, so work steps useintensity=activeand recovery steps useintensity=rest), and an absolute pace range repeats the distance unit on both sides of the dash with a required trailingPaceword ("4:35/km-4:45/km Pace", not"4:35-4:45/km Pace") — both confirmed by the same syntax references. If Intervals.icu's parser turns out to be more lenient than documented once a real write is finally tested, this is the first place to revisit.The single-event detail endpoint's
paired_activity_idomission (discovered in Milestone 3D) directly shapesupdate_running_workout/delete_running_workout's safety check — and was hardened after an initial best-effort version. Both tools first fetch the target event viaGET /athlete/{id}/events/{eventId}, but never trust that response for pairing. Instead, they extract the event's athlete-local date fromstart_date_local, re-fetch that exact single day via the LIST endpoint (GET /athlete/{id}/events?oldest=<date>&newest=<date>, confirmed to reliably includepaired_activity_id), find the matching event by numeric id, and checkpaired_activity_idon that object. If the event can't be found again in that list response (e.g. deleted concurrently, or a date parsing edge case), the action fails closed — refused, never silently allowed through. This is implemented as one shared helper (assertNotPairedViaListEndpointinsrc/lib/intervals/workouts.ts) used by bothupdateRunningWorkoutanddeleteRunningWorkout.createRunningWorkout/updateRunningWorkoutvalidate the API's response before trusting it, rather than only checking the HTTP status: the returned event'scategorymust be"WORKOUT", its sport must be"Run", and its date/name must match what was requested. Any mismatch (or a response that isn't even a plausible event object) raises a clearIntervalsApiErrorrather than silently normalizing and returning something unexpected.The
PUTpayload is always rebuilt from scratch from the new input, never from the event fetched for the safety check — this is what guaranteesupdate_running_workoutcan't accidentally echo back stale calculated fields (old duration, distance, training load, orworkout_doc) alongside the new text.RunningWorkoutInput's validation schema (src/lib/running/workoutInput.ts) is reused, unmodified, as the MCPinputSchemaforcreate_running_workout/update_running_workout(via.extend({ eventId })for update) — a deliberate single-source-of-truth choice so the bounds an AI client sees in the tool's JSON schema can never silently drift from the bounds actually enforced server-side.notesis accepted but not yet wired into the generated text, per the milestone's explicit "defer rather than guess" instruction — see Writing workouts: why text, notworkout_docfor why.Pace target sanity bounds (120-600 seconds/km, i.e. ~2:00/km-10:00/km) are deliberately narrower than
src/lib/running/pace.ts's existing stream-analysis bounds (0.3-8.5 m/s, i.e. roughly 2:00/km-55:00/km) — those exist to distinguish "a real recorded pace sample" from "GPS noise/a paused watch" in already-happened data, which is a very different question from "is this a plausible target pace to assign to a future work interval." Reusing the wider stream bounds here would let a validation call through for e.g.9:00/kmon a "work" interval — technically a real running pace, but never something a structured interval session would target.No live write test was performed at any point during this milestone (see No live write test during this milestone) — all 3E behavior is verified exclusively via mocked unit tests against
intervalsPost/intervalsPut/intervalsDelete, never a real Intervals.icu request.Read-only discovery against real Run sport settings (
GET /athlete/{id}/sport-settings/Run) foundthreshold_pace: nullandpace_zones: nullfor this account (pace_unitsis"MINS_KM"). This is exactly why V1 only supports an absolute pace range target (4:35/km-4:45/km Pace) rather than percent-of-threshold (X% pace) or pace-zone (ZX pace) targets — those would silently fail to resolve to a meaningful value with no threshold pace configured.threshold_paceshould be set in Intervals.icu's Run sport settings before relying on any future threshold-relative pace target.
This server cannot be deployed
Maintenance
Related MCP Connectors
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
- Coach MCPOAuthai.iamcoach
Your endurance training data in your AI assistant: activities, recovery, plan, workout edits.
AI running coach. Reads Strava, Suunto, Polar, Apple Health or Health Connect. Adapts to your feel.
Related MCP Servers
- AlicenseAqualityCmaintenanceConnects Claude with the Intervals.icu API to retrieve fitness data including activities, workouts, wellness metrics, and training events.10362GPL 3.0
- FlicenseAqualityDmaintenanceEnables AI assistants to interact with Intervals.icu fitness tracking and wellness data, allowing users to fetch, filter, and group activities or health metrics. It provides structured summaries of workouts and physical well-being through natural language queries.4-
- FlicenseAqualityDmaintenanceEnables AI assistants to access and analyze intervals.icu training data including activities, fitness metrics (CTL/ATL/TSB), wellness stats, and calendar events. Supports natural language querying of athletic performance for training insights and workout planning.6-
- AlicenseAqualityDmaintenanceEnables interaction with Intervals.icu data, including activities, wellness, and calendar events, via natural language.1024 npm1MIT