Changelog
Current build: v1.56.0+d20b315
v1.56.0 2026-08-09
Darkness is weather, and the lap arithmetic stops depending on the agent remembering it. DAYLIGHT [D10]: get_weather now returns sunrise, sunset, civil dawn and dusk, the day's length, and whether that particular hour is dark — computed from the sunrise equation (Meeus/NOAA, good to about a minute), never fetched, so it costs no upstream call and works for any date at any latitude. Three decisions in it. Dark is judged on CIVIL twilight, not sunrise: there is usable light before dawn and after sunset, and a tool that cried headlamp every morning of the year would be ignored by March. Polar night and midnight sun are named rather than returning nothing, because Tromsø is a place people train — and polar night is reported alongside its usable midday twilight, since 'the sun does not rise today' and 'you need a headlamp right now' are different facts and a runner needs the second. And light_left_min is there so an evening session can be checked against its own length: 70 minutes of running with 40 minutes of light finishes in the dark. The coaching line says the one thing that is about training rather than kit — move the QUALITY, because pace alerts on unlit uneven ground is how ankles go. Found while building it and fixed: a time past the ~10-day forecast horizon used to come back with the LAST rows of the forecast, unlabelled, so a session nine weeks out returned a temperature that could ride straight into a calendar event. It now returns no rows and says so, with the daylight — which IS exact for any date — so the planning question is still answered. LAP FIT INLINE [D12]: make_workout_links takes the profile's lap_m and runs check_session's lap arithmetic on the way past, returning lap_fit and, when a cycle isn't a whole number of laps, the float that fixes it. check_session has always done this, but it is a separate call an agent has to remember, and forgetting is invisible until rep two on the track at the wrong line. One false positive was found by running it and fixed at the source: a cycle of nothing but TIMED steps (a strides block) has its whole length derived from an assumed pace, so it now reports cycle_estimated instead of prescribing a 25 m float nobody can run. A timed jog INSIDE distance reps still gets the advice — that is the design guide's own worked example, where the float is exactly what absorbs the jog's slack.
🤖 For agents: Two things worth doing on your next session: pass lap_m (from the runner's profile) to make_workout_links so track and loop sessions are checked without a separate check_session call, and read get_weather's new daylight block before writing slot_check — a dark start is a real constraint on where the quality goes, not a footnote. If get_weather returns kind 'beyond forecast horizon', do NOT put a temperature in the event: the rows are deliberately empty, and only the daylight is certain.
v1.55.0 2026-08-09
The session page stops being a dead end. /w has always been the link an agent hands over, and until now the only things a runner could do there were add it to a watch, add it to a calendar, or leave — which was fine while every runner had an agent to go back to. /start changed that: it ships people a nine-week plan and no chat at all, and for them a session page with no exits is the whole product. Two exits now. ADJUST THIS WORKOUT [C3] hands the session's own parameters to /build, which has read the real DSL in the browser since v1.33.0 (it imports the parser from /spec.js), so the form opens AS that workout — name, warmup, cooldown, every block, date, start time — and the links rebuild as it is edited. The whole query goes over, garnish included, and /build drops what a form cannot hold and SAYS which: the route map, the coach's note, the forecast line, post-run splits, its place in a plan. One thing is deliberately not carried: the uid. An edited session is a new session, and inheriting an identity is how a watch silently gets a workout the runner did not ask for — so the page says plainly that this builds a second workout, and that an agent retune is what replaces the first. Two smaller honesty guards came out of building it: a session with no emoji no longer picks up the form's default flame on the way in, and a start time crossing into a different timezone says so rather than quietly moving on the calendar. PART OF A TRAINING PLAN [C2] — every plan session's link has carried plan=<id> since v1.18.0 as recovery metadata, and the page never mentioned it. It does now, and it becomes a real link back when it can: the id names a plan but cannot rebuild one (the sessions live in s=, which only the plan link holds), so /plan leaves its own address under watchout-plan-<id> and /w picks it up. A runner who has opened their plan in this browser gets a way back; everyone else is at least told the session belongs to something. Same watchout-* prefix 'Forget everything' already sweeps, still nothing on the server, and the href is checked to be a plan page on this origin before it is used.
v1.54.0 2026-08-09
Hill reps stop getting flat-ground paces. check_route has computed per-km grades since v1.15.0 and fitness.js has had the Minetti gradient cost since v1.37.0, but nothing joined them: a rep on a 3% climb was still handed the pace it would be worth on the flat, which is a target the runner either misses or hurts themselves hitting. Pass target_pace_s_per_km — the pace the effort is worth on the FLAT, straight from pace_zones — and every kilometre comes back with the pace that costs the SAME effort on its gradient, plus the route mean, the hardest and easiest kilometres, and a flag on the ones too steep to run by pace at all. It lives on check_route rather than in a tool of its own because the grades are already computed there; a separate tool would re-fetch the same elevation to answer the same question and cost wire on every conversation to do it. THE NUMBERS MOVED, and visibly: the route-coaching line had carried an uncited rule of thumb — add 2-3 s/km per 10 m of climbing per km — since the guides were written. Measured against Minetti it is 5-7x too low. The real cost is about 5-6% of pace per 1% of gradient, so a 4:25 flat target is 4:40 on 1% and 5:11 on 3%, and the guide now says so and points at the computed number instead of an estimate. Two honesty guards ride with it. Descents are credited at most 10%, which is OUR cap and not the model's: metabolically a 6% descent really is ~28% cheaper, but nobody runs a 4:25 flat pace as 3:12 downhill, because braking and eccentric loading are not in an oxygen-cost polynomial. And past ±4% the kilometre is flagged use_hr — the adjusted number is arithmetically right and practically wrong, so heart rate is the honest control. The note says plainly that GAP models disagree with each other, that this is the aggressive one on climbs, and that what the runner has actually run on this hill beats it.
v1.53.0 2026-08-09
Two more things the server now computes instead of leaving to the model's head. pace_zones [D1]: the runner's training bands — easy / marathon / threshold / interval / repetition — from Daniels' VDOT equations, anchored on a real performance (race, time trial, hard session) or their threshold pace. VDOT and those curves have been in fitness.js since v1.38.0 but were locked inside show_plan_progress, so the bands themselves were still being estimated. DERIVED, not transcribed: the printed tables come from these equations, and at VDOT 50 the band edges land on Daniels' own numbers (threshold 4:15/km exactly, marathon/interval/repetition within 3-6 s/km) — test-locked. Each band ships what it is FOR, because a pace range with no purpose attached is how easy days turn into medium days, and easy warns about its own fast end. Add lthr_bpm for a heart-rate band beside each pace band, except repetition, where reps are over before HR has finished responding and a target would be a number nobody can obey. The WatchOut-specific part is the last field: 'dsl', the band already written as a step target, so a zone pastes straight into blocks with no second conversion to get wrong. check_plan [D3]: check_session validates one session's arithmetic; nothing validated the shape of the WEEKS, which is where the injuries live — a plan can be five perfectly-built sessions and still ramp 30% in a week or stack two quality days back to back. Pass the sessions you are about to build and it returns per-week volume plus what to look at: weekly steps past ~10%, a long run past ~35% of the week, quality days on consecutive days, a week with no rest day, four weeks with no down week. Two false positives were found by running it and removed: the long-run rule assumes a week with somewhere to put the other volume, so it stays quiet below four sessions (on three days a week the long run is structurally half the week), and a plan starting mid-week marks its stub first week partial rather than inventing a 300% ramp into week two. Quality days come from the repo's own emoji taxonomy or an explicit flag; with neither, the spacing check reports itself as a GAP rather than passing. Every threshold is a rule of thumb and every finding carries the arithmetic that triggered it — the tool exists to make an agent look, never to veto a plan the runner chose. Wire budget 54 -> 56 KB for the two tools, after first delegating ~700 bytes of duplicated field prose out of make_plan_links' session schema: it still DECLARES every field, but only describes the ones whose meaning changes inside a plan.
v1.52.0 2026-08-09
A guide for the ARC, and tool errors you can parse. NEW TOOL adapt_plan_guide — every other guide here is about one session (design it, tune it, review it), so the answer to 'I was sick for a week' was whatever the model improvised, sitting next to tools that are otherwise ruthlessly deterministic. It covers what a missed week means in each phase (base absorbs it, peak means the plan was too much), the one-lever decision — DROP by default, SHIFT only if hard/easy spacing survives, COMPRESS only in base and never past the ordinary ~10% weekly step — and it refuses the make-up week that stacks lost work onto planned work, which is the reliable way to turn one bad week into three. Plus the neck rule and a return-to-run ramp (above the neck: run easy, drop the quality; fever or chest symptoms: nothing until a day symptom-free, and it names myocarditis once, plainly), travel weeks kept whole by respecifying sessions rather than degrading them, and the three cases of a moved race — all re-emitted with the SAME uids and seq+1, because identity is plan_id + slot index and never the date. Pass race_date + weekly_km and it also returns the taper table: week-by-week target volumes from Bosquet et al. 2007 (Med Sci Sports Exerc 39:1358), whose finding is that a two-week progressive cut of VOLUME with INTENSITY held produces the gain — the thing runners get backwards by resting completely and arriving flat. Half marathon gets two weeks, marathon and up three, both landing near half of peak, each row labelled with the Monday it starts on so 'weeks_out: 2' is unambiguous on a Thursday. It refuses to guess: no weekly_km, no table. TOOL ERRORS are now a JSON object — error, tool, retriable, and a next line — instead of one prose string a structured-output client had to regex. retriable is the field that earns its place: the messages already said what was wanted, but nothing said whether calling again could ever work, so an agent would retry a bad pace forever and give up on a transient yr.no 503. Deliberately NOT structuredContent: strict SDK clients validate that against the tool's SUCCESS output schema even when isError is set and reject the whole response, so the JSON rides the text block instead. The wire budget moves 52 → 54 KB for the 20th tool, after applying the v1.50.0 rule to its own output schema first.
v1.51.1 2026-08-09
The plan page's '📱 On your phone? Scan to open' row no longer disappears without a word on long plans. QR byte mode tops out at about 2953 bytes (version 40, EC level L); /start's nine-week plan link is 3529 bytes because it carries all 27 sessions in full, so the encoder correctly declined and the page rendered nothing at all. Silent, and on exactly the wrong page — /start's runners are the ones with no agent and no desktop habit, the likeliest of anyone to want this on a phone. The reveal now stays open for business at over-capacity: it says the link is too long to fit in a code and offers the link itself in a copy row, which does the same errand in one copy instead of one scan. Small plans are untouched and still scan. Nothing was shortened to make a code fit: the link IS the plan, and a short canonical plan URL would mean server state, which this product does not keep. Also generalised: copy buttons on /w and /plan are now one delegated handler keyed on data-copy rather than a single hard-coded id pair, so a page can carry more than one link row.
v1.51.0 2026-08-09
The plan page learned where the runner is. /start shipped two days ago and manufactures runners who have NO agent — it takes someone who doesn't run yet, hands them 27 sessions, and points at the plan page, which until now looked identical in week 3 and on day one. It could not say what was next or what had been done. Three things fix that, all client-side, nothing stored on the server. (1) NEXT UP: a spotlight at the top rendered server-side from the first session still ahead, so it is right before a line of JavaScript runs; the script re-points it past anything already ticked, and says 'Plan complete ✓' when there is nothing left. (2) TICK-OFF: one control per session, keyed by the SLOT UID rather than the index — a retune keeps the uid, so ticking survives the plan being re-emitted with new links. Done sessions dim and strike through; the header counts 'Week 2 of 4 · 12 of 34 done' (the week half drops once the plan is finished, because it contradicts the count). State is a list of uids under watchout-done-<plan_id> in localStorage — the same watchout-* prefix that 'Forget everything on this device' already sweeps, and it is documented on /privacy. Ticks ship hidden and the script unhides them: a browser without JS gets a plan that simply doesn't claim to remember anything, rather than a checkbox that forgets. (3) The durability answer — 'this link IS the plan' — was behind a collapsed <details> aimed at runners with a chat to paste it into. The one-line version is now always visible and leads with bookmarking, which is the only answer that means anything to someone who arrived from /start. No change to links, files, identity or any MCP surface; show_plan_progress remains the agent-side view of the same question.
v1.50.0 2026-08-09
tools/list went on the diet it has needed for a year, and the fat was not where anyone had been cutting. Measured rather than guessed, the 62.5 KB wire contract split: outputSchema 44%, inputSchema 34%, tool descriptions 14%. Every previous trimming pass had aimed at that last 14%. Meanwhile the output schemas had grown to nearly half the payload, and 20 KB of that was PROSE — 154 field descriptions explaining values the model reads the instant the tool returns. Describing gen_url as 'Apple .workout file URL' cost bytes on every conversation forever to explain a field whose value is a URL ending in /gen. The rule applied: an output field whose value the model will read needs no description; only a field whose meaning isn't evident from its value does. The warnings were the purest case — their runtime values already carry the same instruction in fuller form, and only when the condition fires, so the schema copy was pure duplication charged to everyone. Kept: sign conventions, cryptic keys (d/h/k, CTL/ATL/TSB), which of two near-identical calendar URLs a Google runner wants, and the kind=plan/kind=workout split. Every field is still DECLARED — the ChatGPT dev-mode check wants the schema to exist, not to be annotated. Result: 48.6 KB, ~16.4k → ~12.8k tokens, about 3.6k tokens back on every conversation before the runner says a word, with no capability lost and no behaviour changed. The budget test drops 66 KB → 52 KB so the reclaimed room can be spent on capability rather than refilled with prose.
v1.49.0 2026-08-09
The /build paste box learned the stride sandwich, and stopped losing the run. '8k easy @ 140-145 with 6 x 20s strides' — one of the most common lines on any training plan — used to come out as a bare 6xt20 with THE 8 KM SILENTLY DROPPED, because the rep matcher grabbed '6 x' and discarded everything before it. It now builds what the design guide asks for: 1xopen~4000@h140-145 / 6xt20,r60 / 1xopen~4000@h140-145 — two open easy halves with the strides between them, so the runner takes them where the path is flat and the ~ hints keep the totals honest (the pasted session estimates at 9.3 km again instead of vanishing). Works with the strides on the same line ('with', 'w/', '+', ', finish with') or on their own line below the easy run, which is how plans are actually written. Stride numbers are used verbatim when given; anything missing (count, length, jog) is filled from the ordinary 6 x 20 s / 60 s shape and ANNOUNCED, never silently. Also: a bare '@ 140-145' now reads as a heart-rate range — it is a pulse to every runner alive, but the old pattern demanded a 'bpm' suffix and dropped the target entirely. Guarded so a pace range can never be mistaken for HR (a m:ss anywhere in the text disables the reading) and implausible bands are left alone.
v1.48.0 2026-08-09
Strides stop living only in the prose. Field report: an easy run designed as '8k @ 140-145 with 6 x 20 s strides' shipped as ONE block (b=1x8000@h140-145) — the strides existed in the sentence and nowhere the watch could cue them, so the runner did them from memory or not at all. Two changes. (1) NEW DSL FORM open~4000 / open~t1200: an open step carrying the size you EXPECT it to be. That makes the right shape expressible — easy open / strides / easy open, three blocks, where the RUNNER advances to the strides when they reach a flat, straight, uninterrupted stretch instead of the plan guessing at a split distance. Without it that shape estimated at ~1.7 km (an open step counts as a nominal minute), and the wrong number flowed into the calendar event, the plan's weekly total and show_plan_progress; with it the same session reads 9.3 km / 56 min like the fixed-distance version. The hint is display and arithmetic ONLY: the emitted step is still goal_type=4 (Apple) / duration_type=5 (FIT) with no payload — byte-identical with and without it, test-locked — and the watch still advances only on a tap. An unhinted open step is still left out of the totals, because there is genuinely no number to add. (2) FORCING FUNCTION: make_workout_links returns structure_warning when the session's name, note or course_fit mentions strides/pickups/accelerations/surges but no block contains a short timed work step — with the three-block open-sandwich spelled out. A correctly built session never triggers it. The design guide gained the rule behind both ('if you say it, build it'; where the strides go is the runner's call, not the plan's).
🤖 For agents: When a session's wording says strides, pickups or surges, BUILD them as steps — three blocks, not one, e.g. blocks: ['1xopen~4000@h140-145', '6xt20,r60', '1xopen~4000@h140-145'] for an 8 km easy run with 6 strides. Prefer open easy sections with a ~ size hint over splitting at a guessed distance: the runner knows where the flat stretch is, the plan doesn't. Always hint an open step that stands in for a known amount of running, or the totals under-count it. Sessions you built earlier with strides only in the text are worth rebuilding on the runner's next ask (same uid, seq+1) — nothing is broken, but the watch never cued the strides.
v1.47.0 2026-08-08
New page: /start — a nine-week walk/run plan for someone who does not run yet. Every other surface here assumes a runner who already trains (Strava history, target paces, a race), but the DSL's biggest untapped audience is the opposite person: 'one minute running, ninety seconds walking, eight times' is b=8xt60,r90, and Apple's Fitness app cannot build that. Pick a start week and a time, and 27 ordinary sessions land in the calendar and on the watch through the existing /plan machinery — nothing about this route is special-cased downstream. The progression is our own and deliberately conservative (run time only ever rises, walk time only ever falls, jumps small enough that a hard week can be repeated rather than skipped), it prescribes NO pace (at this stage the clock is the target, not the speed), and it says plainly that it is a starting point rather than a prescription, with a line about seeing a doctor or physio when coming back from injury, pregnancy or surgery. It is a browser artifact, not a template library on the MCP — that stays method-not-menus. ALSO A REAL FIX FOUND BY BUILDING IT: a session with no pace target anywhere had its work steps priced at the 4:00/km floor meant for strides inside a quality session, so a beginner's first week read '≈ 17 km'. With nothing to be faster than, untargeted work now falls back to the same rest-pace estimate every other unknown uses — this changes the ≈distance shown for any pace-free session (the bytes, the UUID and the steps are untouched).
🤖 For agents: If a runner asks for something to send a beginner — a partner, a friend, a colleague starting out — hand them https://watchout.run/start rather than designing a first block yourself. Also note the estimate fix: for sessions with NO pace target anywhere, the ≈km on cards and pages used to be inflated (untargeted work priced at 4:00/km). Any old artifact of that shape read long; nothing about the workout itself changed, so there is nothing to regenerate.
v1.46.0 2026-08-08
Miles. Metric-only display was the single biggest thing keeping this unreadable for most English-speaking runners, so /w and /plan now read in either unit: a km/mi chip next to the estimates, remembered per browser, plus ?u=mi on a link for sharing a session already in miles. DISPLAY ONLY, and that boundary is test-locked — the DSL, the .workout and FIT bytes, the workout UUID and every generated file link stay metric forever (u= is a display param like dev=, so it can never reach /gen or /fit, and the bytes are asserted byte-identical with and without it). How it works: the server renders BOTH readings into the markup and the toggle swaps which is shown, so there is no browser-side conversion to get wrong and the ?u= choice still renders with JavaScript off. Distances of a mile or more become miles; TRACK REPS STAY METRIC (a 400 is a 400 on every track on earth — '0.25 mi' would be a worse reading, not a better one), and pace targets are re-expressed per mile (4:25/km reads 7:06/mi) while heart-rate ranges are left alone. Route distance, weekly totals and plan totals follow the same switch.
v1.45.0 2026-08-08
/build reads plain English. Paste 'warm up 2 miles easy / 5 x 1000m @ 3:50 (2:30 jog) / cool down 10 min' and the warmup, block and cooldown fields fill themselves in. This is the onramp for people with no AI assistant: the world's training plans exist as sentences — Daniels tables, Pfitzinger weeks, club emails, forum posts, a coach's PDF — and until now the only thing that could translate them into the DSL was a paid subscription. New zero-dependency module prose.js, served raw at GET /prose.js the same way spec.js is, so the browser reads exactly as the server would. It handles reps (3x, 6 ×), miles/km/m, minutes/seconds, pace ranges, HR ranges, and the recovery notations people actually write ('w/ 3 min jog', '90s rest', '(2:30 recovery)', 'jog down 90s'). Three rules it will not break: it never invents a target (a step with no pace in the text gets no alert, and says so); unreadable lines are reported rather than dropped, so a messy paste still yields the parts that worked; and every assumption is printed — mile-to-meter conversions, a per-mile pace converted to per-km, and '2:30' inside a recovery read as a duration rather than a pace. The output is filled INTO the form, never straight into a file, so the runner sees and edits it first — and the test suite round-trips every parse through spec.js, so the box can't produce something that looks right and then fails on the phone. Deliberately not an MCP tool: agents already do this translation, and tools/list is paid for by every conversation.
v1.44.0 2026-08-08
Discoverable by agents, and a better guest upstream. (1) THE CONVENTIONAL PATHS now exist: /llms.txt (the short index — says what this is, points at the MCP server FIRST rather than at the pages), /llms-full.txt (one flat file a third-party agent can integrate from: the whole spec DSL grammar, every endpoint, and every MCP tool with its arguments), plus /robots.txt and /sitemap.xml. llms-full is generated from the SAME tool array tools/list serves, so it cannot drift from the live contract — a new tool appears in the docs by construction. Robots keeps crawlers out of generated artifacts (/w, /plan, /gen, /ics and friends: one URL per workout, unbounded and personal-ish) and out of the per-runner Strava pages that were already noindex, while leaving the docs open. The landing page gained WebApplication JSON-LD that states outright that it is free. (2) UPSTREAM MANNERS: get_weather now asks MET Norway and Open-Meteo on coordinates rounded to two decimals (~1.1 km — far finer than any forecast grid, and MET's terms ask for coarse coordinates and caching). At four decimals nearly every request was a unique cache key and a fresh upstream call; now a neighbourhood of runners shares one fetch. What the runner gets back is unchanged. (3) Workers observability is on (10% sampled) — before this, an unhandled exception in a tool call was invisible unless someone happened to curl the build header.
v1.43.0 2026-08-08
The intensity profile becomes readable for colorblind runners — a measured bug, not a suspected one. The profile encodes step kind in COLOR ALONE (bar height is pace-derived, and an untargeted warmup normalizes to exactly the float height, so height is no second channel), and three of the four kinds were one color for a red-green dichromat: float vs rest sat at dE 1.2 under deuteranopia and 0.1 under protanopia, with warmup colliding into both. Green-vs-orange, the pair you would suspect, was never the problem at dE 82. Fixed on the axis that survives every dichromacy: float went lighter, rest darker, so the worst pair is now dE 15.1 across normal vision, deuteranopia, protanopia and tritanopia. Rest also lost its extra 0.55 opacity — color and opacity were dimming the same step twice, and stacked they put rest bars under 3:1 against the page. The share card (OG PNG) got the same lift. Printing gained its own ink set: the screen palette is built for a dark page and its light float prints at ~1.3:1 on white, so @media print re-inks the profile through per-kind classes with paper colors that clear both the dE floor (20.9) and 3:1 on white. And the profile is no longer aria-hidden — it carries role=img plus a name that says what it is ('Intensity profile: 2 warmup/cooldown steps, 3 work steps, 3 rests') on /w, /plan and the widget cards, so the shape is available with no color at all. test/palette.test.mjs runs the dichromacy simulation over all three palettes and holds the old palette as a negative control, so a future color edit cannot quietly re-introduce this.
v1.42.1 2026-08-08
/privacy answers the question statelessness raises but never addressed: what happens to the runner's stuff if WatchOut.run stops answering. Storing nothing is a promise about their data AND a dependency they should be able to see, so the page now names it per artifact — surviving on their own: workouts already on the watch, downloaded .workout/.fit files, imported calendar events (title, time and the steps in the description), anything sent to intervals.icu or Garmin, every Strava activity logged; needing this site: opening a /w or /plan link, re-downloading a file, route maps and GPX, the mailbox. The practical upshot for a runner is that a calendar event keeps telling them what the session IS either way — the link on it is the part that would stop working. Repo housekeeping alongside it (no runtime effect): the project has a LICENSE (MIT for code, CC BY 4.0 for the reverse-engineered format spec in reference/) and a README that explains the architecture and how to deploy your own instance.
v1.42.0 2026-08-08
Coaching manners, from four field complaints. (1) THE LOG IS AUTOMATIC — the Strava write is the last step of every review, called unasked; a runner having to ask for their own log was the bug. The only opt-out is a standing 'no' in the profile. (2) OUT OF THE ORDINARY CUTS BOTH WAYS — the escalation rule graded only bad days; a session well ABOVE plan is evidence the paces are stale, and now opens the same plan question (show_plan_progress + one structured choice: re-anchor paces, add the effort to anchors[], hold and re-check, or step the block up). Concrete triggers replace vibes: work steps ≥5 s/km off target either way, one rep off by ≥10 s/km, RPE ≥2 points from intent, HR ≥5 bpm off the usual for that pace, a PR, or pain. (3) FACTUAL STRAVA VOICE — descriptions were writing invented atmosphere ('running with the boys', 'legs felt amazing'). The guide now says say what happened and nothing else; log_to_strava returns voice_warning when the summary reads as invented mood/company/scenery, and takes a new `caption` argument for the RUNNER's own words, placed on top verbatim and never tone-checked. (4) COACH, NOT NAG OR CHEERLEADER — the profile is read aloud in every future chat, so it now records FACTS with numbers instead of verdicts about the person ('week 3: 44 km, +7% on the 4-week mean', never 'tends to overextend'), and a new **Standing decisions** section holds what the runner has already settled: raised once, then honoured, and only re-opened with named new evidence. Plus: every changed number ships with its arithmetic — make_workout_links/make_plan_links return change_note on a replacement (seq ≥ 1), and tune_session_guide gains a WAS → NOW / why / totals rule that bites hardest when the RUNNER pushed for the change ('longer' is a request, not a calculation: give them the number and what it costs the week, then let them decide).
🤖 For agents: Three habit changes. (a) End every review by calling log_to_strava — don't offer, do it, and keep the summary factual (colour goes in the new `caption` field, in the runner's own words or not at all). (b) When a session lands unusually GOOD, treat it like a red flag: show_plan_progress and ask, rather than congratulate and move on. (c) When you change any distance, pace or rep count, show the arithmetic that produced it and let the runner decide; write what they decide into a **Standing decisions** block in their profile and stop re-raising it.
🎤 Interview change: The profile gained **Standing decisions** (what the runner has already settled, dated — never re-raised without new evidence), and the Training log is now explicitly facts-and-numbers, never verdicts about the runner. Add the section to an existing profile, move any standing judgements ('tends to overextend' and the like) out of it, and re-issue the runner's standing-instructions block — it now carries the coaching tone (say it once, show the arithmetic on plan changes, log to Strava unasked).
v1.41.0 2026-08-08
Wind becomes a vector, and location anchors on the runner's feet. Field failure (Hemsedal, 6 Aug 2026): a runner staying up at a mountain cabin was offered routes down in the valley — the agent anchored on the place NAME instead of on where the runner actually was, and had to be corrected twice ('I am already on the mountain, same start point as the run I just did'). And every review quoted wind as decoration ('12 °C, 4 m/s') without ever asking whether it helped or hindered — it couldn't: get_weather carried no direction. Now: (1) get_weather rows carry wind_dir_deg (direction the wind blows FROM) + wind_dir (compass) alongside speed and gusts, both branches (yr.no forecast and Open-Meteo history). (2) check_route accepts wind_from_deg + wind_mps and returns per-km wind exposure — distance-weighted travel bearing per kilometre, signed headwind_mps, effect headwind/tailwind/crosswind/neutral, an exposure one-liner, and the asymmetry caveat (headwind costs more than tailwind refunds, so a windy loop is net slower; no shelter model — the math knows the geometry, not the trees). (3) The review guide makes wind part of lap grading — automatic from ~4 m/s when the activity's polyline is in hand, verdict stated plainly (helped, hindered, or didn't matter), and ASK the runner about the exposed stretch when no polyline exists. (4) Location anchoring is now a rule in the design/tune/review guides and the workflow: anchor on the start coordinates of the runner's most recent activity there (start_latlng), never on a town name's centroid — never propose a start the runner can't reach on foot; travelling runners get a TRIP BASE recorded in the profile at review time, and weather anchors on the current base, not home.
🤖 For agents: Two habits change. REVIEWS: get_weather at the run hour now returns wind_dir_deg — from ~4 m/s, pass it with the activity's polyline to check_route (wind_from_deg + wind_mps) and grade each lap against its kilometre's headwind_mps; state in every windy review whether wind helped, hindered or didn't matter, and ask about the route's direction when you can't tell. LOCATION: when the runner is away from home, the latest activity's start coordinates are the base for the next session, its weather, and any route suggestion — record it as a trip base in the profile, and never anchor on a place name.
v1.40.0 2026-08-05
The race prediction says WHICH timeframe it is talking about, and what the goal would take. It was one bare time with no indication of whether it meant 'today' or 'race day', and no read on whether the goal was still available. Now race_prediction carries three separate things: horizon 'now' + as_of (what the runner could race today, and the date that form was measured on); vdot_trend (two anchors ≥14 days apart give a VDOT/week slope held to race day — projected time, clamped at ±0.5 VDOT/week and flagged beyond_data when the horizon runs past twice the measured window); and improvement_needed (the VDOT the goal requires, the gap, that gap expressed as a REQUIRED WEEKLY RATE, and a feasibility verdict against the ~0.15-0.25 VDOT/week a trained runner typically gains in consistent training). With one anchor, or anchors too close together, it says so instead of implying a direction. The card's block is relabelled 'Race shape · today' with the measurement date, and carries the on-trend race-day time and the goal's required rate on a second line. Also documented in the tool contract: a rep session WITH RESTS understates VDOT — prefer a continuous effort as the anchor.
🤖 For agents: When you quote a race prediction, name the timeframe: race_prediction.human is TODAY's form (as of as_of), vdot_trend.projected_human is where the anchors point by race day, and improvement_needed.required_vdot_per_week is what the goal still asks per week. Quoting the 'now' number as a race-day expectation was the ambiguity this fixes.
v1.39.0 2026-08-05
show_plan_progress anchors the fitness plot to the ATHLETE instead of to whatever runs happened to be in the window. The reference heart rate every normalized pace is expressed at used to be the mean of the logged sessions — so logging one threshold run moved the anchor, rewrote every historical value, and made part of the apparent trend the anchor sliding rather than fitness changing. With lthr_bpm the anchor is now 80% of LTHR (the aerobic-endurance band where most easy running actually happens, and Friel's zones are LTHR-referenced), which does not move when a quality session arrives; ref_hr_bpm pins it explicitly; without either, the median is used and reported both in ref_hr_basis and as a data gap. An anchor sitting outside the logged intensities with no pace/HR model to bridge it now returns ref_hr_warning — normalized paces are extrapolation in that case, and say so. Card layout: the per-session list moved behind a reveal (its summary keeps the counts visible — '7 sessions · 3 done · 1 missed'), so the tiles, slot bar, pace plot, fitness strip and race prediction fit one screen instead of being buried by a long plan.
🤖 For agents: Pass lthr_bpm on every show_plan_progress call (it also unlocks the CTL/ATL/TSB curve). Without it the plot's y-values are not comparable between calls, so week-to-week statements about pace-at-HR are unreliable — ref_hr_basis tells you which anchor was used.
v1.38.0 2026-08-05
show_plan_progress: race predictions from real performances, an Efficiency-Factor trend, and a data contract. (1) ANCHORS — pass anchors[] (a race, time trial, or the sustained part of a quality session as date/distance_m/duration_s) plus race_distance_m and the result carries a predicted race time from Daniels & Gilbert VDOT, cross-checked by Critical Speed when two efforts of different duration separate the parameters (Monod & Scherrer / Hill / Jones) and by Riegel's 1.06 exponent, scored against goal_pace_s_per_km. This is the defensible race-pace read: an actual performance converted, not a trend extended — one anchor a week is plenty. (2) EFFICIENCY FACTOR — m/min per bpm on normalized pace (Friel/TrainingPeaks) per point plus a trend; being a ratio it needs no intensity model, so it is the signal that works on ordinary easy runs. (3) DATA CONTRACT — the result now says what it could NOT draw: data_gaps names each missing visual, the field it needs and where that number comes from, and the card itself shows an 'n more views available' line instead of passing a half-empty chart off as the whole picture. Also fixed: planned session paces are discrete markers rather than a connected line (an easy run's 5:55 and a threshold's 4:50 are not two points on a trend — that line drew a meaningless cliff), and the trend projection is capped to one data-window's reach so it is always drawn and never a rumour. The physiology math moved into its own leaf module, src/fitness.js, with its citations.
🤖 For agents: Read data_gaps on every show_plan_progress result and fill it before showing the runner — the card is exactly as good as what you pass. New inputs worth gathering once: anchors[] + race_distance_m (a race/time-trial/quality-session effort → a real VDOT race prediction), and per-session elevation_gain_m + temp_c. Stop quoting the pace projection for race day; quote the race prediction and the goal corridor.
v1.37.0 2026-08-05
show_plan_progress learns physiology, so progress no longer waits for a hard workout. (1) NORMALIZATION: pass elevation_gain_m and temp_c/humidity_pct per completed session and every pace becomes its flat-and-cool equivalent — gradient cost from Minetti et al. 2002, heat penalty above 15 °C from the Ely/Périard literature. A 6:02/km hour on 200 m of gain at 30 °C reads as 5:38 of fitness, which is why hot hilly weeks used to look like lost form. (2) LOAD MODEL: pass lthr_bpm (and ideally hr_rest_bpm) and every logged run's hrTSS feeds a Banister fitness/fatigue curve — CTL, ATL, TSB, a ramp-per-week read with an injury-risk threshold, and a projection of what the REMAINING planned sessions build CTL to by race day (costed at the runner's own hrTSS-per-km; an unplanned tail before race day is flagged as the plan's gap, not a forecast). The card gains a fitness strip under the pace plot. (3) The naive pace projection now refuses to over-reach: past its own data window, or clamped to the observed range, it is flagged and withheld from both card and summary — the goal corridor and race_equivalent carry the forward-looking story instead.
🤖 For agents: Two new habits make the progress card far better and cost one Strava field each: pass elevation_gain_m + temp_c per completed session (Strava's activity detail carries total_elevation_gain and average_temp; get_weather covers the hour otherwise), and pass lthr_bpm from the runner's profile — add LTHR, resting HR and max HR to the profile if they are missing (a lactate test or the HR held for ~an hour). With those, fitness updates on every easy run instead of once per quality session.
🎤 Interview change: Add three physiological fields to the runner profile if absent: threshold HR (LTHR — from a lactate test or the HR they can hold for ~an hour), resting HR, and max HR. They unlock the training-load fitness curve in show_plan_progress; ask for them once and store them.
v1.36.0 2026-08-05
show_plan_progress stops comparing apples to oranges. Field failure (5 Aug 2026): ten easy runs at ~139 bpm were graded '59 s/km behind' a 4:27 race-pace goal and the card went red — pace means nothing without the intensity it was run at, and the agent had to talk the card down in prose. Now the tool fits the runner's OWN pace/HR slope (Theil–Sen over every pair of completed runs, needs ≥12 bpm of spread) and converts paces between intensities with it; goal checkpoints carry hr_bpm and race goals carry goal_hr_bpm, so the goal path lands in the same currency as the runs. When the conversion isn't supportable — no HR spread and no stated checkpoint intensity, or a goal pace far faster than anything logged at that HR — the verdict is WITHHELD (verdict 'insufficient_data', goal_note explaining what opens it) and the card draws no goal line and stays neutral instead of shouting red. With a model in hand the result also carries race_equivalent: current form expressed at race HR, flagged when extrapolated beyond the observed range.
🤖 For agents: Pass goal_hr_bpm with any race-pace goal and hr_bpm on every targets[] checkpoint — without them a race-pace goal will not be graded (by design). Design part-goals in phases: aerobic checkpoints at easy-run HR during base weeks, threshold/race checkpoints from the first quality session on, and make sure the plan contains a session that actually tests each one. Never report 'behind' when goal_note is present; say the aerobic base is tracking and race-pace evidence starts with the first quality session.
v1.35.1 2026-08-05
Connector reconnect fix. Clients probing MCP auth discovery (GET /.well-known/oauth-protected-resource and friends) hit the router's catch-all 302→/ and received the landing page where OAuth metadata should be — Claude aborted the whole reconnect with 'could not start MCP authorization'. Every /.well-known/ path now answers 404 (the spec's 'no auth required' signal for an unauthenticated server), CORS-open for browser-based clients. No tool or artifact changes.
v1.35.0 2026-08-05
New tool: show_plan_progress — the plan's at-a-glance dashboard as an inline card (Claude MCP Apps + ChatGPT Apps SDK, like the other cards). The agent passes the plan's sessions from its manifest with what it knows happened — completed/missed per past slot, the Strava actuals it already reads in the review flow (actual km/minutes, average pace, average HR), planned target paces, race_date + goal_pace_s_per_km, and targets[] = the plan's PART-GOALS (dated pace-at-HR checkpoints designed from the plan's structure). The server computes done counts, km done vs planned, completion streak, past-adherence, days-to-race, the pace-at-HR fitness trend (each pace normalized to a common reference HR; needs 3+ completed sessions carrying pace + HR — fewer stays silent instead of plotting noise), a straight-line projection to race day, and the goal verdict: today's interpolated part-goal, the gap to it, and the projection scored against the race target. The card draws stat tiles, a segmented status bar with a today marker, and the pace-at-HR plot on the plan's own timeline: goal path as a faint corridor to a ring on race day, actuals over dashed planned targets left of now, projection right, all verdicts colored against the goal; the y-scale keeps a linear middle band with log-compressed margins so outlier sessions can't flatten the resolution. Past sessions without a status count as 'unreported' (shown honestly, with a nudge) — statuses and actuals are the agent's claims sourced from Strava, the server still stores nothing and still cannot read Strava itself.
🤖 For agents: Plans generated earlier work as-is — nothing to rebuild. From now on: keep per-slot outcomes in your saved plan manifest after each review (completed/missed, actual km + minutes, pace s/km, avg HR, one-line result — all from the runner's Strava), design part-goal targets (dated pace-at-HR checkpoints toward the race goal) when you build a plan, and answer 'how is my plan going?' with show_plan_progress instead of a text-only summary.
v1.34.2 2026-08-05
Internal refactor, zero wire changes: the four Strava-flow tool handlers (log_to_strava, pull_strava_routes, get_strava_activities, fetch_strava_handoff) moved from mcp.js into their own module, mcp-strava.js — the server-side halves of the flows whose pages live in strava.js — the same split mcp-tools.js got in v1.33.1. Also drops three imports mcp.js declared but never used. Same tools, same arguments, same results.
v1.34.1 2026-08-05
Leanness sweep round 2 — internal only, every output byte-identical (test goldens unchanged). The router's four hand-copied response idioms (edge-cache dance, static page/asset headers, attachment filename transliteration, usage-error 400s) became shared helpers; the OG renderers share one canvas + palette declaration instead of two; the '2h 21min' clock format is defined once in web.js (calendar.js carried a drifted-in twin); the parse-the-whole-spec validation used by make_workout_links and read_workout_link is one spec.js function; and the plan page and its OG image share their per-slot prep. Nothing an agent or runner can observe changed.
v1.34.0 2026-08-05
The server speaks MCP 2026-07-28, the stateless revision — which this server always was underneath (no sessions, every request self-contained); the new revision just made that shape official wire protocol. Dual-era: requests carrying their protocol version in params._meta are served per 2026-07-28 (server/discover probe, per-request version check with UnsupportedProtocolVersionError, Mcp-Method/Mcp-Name header-body validation, resultType + serverInfo stamps, ttlMs/cacheScope freshness hints on the list/read surfaces so hosts can cache tools/list); initialize-handshake clients get the legacy behavior byte-for-byte unchanged. No tool behavior changed — same tools, same arguments, same results.
v1.33.2 2026-08-05
Connector reliability fix. The server never answered the MCP ping liveness probe (it returned method-not-found), which let clients mark the connector as 'not responding' until the user asked for a retry — ping now returns the spec's empty result. Also: initialize accepts protocol version 2025-11-25 (what current Claude offers) instead of silently downgrading the client to 2025-06-18, and the CORS preflight allows all request headers so browser-based clients on newer MCP revisions (which require Mcp-Method/Mcp-Name headers) are not rejected before the request body is ever read. No changes to any generated artifact.
v1.33.1 2026-07-23
Internal refactor, zero wire changes: the MCP tool schemas (the tools/list contract) moved from mcp.js into their own module, mcp-tools.js, so the server's highest-churn file carries only handlers and JSON-RPC plumbing. The full tool-contract snapshot test locks that nothing on the wire moved.
v1.33.0 2026-07-23
/build previews with the real parser. The browser generator used to hand-mirror the spec DSL for its live preview (kept in sync by a test over the golden specs); now the actual spec module is served at GET /spec.js and /build imports it — the preview is planText by construction, for every input, and an invalid spec shows the encoder's real error message instead of a best-effort guess. No agent-facing changes.
v1.32.2 2026-07-23
Quality hardening, invisible in use. Every inline <script> on every page and widget card (19 scripts across 12 pages + 5 cards) is now syntax-checked at test time — client-JS typos used to ship silently and die in the runner's browser. The semantic step-kind color palette (warmup-green, work-orange, rest-grey) is declared once in web.js and interpolated into both the session pages and the cards, instead of living as hand-typed hex literals in two files.
v1.32.1 2026-07-23
Wire diet. tools/list is paid by every conversation before the runner says a word — it was 57.6 KB of tool schemas whose descriptions had absorbed guide-grade teaching prose, one sentence per lesson. Descriptions now say what/when and point at the guide tools for method (workout_design_guide, tune_session_guide, review_session_guide — fetched on demand); nothing was deleted, only de-duplicated, and a 52 KB budget test keeps it from creeping back. The long-poll collect habit is now single-sourced (one constant interpolated into every relay-flow note — the peek/collect bug came from the same instruction drifting across retellings). whats_new without since now returns the most recent 10 entries with a pointer to /changelog (since-scoped calls are never truncated).
v1.32.0 2026-07-23
The post-run review now catches stale profiles. Field finding: a runner whose only interaction is the post-run loop went weeks — through numerous material changes — without ever being offered the delta re-interview, because version awareness only fired on read_workout_link/whats_new, which that loop never calls. review_session_guide now stamps server_version + a version_note in its result, and the review method gained step 0: compare server_version against the profile's Profile version; when it's older or missing, call runner_profile_interview(since=<Profile version>) and — if the delta is non-empty — ask the runner with a structured question (catch up now, ~2 minutes, or after the review?) and run their pick before continuing the review, then restamp Profile version.
🤖 For agents: New habit: START every post-run review with the version check — review_session_guide's server_version vs the profile's 'Profile version'. When they differ (or the profile has no version), run runner_profile_interview(since=<Profile version>) and offer any non-empty delta as a structured question before the review; restamp Profile version to current afterwards.
v1.31.0 2026-07-23
The Strava post is a social post, not a coaching report. The review guide now separates the two channels: the full review — lap grades, what it means, what to change — is coach feedback and belongs in the chat reply to the runner; the public Strava description stays terse (a line or two) and free of sensitive detail — no heart-rate numbers, no health or injury context, no sleep/stress/life circumstances unless the runner explicitly asks. log_to_strava enforces the nudge where agents actually read it: a verbose summary returns tone_warning, a heart-rate/health-smelling one returns privacy_warning (guidance, never a block — the update still builds). The review guide also gained an escalation rule: red flags in the data or the runner's answers (pain, RPE far above intent, unexplained misses, HR drift, log trends) go to the runner as a structured question (AskUserQuestion / elicitation) with concrete options — keep / ease / swap / rest — and the answer drives the retune or plan change; never silently rewrite the plan.
🤖 For agents: Two habit changes in the post-run flow: (1) split the channels — say the coaching analysis in your chat reply and keep the Strava description a terse public-safe social post; heed tone_warning/privacy_warning on log_to_strava results by re-calling with a shorter, health-free summary. (2) When a run or the runner's answers raise a red flag, ask a structured question with concrete options (keep / ease / swap / rest) BEFORE changing the plan or the next sessions.
v1.30.0 2026-07-20
The agent waits for the drop itself — no announcing needed. fetch_strava_handoff gained wait_s (long-poll, max 30 s per call): the server holds the request open and answers the moment the runner taps 'Send to your assistant'. Since hosts don't let widgets start a turn (Claude drops ui/message and ui/update-model-context), the guidance is now: hand out the page link, then immediately call fetch_strava_handoff(channel, key, wait_s: 25) and chain calls (~8 rounds ≈ 4 min) while the runner works through the page — routes, activities and the applied-confirmation all arrive mid-turn without the runner typing anything. The inline cards also poll faster right after rendering (1.2 s ramping to 3 s, with a stall watchdog), so the card flip is snappier.
🤖 For agents: New habit for every relay flow (pull_strava_routes, get_strava_activities, log_to_strava): after handing the runner the link, DON'T end your turn or wait to be told — immediately long-poll fetch_strava_handoff(channel, key, wait_s: 25), chaining calls while they work. You'll have the data the moment they tap Send; only after ~8 empty rounds ask them to tell you when it's sent.
v1.29.1 2026-07-20
Peek vs collect — a live fix. Field finding (Claude, 20 Jul 2026): the routes card showed the sent routes, but the agent's fetch_strava_handoff then found the mailbox EMPTY — the card's polling had consumed the one-shot drop, and on hosts that don't deliver ui/update-model-context the card is a dead end for data. Now the cards only PEEK (fetch_strava_handoff gained peek: true — read without consuming); the agent's plain collect is the only read that empties the mailbox, so the drop always waits for it even after the card displays it. The drop TTL grew from 5 to 15 minutes (a conversational pace starved the old window), and the received-state cards now say the words that always work: tell your assistant 'pull in my routes' / 'pull in my activities'.
🤖 For agents: If a runner says the card shows their routes/activities but you found the mailbox empty: that was pre-1.29.1 behavior (the card consumed the drop) — ask them to tap Send again; from 1.29.1 the card never consumes. Your habit stays: when the runner says it's sent (or the card shows it), call fetch_strava_handoff(channel, key) — without peek — to collect.
v1.29.0 2026-07-20
Pass-back by default. The spike's live loop is now the standard shape for every browser↔agent hand-off: get_strava_activities mints the same encrypted one-shot channel as pull_strava_routes (the activities page gained 'Send to your assistant'; the card collects the list, shows it, and feeds it to the model — paste and full-export stay as fallbacks), and log_to_strava mints an applied-confirmation channel: when the runner's browser successfully writes to Strava, the page confirms through the relay, the Strava card flips to '✓ Applied', and the agent can verify with fetch_strava_handoff instead of assuming. The widget shell owns one shared polling loop for all cards. Field finding baked in (Claude, live 20 Jul 2026): hosts may silently drop ui/message — the routes card's ask button now times out after 4 s and falls back to telling the runner to just type it; no other card grew ui/message buttons until hosts deliver it. The interview and design guides now route agents to the send-back flows first.
🤖 For agents: Two habits. (1) Hosts without Strava tools: activities now come back like routes — hand out activities_url, the runner taps Send, you receive via the card or fetch_strava_handoff(channel, key); stop asking for pastes first. (2) After log_to_strava: the card's '✓ Applied' (or fetch_strava_handoff on the returned channel — format watchout-strava-applied/1) CONFIRMS the write landed; check it before telling the runner their log is on Strava.
v1.28.0 2026-07-20
Widget→agent pass-back, spiked on the route round-trip. New tool pull_strava_routes: hand the runner routes_url; on the page they mark saved routes and tap 'Send to your assistant' — the selection is AES-GCM-encrypted in their browser (the key rides the URL fragment, which never reaches any server) and parked in a one-shot five-minute mailbox (fetch_strava_handoff collects it once; collected or expired, it's gone — the server only ever holds ciphertext it cannot read). The inline routes card polls the mailbox itself on hosts that let widgets call server tools (MCP Apps hostCapabilities.serverTools), renders the received routes, hands them to the model via ui/update-model-context, and offers an 'Ask for a recommendation' button (ui/message). No relay configured, or no widget support? Everything degrades to the existing copy-paste block. The widget shell now exposes the MCP Apps pass-back channels (tools/call, ui/update-model-context, ui/message) to all cards; /privacy documents the mailbox honestly. get_strava_activities is unchanged (activities still paste-based).
🤖 For agents: Reading saved Strava routes on hosts without route-capable Strava tools: prefer pull_strava_routes over get_strava_activities' routes_url — the runner then sends routes with one tap instead of copy-pasting, and you receive them via the card or fetch_strava_handoff(channel, key). Ground each polyline5 with check_route as before.
v1.27.0 2026-07-20
QR handoff. /w and /plan render their own URL as a scannable QR code behind an 'On your phone? Scan to open' reveal — the desktop→phone hop, since the phone is where the .workout and .ics actually install. Server-rendered SVG from a new zero-dependency QR encoder (byte mode, EC level L, versions 1-40, capacity 2953 bytes — long plan links included); every version is verified matrix-identical to a reference encoder and round-tripped through a real decoder (scripts/verify-qr.mjs), and the matrix is hash-locked in the test suite. No changes to links, files or identity.
v1.26.0 2026-07-20
Browser quick-wins. The /w session page gained a Share button (Web Share sheet on mobile, clipboard elsewhere — one-shot auto-download triggers are stripped from the shared link), a print stylesheet (printing a session gives a clean trackside sheet: title, chips, forecast, profile, steps, note — no buttons or chrome), and its three calendar links collapsed to one decision: Add to Calendar up front, the Google variants (upsert import + one-off quick-add) behind a 'Using Google Calendar?' disclosure. /build now detects the browser's timezone (scheduled links carry tz= instead of silently meaning Oslo wall clock) and says out loud which day the event lands on ('(today)' when no date is set). The Strava pull pages (/strava/activities, /strava/routes) fetch on load when connected — the copy-paste loop is now open → copy → paste — and name the connected athlete; /connections records the OAuth-granted scope, and the routes page states saved-routes access up front instead of letting an empty list make the runner guess. The landing hero frames the sample workout as the try-it-now path for visitors without an AI subscription.
v1.25.0 2026-07-20
Server quick-wins. New `now` tool: the ORIENT clock as a callable — current UTC plus, given the runner's IANA timezone, their local wall clock (start-format time, weekday, ISO week); call it first in a fresh chat when you hold no link (read_workout_link already carried now.utc). Deliberately no location: MCP calls arrive from the host's datacenter, so place/timezone always come from the runner's profile. The workout card now shows the runner-facing garnish — the 🌤 forecast line under the chips and the 📝 coach's note under the steps (make_workout_links echoes both as output fields, truncated as displayed); the plan card's week dividers carry weekly ≈km subtotals like the plan page, and the /plan page flags sessions that carry a note (📝 on the sub line, text inside the collapsed steps). check_session warns when a bare rest number smells like intended meters ('r400' = 400 SECONDS; write r400m for a 400 m jog). The design guide gained a ladders & pyramids worked example (one bare block runs its whole step sequence once). The met.no User-Agent now derives from the build stamp, and /api + OpenAPI name every tool (now test-locked against the live tool list).
v1.24.0 2026-07-20
Fresh-chat orientation. The server instructions now OPEN with an ORIENT step: a returning runner's conversation starts mid-plan, so before answering, fix the clock, recover the plan (memory → the runner's calendar via read_workout_link → a pasted link), and map the opener to a flow ('back from my run' = review the latest Strava activity against the planned session; 'what's today?' = deliver/tune the already-planned one) instead of re-interviewing or improvising a generic session. read_workout_link results are now time-anchored: every result carries the server clock (now.utc) and each scheduled session a when verdict (past/today/upcoming, computed in its own timezone); plan reads also return next_slot — where the plan stands right now. The review guide says how to find the planned session from the calendar in a fresh chat (match by start time), and the tune guide reads today's event itself via host calendar tools before asking the runner to paste a link. The profile interview gained a second output: a paste-ready standing-instructions block (Claude project description / ChatGPT custom instructions) handed to the runner at the end, so their fresh chats orient first — the profile steers the agent, but standing instructions are what run first in a new conversation.
🤖 For agents: Fresh conversations: orient before answering. Anchor 'today' to read_workout_link's now.utc and when verdicts — not to your own sense of date — and recover the plan from the runner's calendar instead of re-interviewing them or building a new generic session over the existing plan.
🎤 Interview change: The interview now ends by handing the runner a paste-ready standing-instructions block (project description / custom instructions) built from their profile — see the guide's 'Output 2'. Runners profiled before this never got one: build it from the existing profile and offer it on the next touch; nothing to re-ask unless the profile lacks the calendar name.
v1.23.0 2026-07-20
Browser-flow polish. The /plan page gained the same Apple/Garmin toggle /w has (remembered choice, ?dev=garmin presets it) — the Garmin bulk-send button now fronts only for Garmin runners, and it explains itself when idle like /w's does. A log_to_strava deep link that lands on /strava before the runner has connected no longer loses its prefilled update: the payload survives the OAuth detour and /connections offers 'Continue logging your run' when the token lands. The create-your-own-app steps on /connections now include Strava's required Website field (exactly where novices stalled), and /w's GPX hint follows the platform toggle instead of always addressing both watches.
v1.22.0 2026-07-20
Calendar coherence at retune time. make_workout_links now returns ics_upsert_url for single sessions (plans already had it): Google Calendar updates a known UID on import, so Google runners' retunes replace the event in place instead of needing the iOS '(rev N)' import-and-delete dance. The tune guide's Deliver step now branches on the runner's calendar delivery mode (agent-written = update the event in place, Apple .ics = new rev event + delete old, Google .ics = re-import ics_upsert_url), and the profile's Calendar field records the .ics flavor so the right path is known without re-asking. The /w page grew the same 'Using Google Calendar?' import hint the plan page had, and its Google quick-add link is labelled as the one-off it is (it never tracked retunes). Internally the agent calendar payloads (calendar_event, plan anchor_event) are now built in calendar.js next to the .ics they mirror — fixing a drift where the anchor title differed between the two delivery paths.
🤖 For agents: Two habits to adopt. (1) Google-runner single sessions: hand ics_upsert_url (or write calendar_event directly), not ics_url or the gcal quick-add — retunes then replace in place; sessions delivered before 1.22.0 have no upsert link, but rebuilding with the same uid (seq+1) returns one. (2) Retunes: pick the delivery path from the profile's Calendar field instead of defaulting to the iOS import-and-delete instruction.
🎤 Interview change: The profile's Calendar field now records the .ics FLAVOR when the host has no calendar tools: '.ics (Apple)' or '.ics (Google, upsert)' instead of the bare '.ics import'. One question: which calendar app does the runner import into? Update existing profiles on the next touch — the flavor picks the retune path.
v1.21.3 2026-07-20
MCP schema truth-up. Tool metadata now matches what the tools actually do: log_to_strava's apply_url (the card's one-tap 'Update as is' button) and the link_note on make_workout_links/make_plan_links are declared in the output schemas; make_workout_links and log_to_strava are annotated open-world (they fetch Open-Meteo elevation for route visuals when route_new/fx is present); get_weather's coaching field got its missing type. workout_format_help now returns only the format documentation instead of re-shipping the entire workflow prose. The guide prose is also dual-published as MCP resources (watchout://guide/…) for hosts that pin or preload resources. Plan builds no longer pay for a per-slot elevation fetch whose result was discarded. No changes to links, files or identity.
v1.21.2 2026-07-20
Internal restructure, no behavior changes to links, files or tools: the chat widget surface got its own module (src/widgets.js — bridge shell, all four cards, their ui:// registration and the route-viz payload builders, previously spread across pages.js and mcp.js), a tiny web.js leaf now holds escapeHtml + CORS, and the display-vs-identity parameter taxonomy is declared once in spec.js with every strip list derived from it. One small fix rode along: one-shot page triggers (autocal/autogen/autofit) hand-built into an /ics link no longer survive into the calendar event's URL, so an event link can never re-fire a download on open. Workout bytes, UUIDs and all generated links are unchanged (golden tests pass without regeneration).
v1.21.1 2026-07-20
Polish sweep from the four-surface review. Malformed /w and /plan links now render a branded helper card (200, like the cut-link case) instead of dumping a plain-text API reference at the runner — on /plan, one broken session no longer takes down the whole page. The MCP instructions no longer claim Strava is read-only-never-write (that predated log_to_strava; the tool IS the sanctioned write path, official-connector reads stay read-only). The chat workout card stopped showing agent-directed warning text to the runner. Also: /strava/routes uses the quiet agent-surface header like the other deep-link pages, the default .ics filename is 'workout.ics' (was a Norwegian leftover), the OpenAPI /mcp summary lists all fifteen tools, the /w and card 'set/estimated' distance split is labelled ('set as distance · estimated from timed steps'), and a small a11y pass (aria-pressed on toggles, status roles on the Garmin send message, labelled API-key field, higher footer/fine-print contrast).
v1.21.0 2026-07-20
Multi-select on /strava/routes. The runner can mark several saved routes (tap to toggle, Select all) and copy ONE paste block carrying all of them — single routes keep the exact 'WatchOut route:' shape, several become 'WatchOut routes (N):' with numbered entries. The page no longer scroll-jumps to the block; the copy button lights up and counts the marked routes instead. When a runner pastes a multi-route block, ground each polyline with check_route and recommend which fits the session — don't just take the first.
v1.20.0 2026-07-18
Delivery-aware inline cards. make_workout_links and make_plan_links take calendar_delivery: 'agent' — the agent's declaration that it writes the returned calendar_event / calendar.events itself via host calendar tools (the 1.18.0 connector flow). The chat cards then match what actually happened: a green '✓ On your calendar' line replaces the Add-to-Calendar / Add-all-to-Calendar buttons (whose .ics import would duplicate the events the agent just wrote), the plan card's primary action becomes Open plan page, and the Google Calendar file links are dropped. Display-only: files, links and identity are untouched, and omitting the argument keeps the cards exactly as before.
🤖 For agents: Adopt the flag: whenever you deliver a session or plan by writing its calendar events directly (host calendar tools), pass calendar_delivery: 'agent' on that make_workout_links/make_plan_links call — and only then, since the card tells the runner the events are already on their calendar. Nothing to refresh: cards already rendered are chat history, and existing links are unaffected.
v1.19.0 2026-07-18
Version-stamped links + delta re-interview. Every page/calendar link built by make_workout_links/make_plan_links now carries v=<server version>, so the runner's calendar — the durable state since 1.18.0 — records which build made each artifact, not just agent memory. read_workout_link reads it back as generated_with and, when the link is older than the server, inlines changes_since (the material changelog entries) so a fresh-chat recovery can offer a refresh unprompted; unstamped links (pre-1.19, hand-built, /build) get a version_note instead. Changelog entries gained interview_note (the interview PROCESS changed — what to re-ask the runner), runner_profile_interview takes since=<Profile version> and returns only those process changes, and the profile template gained a Profile version field (stamped from the tool's interview_version) to key the delta. File links (/gen, /fit) stay unstamped — workout bytes and identity are untouched.
🤖 For agents: Links generated before 1.19.0 carry no v= — read_workout_link flags them with a version_note; next time you touch such a session, rebuild it with the same uid (seq+1) and its calendar copy gets stamped. Add '**Profile version**: <current server version>' to profiles you already hold and update it on every (re-)interview; from now on, when whats_new or changes_since shows an entry with interview_note, call runner_profile_interview(since=<the profile's version>) and ask the runner only the new questions.
🎤 Interview change: The profile gained a '**Profile version**' field — set it from the interview tool's interview_version and bump it whenever you update the profile; it keys these delta re-interviews. Nothing new to ask the runner.
v1.18.0 2026-07-18
Plans persist in the runner's calendar, not just in agent memory. make_workout_links results gain calendar_event and make_plan_links results gain calendar (one ready-to-write event object per session plus an all-day 📋 anchor event carrying plan_url): on hosts WITH calendar tools (a Google Calendar connector or similar) agents write events directly — updates replace in place, skipping the ICS seq/'(rev N)' dance — and any later chat recovers a session or the whole plan by listing the runner's events and calling read_workout_link on the watchout.run links found there. Every plan session's page/calendar link now carries plan=<plan_id> (membership metadata — never identity or file links), so ONE pasted event names its plan; make_workout_links takes plan_id to keep membership on retunes, and read_workout_link surfaces it. The protocol picks ONE calendar, chosen once with the runner (a dedicated training calendar recommended) and saved in the profile. /plan.ics grew the same 📋 anchor VEVENT (its URL is the plan page, revision-scoped like sessions), and the /plan page has a 'Pick this plan up in a new chat' copy block.
🤖 For agents: Adopt the calendar habit: when the host has calendar tools, prefer writing calendar/calendar_event objects over handing out .ics links, create the plan anchor event, and pick the delivery calendar ONCE with the runner (save name + id in the profile). When retuning a plan slot, pass plan_id to make_workout_links so the rebuilt link keeps naming its plan. Plans delivered before 1.18.0 have no anchor and their session links carry no plan= — recovery still works from any session event's link (read_workout_link), and re-importing the plan's current .ics (same plan_id, same uids — no seq change needed) adds the anchor and the membership-tagged links without duplicating sessions.
🎤 Interview change: New profile field: '**Calendar**' — the ONE delivery calendar. Host has calendar tools? Ask the runner which calendar their workouts should land in (a dedicated training calendar is the recommendation — tidy and separable), then save the choice (name + id) in the profile and use it for every session. No calendar tools on this host? Record '.ics import' instead.
v1.17.0 2026-07-17
The GPX course (/route.gpx) now carries real elevation. Strava polylines are lat/lon only, so the track used to land as "Elevation: None" in apps that draw a profile — WorkOutDoors on Apple Watch, Garmin Connect. We now drape heights from the same Open-Meteo DEM that check_route uses (Copernicus GLO-90, ~90 m) onto every trackpoint via <ele>, so those apps show the climb/descent profile and detect hills. If the elevation service is unreachable the file degrades to the previous flat track, never an error. Existing /route.gpx links pick this up automatically.
🤖 For agents: No action needed on your side — /route.gpx links you already handed out now serve elevation transparently. Worth knowing when a runner uses WorkOutDoors (Apple Watch maps/navigation): our GPX gives them the route WITH its elevation profile, but WorkOutDoors cannot import the structured pacing/intervals from any file — that stays on the native .workout file (or they re-enter it in WorkOutDoors' own interval editor).
v1.16.0 2026-07-15
Widget card file buttons (Add to Apple Watch, Garmin FIT) now attempt the MCP Apps draft ui/download-file first — a native, browser-free file hand-off — when the host advertises hostCapabilities.downloadFile at ui/initialize. No host ships the capability yet, so behavior is unchanged today (buttons keep routing via the workout page); this self-activates the day a host does. Calendar buttons deliberately stay page-routed: a downloaded .ics is a file, not an import.
v1.15.1 2026-07-15
Landing page polish: the connector setup instructions are a compact card instead of a text wall, a slim nav bar slides in when scrolling back up the page (the hero stays chrome-free), and the hero no longer overlaps its scroll cue when the instructions are open. Display-only — no API or tool changes.
v1.15.0 2026-07-15
Route ground truth + layered route visuals. New check_route tool (polyline → real distance, elevation gain, per-km grades, flat stretches for reps, loop detection — terrain from Open-Meteo) and an unfamiliar-territory protocol in workout_design_guide (anchor logistics, get a real route, never invent geography). Route visuals are glanceable everywhere: the map line carries ONE color story (terrain grades pre-run, effort vs plan post-run) and terrain always shows as an elevation silhouette — hills as bumps — with the post-run effort band along its baseline. Effort/grade use their own cool color-way (cyan→blue→violet/fuchsia→red), disjoint from the step-kind colors (green warmup/cooldown, orange work, grey rest). make_workout_links takes route_new (unknown route → page map opens expanded, chat card shows it terrain-painted); log_to_strava takes fx + the session link (card shows the effort map, deviation-colored session bars and the silhouette; the description's session link opens the effort view). The route round-trip is closed: /strava/routes lists the runner's saved Strava routes client-side as paste blocks carrying the polyline, with the build-a-route steps on the page.
🤖 For agents: For sessions in places the runner doesn't know (travel, race trips): get a route polyline first — the runner's saved Strava routes via their Strava tools, or the round-trip in workout_design_guide (they save a suggested route in the Strava app and paste it back from watchout.run/strava/routes) — ground it with check_route before designing, and pass route_new: true with route_polyline. At review time pass fx (one '<meters>[@<±s/km>]' per lap) plus the session link to log_to_strava so the runner sees where the plan held.
v1.14.1 2026-07-14
Widget card file buttons (Add to Apple Watch, Garmin FIT, plan Google Calendar file) route via the /w and /plan pages with auto-download triggers — a bare .workout/.fit/.ics URL handed to the host's open-link dies on iOS (no browsing context), which made the card's watch button a no-op on Claude iOS.
v1.14.0 2026-07-14
Version awareness: this changelog (/changelog, /changelog.json), the whats_new tool, and generated_with stamped on every make_workout_links/make_plan_links result.
🤖 For agents: Start saving generated_with next to plan manifests and workout uuids in your memory. When you pick up a plan or session made under an older version, call whats_new(since=<that version>) — if it returns material entries, tell the runner in one line and offer a refresh (same uids, seq+1).
v1.13.0 2026-07-14
read_workout_link: any pasted WatchOut link (/w, /ics, /gen, /fit, /plan) decodes back into ready-to-reuse session fields, uid and revision included. New revision_warning when a uid is reused on a scheduled session without a seq bump.
🤖 For agents: Lost manifests are no longer dead ends — recover a session or a whole plan manifest from nothing but the link in the runner's calendar event.
v1.12.0 2026-07-14
Calendar events became runner briefings: ≈distance/time estimate line, steps one per line, optional coach's note (note= → 📝) and forecast (wx= → 🌤), and no duplicate links in the description (the event's URL field owns the page link).
🤖 For agents: Events generated before 1.12.0 carry the old wall-of-text description with duplicate links and no note/weather. Next time you touch such a session, rebuild it with the same uid, seq+1, and pass note + weather.
v1.11.0 2026-07-14
Warmup/cooldown became a per-session decision — distance leg, timed warmup, open, or 'none' — with a warning when omitted (the silent legacy default is 10 min each). Midnight start times are warned as placeholders.
🤖 For agents: Sessions built before 1.11.0 may carry a 10-min warmup/cooldown that was never a decision. Revisit door-to-door easy runs especially: they usually want 'none'.
v1.10.4 2026-07-14
Inline widget cards fixed for Claude (MCP Apps: full HTML documents, spec-shaped ui/initialize, no ui.domain, v2 resource URIs) and calendar buttons routed via the workout page so iOS imports the event instead of subscribing to it.
Machine-readable: /changelog.json. Agents: the
whats_new MCP tool takes since=<generated_with> and returns only
what changed after the version that built your saved plan or workout.