{
  "openapi": "3.1.0",
  "info": {
    "title": "watchout.run",
    "version": "1.56.0+d20b315",
    "x-build": {
      "sha": "d20b315a45dc5e22a256d2ba29b1fe02e3549acd",
      "builtAt": "2026-08-09T15:41Z"
    },
    "description": "Apple Watch workout generator: .workout files (Apple WorkoutKit), calendar events, and shareable workout pages from URL parameters. Deterministic: same parameters -> same bytes and same workout UUID. Also exposes an unauthenticated MCP server at https://mcp.watchout.run.",
    "contact": {
      "name": "WatchOut.run",
      "url": "https://watchout.run",
      "email": "privacy@watchout.run"
    },
    "termsOfService": "https://watchout.run/privacy"
  },
  "servers": [
    {
      "url": "https://watchout.run"
    }
  ],
  "paths": {
    "/gen": {
      "get": {
        "summary": "Generate a .workout file",
        "parameters": [
          {
            "in": "query",
            "name": "name",
            "schema": {
              "type": "string"
            },
            "description": "Workout name"
          },
          {
            "in": "query",
            "name": "d",
            "schema": {
              "type": "string",
              "pattern": "^\\d{6}$"
            },
            "description": "Date YYMMDD, prefixed to title/filename"
          },
          {
            "in": "query",
            "name": "wu",
            "schema": {
              "type": "string"
            },
            "description": "Warmup goal (optional): '15@5:45' (min@pace), '2000m@5:45' (distance), 'open', or 'none' to skip. Omit for a default warmup. Pace is min:sec per km"
          },
          {
            "in": "query",
            "name": "cd",
            "schema": {
              "type": "string"
            },
            "description": "Cooldown goal (optional): '10@6:00' | '1500m@6:00' | 'open' | 'none'"
          },
          {
            "in": "query",
            "name": "b",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "explode": true,
            "description": "Interval block '<reps>x<steps>' (or a bare step list without Nx, which runs once), repeatable. Work: '2000@4:25-4:20' | '400@3:55' | '2000@h140-155' (HR bpm) | 't30'/'t300@4:25' (timed s) | '2000' | 'open' | 'open~4000' (open with an expected size for the totals). Rest (r-prefix): 'r120' (s) | 'r400m@6:00' (distance) | 'ropen'. Any step takes an optional @pace or @h<lo>-<hi>"
          },
          {
            "in": "query",
            "name": "uid",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Workout UUID override (default: deterministic from params)"
          }
        ],
        "responses": {
          "200": {
            "description": ".workout file (Apple WorkoutKit protobuf)",
            "content": {
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "400": {
            "description": "Malformed spec (plain-text error + usage)"
          }
        }
      }
    },
    "/fit": {
      "get": {
        "summary": "Generate a Garmin FIT workout file (same step structure; deliver via intervals.icu sync or USB — Garmin Connect cannot import workout files)",
        "parameters": [
          {
            "in": "query",
            "name": "name",
            "schema": {
              "type": "string"
            },
            "description": "Workout name"
          },
          {
            "in": "query",
            "name": "d",
            "schema": {
              "type": "string",
              "pattern": "^\\d{6}$"
            },
            "description": "Date YYMMDD, prefixed to title/filename"
          },
          {
            "in": "query",
            "name": "wu",
            "schema": {
              "type": "string"
            },
            "description": "Warmup goal (optional): '15@5:45' (min@pace), '2000m@5:45' (distance), 'open', or 'none' to skip. Omit for a default warmup. Pace is min:sec per km"
          },
          {
            "in": "query",
            "name": "cd",
            "schema": {
              "type": "string"
            },
            "description": "Cooldown goal (optional): '10@6:00' | '1500m@6:00' | 'open' | 'none'"
          },
          {
            "in": "query",
            "name": "b",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "explode": true,
            "description": "Interval block '<reps>x<steps>' (or a bare step list without Nx, which runs once), repeatable. Work: '2000@4:25-4:20' | '400@3:55' | '2000@h140-155' (HR bpm) | 't30'/'t300@4:25' (timed s) | '2000' | 'open' | 'open~4000' (open with an expected size for the totals). Rest (r-prefix): 'r120' (s) | 'r400m@6:00' (distance) | 'ropen'. Any step takes an optional @pace or @h<lo>-<hi>"
          }
        ],
        "responses": {
          "200": {
            "description": "FIT workout file",
            "content": {
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "400": {
            "description": "Malformed spec (plain-text error + usage)"
          }
        }
      }
    },
    "/icu": {
      "get": {
        "summary": "intervals.icu planned-workout event for the spec (JSON: workout text in intervals.icu's builder syntax + ready event body). POST [event] to https://intervals.icu/api/v1/athlete/0/events/bulk?upsert=true with the athlete's API key (Basic auth, username API_KEY): it syncs to a linked Garmin as a native structured workout, and re-sending the same session updates it (upsert matches external_id = the deterministic workout UUID; pass uid to pin it when retuning).",
        "parameters": [
          {
            "in": "query",
            "name": "name",
            "schema": {
              "type": "string"
            },
            "description": "Workout name"
          },
          {
            "in": "query",
            "name": "d",
            "schema": {
              "type": "string",
              "pattern": "^\\d{6}$"
            },
            "description": "Date YYMMDD, prefixed to title/filename"
          },
          {
            "in": "query",
            "name": "wu",
            "schema": {
              "type": "string"
            },
            "description": "Warmup goal (optional): '15@5:45' (min@pace), '2000m@5:45' (distance), 'open', or 'none' to skip. Omit for a default warmup. Pace is min:sec per km"
          },
          {
            "in": "query",
            "name": "cd",
            "schema": {
              "type": "string"
            },
            "description": "Cooldown goal (optional): '10@6:00' | '1500m@6:00' | 'open' | 'none'"
          },
          {
            "in": "query",
            "name": "b",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "explode": true,
            "description": "Interval block '<reps>x<steps>' (or a bare step list without Nx, which runs once), repeatable. Work: '2000@4:25-4:20' | '400@3:55' | '2000@h140-155' (HR bpm) | 't30'/'t300@4:25' (timed s) | '2000' | 'open' | 'open~4000' (open with an expected size for the totals). Rest (r-prefix): 'r120' (s) | 'r400m@6:00' (distance) | 'ropen'. Any step takes an optional @pace or @h<lo>-<hi>"
          },
          {
            "in": "query",
            "name": "uid",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Workout UUID override (default: deterministic from params)"
          },
          {
            "in": "query",
            "name": "lthr",
            "schema": {
              "type": "number"
            },
            "description": "Threshold HR in bpm — converts h<lo>-<hi> steps to the % LTHR ranges intervals.icu requires (absolute bpm targets are unsupported there)"
          }
        ],
        "responses": {
          "200": {
            "description": "{ event, workout_text, warnings, post_to, auth }",
            "content": {
              "application/json": {}
            }
          },
          "400": {
            "description": "Malformed spec (JSON error)"
          }
        }
      }
    },
    "/route.gpx": {
      "get": {
        "summary": "GPX course from an encoded polyline (precision 5, e.g. Strava map.summary_polyline)",
        "parameters": [
          {
            "in": "query",
            "name": "p",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Encoded polyline, precision 5"
          },
          {
            "in": "query",
            "name": "name",
            "schema": {
              "type": "string"
            },
            "description": "Route/course name"
          }
        ],
        "responses": {
          "200": {
            "description": "GPX 1.1 track",
            "content": {
              "application/gpx+xml": {}
            }
          },
          "400": {
            "description": "Bad polyline"
          }
        }
      }
    },
    "/w": {
      "get": {
        "summary": "Shareable workout page (Add-to-Watch + Add-to-Calendar buttons)",
        "parameters": [
          {
            "in": "query",
            "name": "name",
            "schema": {
              "type": "string"
            },
            "description": "Workout name"
          },
          {
            "in": "query",
            "name": "d",
            "schema": {
              "type": "string",
              "pattern": "^\\d{6}$"
            },
            "description": "Date YYMMDD, prefixed to title/filename"
          },
          {
            "in": "query",
            "name": "wu",
            "schema": {
              "type": "string"
            },
            "description": "Warmup goal (optional): '15@5:45' (min@pace), '2000m@5:45' (distance), 'open', or 'none' to skip. Omit for a default warmup. Pace is min:sec per km"
          },
          {
            "in": "query",
            "name": "cd",
            "schema": {
              "type": "string"
            },
            "description": "Cooldown goal (optional): '10@6:00' | '1500m@6:00' | 'open' | 'none'"
          },
          {
            "in": "query",
            "name": "b",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "explode": true,
            "description": "Interval block '<reps>x<steps>' (or a bare step list without Nx, which runs once), repeatable. Work: '2000@4:25-4:20' | '400@3:55' | '2000@h140-155' (HR bpm) | 't30'/'t300@4:25' (timed s) | '2000' | 'open' | 'open~4000' (open with an expected size for the totals). Rest (r-prefix): 'r120' (s) | 'r400m@6:00' (distance) | 'ropen'. Any step takes an optional @pace or @h<lo>-<hi>"
          },
          {
            "in": "query",
            "name": "uid",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Workout UUID override (default: deterministic from params)"
          },
          {
            "in": "query",
            "name": "dt",
            "schema": {
              "type": "string",
              "pattern": "^\\d{8}T\\d{4,6}$"
            },
            "description": "Start time YYYYMMDDTHHMM[SS], local to tz"
          },
          {
            "in": "query",
            "name": "tz",
            "schema": {
              "type": "string",
              "default": "Europe/Oslo"
            },
            "description": "IANA timezone for the calendar event"
          },
          {
            "in": "query",
            "name": "e",
            "schema": {
              "type": "string"
            },
            "description": "Session-type emoji: 🔥 intervals/threshold, 🟢 easy, 🛣️ long, ⚡ hills, 🏁 race"
          },
          {
            "in": "query",
            "name": "dur",
            "schema": {
              "type": "integer",
              "default": 60
            },
            "description": "Calendar event duration in minutes"
          },
          {
            "in": "query",
            "name": "seq",
            "schema": {
              "type": "integer",
              "default": 0
            },
            "description": "Revision of this session, part of the event UID (uid-R<seq>; 0 = bare uid). Bump on every retune/reschedule so the .ics imports as a NEW '(rev N)'-marked event carrying the tuned link (iOS never updates a known UID in place); the superseded event is deleted by hand"
          },
          {
            "in": "query",
            "name": "cal",
            "schema": {
              "type": "string",
              "enum": [
                "upsert"
              ]
            },
            "description": "Import style 'upsert' for calendars that UPDATE a known UID on import (Google Calendar): the event UID stays bare across revisions and carries no rev marker, so a retune replaces the event in place. Omit for add-only calendars (Apple/iOS), where revisions import as new rev-marked events"
          },
          {
            "in": "query",
            "name": "wx",
            "schema": {
              "type": "string",
              "maxLength": 160
            },
            "description": "Forecast one-liner for the start hour, e.g. '14°C, 3 m/s SW, dry' — shown 🌤 in the event description and on the workout page. Display-only: never part of workout identity or file links"
          },
          {
            "in": "query",
            "name": "note",
            "schema": {
              "type": "string",
              "maxLength": 500
            },
            "description": "Coach's note to the runner — shown 📝 in the event description and on the workout page. Display-only: never part of workout identity or file links"
          }
        ],
        "responses": {
          "200": {
            "description": "HTML page",
            "content": {
              "text/html": {}
            }
          },
          "400": {
            "description": "Malformed spec"
          }
        }
      }
    },
    "/ics": {
      "get": {
        "summary": "Calendar event for the workout (dt required); opening the .ics adds it to a calendar of the user's choice",
        "parameters": [
          {
            "in": "query",
            "name": "name",
            "schema": {
              "type": "string"
            },
            "description": "Workout name"
          },
          {
            "in": "query",
            "name": "d",
            "schema": {
              "type": "string",
              "pattern": "^\\d{6}$"
            },
            "description": "Date YYMMDD, prefixed to title/filename"
          },
          {
            "in": "query",
            "name": "wu",
            "schema": {
              "type": "string"
            },
            "description": "Warmup goal (optional): '15@5:45' (min@pace), '2000m@5:45' (distance), 'open', or 'none' to skip. Omit for a default warmup. Pace is min:sec per km"
          },
          {
            "in": "query",
            "name": "cd",
            "schema": {
              "type": "string"
            },
            "description": "Cooldown goal (optional): '10@6:00' | '1500m@6:00' | 'open' | 'none'"
          },
          {
            "in": "query",
            "name": "b",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "explode": true,
            "description": "Interval block '<reps>x<steps>' (or a bare step list without Nx, which runs once), repeatable. Work: '2000@4:25-4:20' | '400@3:55' | '2000@h140-155' (HR bpm) | 't30'/'t300@4:25' (timed s) | '2000' | 'open' | 'open~4000' (open with an expected size for the totals). Rest (r-prefix): 'r120' (s) | 'r400m@6:00' (distance) | 'ropen'. Any step takes an optional @pace or @h<lo>-<hi>"
          },
          {
            "in": "query",
            "name": "uid",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Workout UUID override (default: deterministic from params)"
          },
          {
            "in": "query",
            "name": "dt",
            "schema": {
              "type": "string",
              "pattern": "^\\d{8}T\\d{4,6}$"
            },
            "description": "Start time YYYYMMDDTHHMM[SS], local to tz",
            "required": true
          },
          {
            "in": "query",
            "name": "tz",
            "schema": {
              "type": "string",
              "default": "Europe/Oslo"
            },
            "description": "IANA timezone for the calendar event"
          },
          {
            "in": "query",
            "name": "e",
            "schema": {
              "type": "string"
            },
            "description": "Session-type emoji: 🔥 intervals/threshold, 🟢 easy, 🛣️ long, ⚡ hills, 🏁 race"
          },
          {
            "in": "query",
            "name": "dur",
            "schema": {
              "type": "integer",
              "default": 60
            },
            "description": "Calendar event duration in minutes"
          },
          {
            "in": "query",
            "name": "seq",
            "schema": {
              "type": "integer",
              "default": 0
            },
            "description": "Revision of this session, part of the event UID (uid-R<seq>; 0 = bare uid). Bump on every retune/reschedule so the .ics imports as a NEW '(rev N)'-marked event carrying the tuned link (iOS never updates a known UID in place); the superseded event is deleted by hand"
          },
          {
            "in": "query",
            "name": "cal",
            "schema": {
              "type": "string",
              "enum": [
                "upsert"
              ]
            },
            "description": "Import style 'upsert' for calendars that UPDATE a known UID on import (Google Calendar): the event UID stays bare across revisions and carries no rev marker, so a retune replaces the event in place. Omit for add-only calendars (Apple/iOS), where revisions import as new rev-marked events"
          },
          {
            "in": "query",
            "name": "wx",
            "schema": {
              "type": "string",
              "maxLength": 160
            },
            "description": "Forecast one-liner for the start hour, e.g. '14°C, 3 m/s SW, dry' — shown 🌤 in the event description and on the workout page. Display-only: never part of workout identity or file links"
          },
          {
            "in": "query",
            "name": "note",
            "schema": {
              "type": "string",
              "maxLength": 500
            },
            "description": "Coach's note to the runner — shown 📝 in the event description and on the workout page. Display-only: never part of workout identity or file links"
          }
        ],
        "responses": {
          "200": {
            "description": "iCalendar event",
            "content": {
              "text/calendar": {}
            }
          },
          "400": {
            "description": "Missing/malformed dt or spec"
          }
        }
      }
    },
    "/plan": {
      "get": {
        "summary": "Training-plan page: every session, Add-all-to-Calendar (/plan.ics) and Send-all-to-Garmin (bulk). Session identity = plan id + slot index, so retuned/moved sessions update in place",
        "parameters": [
          {
            "in": "query",
            "name": "plan",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^[\\w-]{1,64}$"
            },
            "description": "Stable plan id — keys every session's identity (slot uid = hash of plan id + slot index)"
          },
          {
            "in": "query",
            "name": "name",
            "schema": {
              "type": "string"
            },
            "description": "Human plan title for the page"
          },
          {
            "in": "query",
            "name": "tz",
            "schema": {
              "type": "string",
              "default": "Europe/Oslo"
            },
            "description": "IANA timezone applied to sessions that don't set their own"
          },
          {
            "in": "query",
            "name": "s",
            "required": true,
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "explode": true,
            "description": "One per session, in slot order: the session's url-encoded parameter string (same params as /ics)"
          },
          {
            "in": "query",
            "name": "cal",
            "schema": {
              "type": "string",
              "enum": [
                "upsert"
              ]
            },
            "description": "Import style applied to every session's VEVENT: 'upsert' keeps bare UIDs for calendars that update a known UID on import (Google Calendar); omit for add-only calendars (Apple/iOS), where revisions import as new rev-marked events"
          }
        ],
        "responses": {
          "200": {
            "description": "HTML page",
            "content": {
              "text/html": {}
            }
          },
          "400": {
            "description": "Malformed plan"
          }
        }
      }
    },
    "/plan.ics": {
      "get": {
        "summary": "One iCalendar file with one VEVENT per session of the plan",
        "parameters": [
          {
            "in": "query",
            "name": "plan",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^[\\w-]{1,64}$"
            },
            "description": "Stable plan id — keys every session's identity (slot uid = hash of plan id + slot index)"
          },
          {
            "in": "query",
            "name": "name",
            "schema": {
              "type": "string"
            },
            "description": "Human plan title for the page"
          },
          {
            "in": "query",
            "name": "tz",
            "schema": {
              "type": "string",
              "default": "Europe/Oslo"
            },
            "description": "IANA timezone applied to sessions that don't set their own"
          },
          {
            "in": "query",
            "name": "s",
            "required": true,
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "explode": true,
            "description": "One per session, in slot order: the session's url-encoded parameter string (same params as /ics)"
          },
          {
            "in": "query",
            "name": "cal",
            "schema": {
              "type": "string",
              "enum": [
                "upsert"
              ]
            },
            "description": "Import style applied to every session's VEVENT: 'upsert' keeps bare UIDs for calendars that update a known UID on import (Google Calendar); omit for add-only calendars (Apple/iOS), where revisions import as new rev-marked events"
          }
        ],
        "responses": {
          "200": {
            "description": "iCalendar events",
            "content": {
              "text/calendar": {}
            }
          },
          "400": {
            "description": "Malformed plan"
          }
        }
      }
    },
    "/mcp": {
      "post": {
        "summary": "Stateless MCP server (Streamable HTTP, JSON-RPC 2.0). Tools: make_workout_links, make_plan_links, show_plan_progress, read_workout_link, now, runner_profile_interview, workout_design_guide, check_session, check_route, check_plan, pace_zones, tune_session_guide, adapt_plan_guide, review_session_guide, get_weather, garmin_delivery_guide, workout_format_help, log_to_strava, get_strava_activities, pull_strava_routes, fetch_strava_handoff, whats_new. Prompts: setup_runner, tune_today, review_run",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "JSON-RPC 2.0 message or batch"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "JSON-RPC response",
            "content": {
              "application/json": {}
            }
          },
          "202": {
            "description": "Notification accepted"
          }
        }
      }
    },
    "/changelog.json": {
      "get": {
        "summary": "Machine-readable changelog: current version + per-version entries; entries with agent_note mark MATERIAL changes for artifacts generated under older versions (MCP results carry generated_with; the whats_new tool diffs against it)",
        "responses": {
          "200": {
            "description": "Changelog",
            "content": {
              "application/json": {}
            }
          }
        }
      }
    }
  }
}