API

Base URL https://watchout.run — all endpoints are GET, unauthenticated, and deterministic: the same parameters always produce the same bytes and the same workout UUID, so re-importing never duplicates on the watch. Machine-readable spec: /openapi.json.

Workout parameters

Shared by /gen, /w and /ics. Pace is always min:sec per km.

paramexamplemeaning
nameThreshold 3x2kmWorkout name
d260811Date YYMMDD, prefixed to title and filename (makes it searchable on the watch)
wu15@5:45Warmup goal, optional: 15@5:45 (minutes @ pace), 2000m@5:45 (distance), open (no target), or none to skip it. HR range in place of pace: 12@h120-140. Omit the param for a default warmup.
cd10@6:00Cooldown goal, optional: same forms — 10@6:00 · 1500m@6:00 · open · none
b3x2000@4:25-4:20,r120Interval block, repeatable. repsxsteps, steps comma-separated. Work: 2000@4:25-4:20 (pace range) · 400@3:55 (single pace) · 2000@h140-155 (HR range bpm) · t30/t300@4:25 (timed, seconds) · 2000 (no alert) · open (until you tap) · open~4000 (open, expected ~4 km — display and totals only, the step still ends on a tap). Rest (r-prefix): r120 (seconds) · r400m@6:00 (distance) · ropen. Any step takes an optional @pace or @h<lo>-<hi>
uid9B2F…Workout UUID override; omitted = derived deterministically from the parameters

GET /gen

Returns the .workout file (Apple WorkoutKit protobuf, application/octet-stream). On iPhone: download → open in the Fitness app → “Add to Apple Watch”.

/gen?name=Threshold+3x2km&d=260811&wu=15@5:45&cd=10@6:00&b=3x2000@4:25-4:20,r120

GET /fit

Same parameters (minus uid), returns a Garmin FIT workout file with the identical step structure — speed ranges in mm/s, heart-rate ranges in bpm. Delivery: Garmin Connect cannot import workout files, so upload it as a planned workout on intervals.icu (free; auto-syncs to the watch once Garmin is linked) or copy it to the watch's GARMIN/NewFiles folder over USB.

/fit?name=Threshold+3x2km&d=260811&wu=15@5:45&cd=10@6:00&b=3x2000@4:25-4:20,r120

GET /icu

The same spec as an intervals.icu planned-workout event (JSON): workout_text in intervals.icu's own builder syntax plus a ready event body. POST it as a one-element array to https://intervals.icu/api/v1/athlete/0/events/bulk?upsert=true (HTTP Basic, username API_KEY, password = your intervals.icu API key) and it appears on your calendar and syncs to a linked Garmin as a native structured workout — this is what the workout page's Send to Garmin button does, with the key kept in your browser. upsert=true matches on external_id (the deterministic workout UUID), so re-sending the same session updates the event instead of duplicating. Optional lthr=<bpm> (your threshold HR) converts h<lo>-<hi> bpm steps to the % LTHR ranges intervals.icu requires; open steps become 1s press lap (the watch waits for the lap button).

/icu?name=Threshold+3x2km&d=260811&wu=15@5:45&cd=10@6:00&b=3x2000@4:25-4:20,r120

GET /route.gpx

?name=…&p=<encoded polyline> (precision 5, e.g. Strava's map.summary_polyline) returns a GPX course. Pass the same polyline as route= on /w or /ics and the workout page renders the route shape with the GPX link (the calendar event's page link carries it). Garmin Connect imports GPX as a Course (followable on the watch); Apple users open it in their maps/running app.

GET /w

Human-friendly workout page with “Add to Apple Watch”, “Send to Garmin watch” (via intervals.icu, one-time API-key setup kept in the browser) and “Add to Calendar” buttons — the link to share. Extra optional parameters:

paramexamplemeaning
dt20260811T1945Start time YYYYMMDDTHHMM, local to tz. Enables the calendar button; d is derived if omitted
tzEurope/LondonIANA timezone for the calendar event (default Europe/Oslo)
e🔥Session-type emoji: 🔥 intervals/threshold · 🟢 easy · 🛣️ long · ⚡ hills/fartlek · 🏁 race
dur75Calendar event duration in minutes (default: the session's estimated time, rounded up to the nearest 30 min)
wx14°C, 3 m/s SW, dryForecast one-liner for the start hour — shown 🌤 on the page and in the calendar event (≤160 chars, display-only)
noteKeep the reps controlledCoach's note to the runner — shown 📝 on the page and in the calendar event (≤500 chars, display-only)
route<polyline5>Encoded route polyline behind a compact reveal: terrain-painted map shape (grade colors, elevations fetched in the browser from Open-Meteo) + GPX course link
rnew1Marks a route the runner doesn't know yet — the map opens expanded instead of collapsed. Display-only
fx2000,2000@3,333,2000@-2Post-run effort coloring — the route map by deviation vs plan, plus a band under the elevation profile: sequential <meters>[@<±s/km> vs plan] segments, one per lap (positive = slower; no @ = no target). Display-only
/w?name=Threshold+3x2km&d=260811&wu=15@5:45&cd=10@6:00&b=3x2000@4:25-4:20,r120&dt=20260811T1945&e=🔥

GET /ics

Calendar event (text/calendar) for the workout; takes the same parameters as /w, dt required. The event's URL field links to the workout page; the description is the runner's briefing — estimated distance/time, the forecast (wx=), the steps one per line, and the coach's note (note=). No links in the description (the URL field carries the page); cal=upsert adds one, since Google Calendar drops the URL property on import. Opening the .ics lets the runner choose which of their calendars the event is added to; the workout page also offers a Google Calendar link. The event's UID is the workout UUID (honours uid=) plus a seq= revision suffix. The event's link IS the workout, and iOS ignores re-imports of a known UID (verified iOS 26) — so a retuned or moved session bumps seq and imports as a new event, marked (rev N) in the title, carrying the tuned link; the runner deletes the superseded event. Re-importing the same revision is deduped. Google Calendar does the opposite and updates a known UID on import: cal=upsert keeps the UID bare across revisions (no rev marker), so there a retune replaces the event in place. The watch file and the intervals.icu event update in place on the same uid throughout.

/ics?name=Threshold+3x2km&d=260811&wu=15@5:45&cd=10@6:00&b=3x2000@4:25-4:20,r120&dt=20260811T1945&dur=60&e=🔥

GET /plan & /plan.ics

A whole training plan as one link. plan= is a stable plan id, each s= is one session's url-encoded parameter string (same params as /ics), in slot order. /plan renders the sessions with an Add-all-to-Calendar button (/plan.ics — one VEVENT per session) and a Send-all-to-Garmin button (one intervals.icu bulk POST from the browser). Session identity is plan id + slot index, never the date: a retuned or rescheduled session keeps its UUID/external_id, so the watch replaces and intervals.icu upserts. Calendar events are revision-scoped (uid + seq inside each s=): iOS ignores re-imports of a known UID, so a tuned or moved session bumps its seq and imports as a new event carrying the tuned link — the runner deletes the superseded event (the new one is marked (rev N)). Unchanged sessions dedupe on re-import, so re-importing a re-emitted plan adds only what changed. For Google Calendar — which updates a known UID on import — add cal=upsert to /plan.ics: UIDs stay bare and a re-import replaces retuned events in place; the plan page offers this file behind its Google Calendar link.

/plan?plan=oslo-hm-2026w29&s=name%3DThreshold%26dt%3D20260714T1800%26b%3D3x2000%404%3A25%2Cr120&s=…

POST /mcp

Stateless MCP server (Streamable HTTP, CORS-enabled, no auth). Add https://mcp.watchout.run as a custom connector in Claude (Settings → Connectors → Add custom connector) or ChatGPT (Settings → Apps & Connectors → Developer mode → Create; paid plans; download the icon for the optional PNG-upload field — 256×256, 2.6 KB) — any MCP client speaking Streamable HTTP works. It works best when the runner's own Strava is connected in the same agent (or their training history lives in the project) — the interview and design guidance read routes and paces from there; this server stores nothing. Tools: make_workout_links (build a workout, get page/file/calendar links), make_plan_links (a whole training plan in one call: plan page, one multi-event .ics, bulk Garmin send, per-slot manifest), show_plan_progress (the plan's at-a-glance dashboard: the agent passes its manifest + what happened, the server computes done/missed, km and adherence, the inline card draws it), read_workout_link (decode any pasted WatchOut link back into session state — links carry the full session, so "refresh my calendar event" needs nothing but the link), now (the server clock, with local wall-time in a given timezone — fresh chats anchor "today" to it), whats_new (changelog since the version that generated a saved plan/workout — material changes come with what-to-do notes; see /changelog), runner_profile_interview (one-time "grill me" setup: mine the runner's Strava history for routes/laps/hills/paces and save a profile in the agent's own memory), workout_design_guide (course-aware design method: lap math, floats, hills), check_session (deterministic session math: lap fit, suggested floats, totals — agents verify before linking), check_route (route ground truth: a polyline in — real distance, elevation gain, per-km grades and the flat stretches fit for reps out; for places the runner doesn't know), get_weather (hourly forecast for the route via MET Norway / yr.no — heat/wind/cold thresholds for pace-vs-HR decisions), tune_session_guide (frictionless pre-run retuning; workout_uuid reuse replaces the session on the watch), adapt_plan_guide (the arc, not the session: missed weeks, illness, travel, a moved race, and the taper table), check_plan (the shape of the WEEKS: ramp rate, long-run share, hard-day spacing, down weeks), pace_zones (Daniels training bands from a real performance, each ready to paste into a step), review_session_guide (post-run lap-vs-plan debrief, profile updates, Strava retitling via the runner's own Strava MCP), garmin_delivery_guide (the ranked paths onto a Garmin watch — intervals.icu sync first), log_to_strava (one-tap post-run Strava update via the runner's own browser — with the run's effort-vs-plan route visual when the session had one) and get_strava_activities (read-Strava handoff for hosts without Strava tools — the runner fetches their own runs client-side), pull_strava_routes (saved-routes round-trip: the runner sends their routes from the browser, encrypted end-to-end; the inline card hands them to the agent), fetch_strava_handoff (collect that one-shot encrypted drop) and workout_format_help (full format documentation). The setup_runner MCP prompt exposes the interview as a one-click action in clients that support prompts.

curl -X POST https://mcp.watchout.run/ -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"make_workout_links",
       "arguments":{"name":"Threshold 3x2km","date":"260811","emoji":"🔥","warmup":"15@5:45",
       "cooldown":"10@6:00","blocks":["3x2000@4:25-4:20,r120"],"start":"20260811T1945"}}}'

Addresses

mcp.watchout.run serves the MCP endpoint on / — it is the address to paste into Claude or ChatGPT. Everything else lives on watchout.run: /apple and /garmin preset the landing platform, and ?dev=garmin does the same on workout pages — share watchout.run/w?…&dev=garmin with a Garmin friend and they land on the right button.