MCP App Store

Overview

Joules helps cyclists review their training, plan upcoming weeks, adjust workouts around life events, log completed sessions, and keep race and coaching context current through ChatGPT.

Tools

addRace

ChatGPT
<usecase> Adds a single race event to the athlete's calendar. Use when the athlete mentions a race they have signed up for or want to put on the calendar — addRace lands the event by itself, no planning session required. Returns the new eventId so a follow-up removeRace can target it if the athlete changes their mind. </usecase> <instructions> date is the race day in YYYY-MM-DD; past dates are rejected (athletes can't sign up retroactively). name is the race title. priority is A, B, or C — match what the athlete says, defaulting to B if they don't specify. estimatedLoad (optional) is the expected race TSS — leave it unset rather than guessing. targetTSB (optional) is how fresh the athlete should arrive: A-race 10–15, B-race 5–10, C-race 0–5. Requires the joules.write OAuth scope and an active Joules subscription. </instructions>

addRace

ChatGPT
<usecase> Adds a single race event to the athlete's calendar. Use when the athlete mentions a race they have signed up for or want to put on the calendar — addRace lands the event by itself, no planning session required. Returns the new eventId so a follow-up removeRace can target it if the athlete changes their mind. </usecase> <instructions> date is the race day in YYYY-MM-DD; past dates are rejected (athletes can't sign up retroactively). name is the race title. priority is A, B, or C — match what the athlete says, defaulting to B if they don't specify. estimatedLoad (optional) is the expected race TSS — leave it unset rather than guessing. targetTSB (optional) is how fresh the athlete should arrive: A-race 10–15, B-race 5–10, C-race 0–5. Requires the joules.write OAuth scope and an active Joules subscription. </instructions>

buildWeek

ChatGPT
<usecase> Builds and validates one week of training, persisting it as a committable proposal. Renders NO card — this is the loop tool: call it repeatedly to converge the week into tolerance, then call presentWeek({ proposalId }) to surface the week-preview card the athlete accepts. Pass the full event list for Mon–Sun — omitted days are rest days. Each event is processed (solver for system workouts, custom workouts get their load calculated, outdoor rides take their target load, races take their estimated load), load is summed, and the draft is checked against the week's target load (±20%). Returns a proposalId plus the processed week, an inTolerance flag, and a day-by-day fitness projection. Re-building the same week replaces the prior pending draft. </usecase> <instructions> weekStart is the Monday YYYY-MM-DD returned by getPlanningContext (also accepts any date inside the desired ISO week — snapped to Monday). events is an array of PlannedEvent objects — each with a day (monday…sunday) and exactly one of system_workout / custom_workout / outdoor_ride / race (or none for a rest day). Events placed on lockedDays from the planning-context payload are rejected. Scheduled races (in scheduledRaces) are NOT locked: you can place a workout the same day. narrative is required — the athlete-facing message describing the week, frozen now because the card's Accept button commits without another model turn. Plain text; open with what the week is about, walk through key sessions, close with what to watch for. Pass targetLoadOverride to pin a deliberately lighter or heavier week (time crunch, deload, life) — the week is then checked against that number instead of the planned target, and committing records it as this week's target. Omit it to plan to the planned target. Get the draft in tolerance, then call presentWeek to show it; if it lands out of tolerance, adjust the events and call buildWeek again. </instructions>

buildWeek

ChatGPT
<usecase> Builds and validates one week of training, persisting it as a committable proposal. Renders NO card — this is the loop tool: call it repeatedly to converge the week into tolerance, then call presentWeek({ proposalId }) to surface the week-preview card the athlete accepts. Pass the full event list for Mon–Sun — omitted days are rest days. Each event is processed (solver for system workouts, custom workouts get their load calculated, outdoor rides take their target load, races take their estimated load), load is summed, and the draft is checked against the week's target load (±20%). Returns a proposalId plus the processed week, an inTolerance flag, and a day-by-day fitness projection. Re-building the same week replaces the prior pending draft. </usecase> <instructions> weekStart is the Monday YYYY-MM-DD returned by getPlanningContext (also accepts any date inside the desired ISO week — snapped to Monday). events is an array of PlannedEvent objects — each with a day (monday…sunday) and exactly one of system_workout / custom_workout / outdoor_ride / race (or none for a rest day). Events placed on lockedDays from the planning-context payload are rejected. Scheduled races (in scheduledRaces) are NOT locked: you can place a workout the same day. narrative is required — the athlete-facing message describing the week, frozen now because the card's Accept button commits without another model turn. Plain text; open with what the week is about, walk through key sessions, close with what to watch for. Pass targetLoadOverride to pin a deliberately lighter or heavier week (time crunch, deload, life) — the week is then checked against that number instead of the planned target, and committing records it as this week's target. Omit it to plan to the planned target. Get the draft in tolerance, then call presentWeek to show it; if it lands out of tolerance, adjust the events and call buildWeek again. </instructions>

commitWeek

ChatGPT
<usecase> Commits a week built by buildWeek — writes the processed events onto the athlete's calendar and stamps the week's narrative. Takes only the proposalId; the committable draft already lives server-side. Re-validates the frozen draft against live state first: if the target moved or a day got locked since the proposal was made, the commit is refused and you're sent back to buildWeek. Committing the same proposal twice is a no-op. </usecase> <instructions> proposalId is the wp_… token returned by buildWeek. The athlete usually commits via the Accept button in the week-preview card; call this only when committing on their behalf after they approve in chat. </instructions>

commitWeek

ChatGPT
<usecase> Commits a week built by buildWeek — writes the processed events onto the athlete's calendar and stamps the week's narrative. Takes only the proposalId; the committable draft already lives server-side. Re-validates the frozen draft against live state first: if the target moved or a day got locked since the proposal was made, the commit is refused and you're sent back to buildWeek. Committing the same proposal twice is a no-op. </usecase> <instructions> proposalId is the wp_… token returned by buildWeek. The athlete usually commits via the Accept button in the week-preview card; call this only when committing on their behalf after they approve in chat. </instructions>

findActivities

ChatGPT
<usecase> Search/identify specific activities and pick one to drill into. This is a SHORT-LIST tool — recent rides, filtered drill-downs, "find that race" lookups. Returns up to ~25 activity rows with the fields needed to identify a ride (id, name, date, sport, duration, load, NP, avgHr) so the model can either describe a single ride or pass an id to getActivityDetail for streams/laps/equipment. Not for time-aggregated questions like "how much did I train this month" or "how has my volume trended" — call getFitnessHistory (CTL/ATL + per-day load) or getWeekSummary (one week's totals) for those. Returning all rides over a long window so you can sum them is the wrong shape; this tool caps results at 25. </usecase> <instructions> All inputs optional. Date range — pick one: window shorthand ("7d", "14d", "28d", "90d", "180d", "1y", "5y", "all"; default "28d") OR startDate + endDate for an explicit calendar range around a known event. Long windows are usually the wrong call here — if the user is asking about overall volume use getFitnessHistory instead. Sportsport filters by activity type (common: "Ride", "VirtualRide", "GravelRide", "MountainBikeRide", "Run", "Swim"); when omitted, filters to the canonical "all ride" bucket (Ride, VirtualRide, EBikeRide, MountainBikeRide, GravelRide, EMountainBikeRide, Handcycle, Velomobile) so MTB/gravel/virtual surface alongside road. Pass sport to narrow further or look at non-cycling. Match is on sport_type then activity_type. FiltersminDuration (seconds, matched against moving time with elapsed fallback), minLoad (power-based training load), nameContains (case-insensitive substring; when it matches nothing, data.nameSearchFallback carries the same-window candidates so you can locate the ride by date even when the athlete used a personal nickname). OrderorderBy: "recent" (default — newest first), "load_desc" (highest load first — top-N hardest), or "duration_desc" (longest first). Use load_desc / duration_desc with a longer window for "what was my hardest/longest ride this year" without dumping everything. Limitlimit defaults to 10, max 25. If the filter is too broad summary.truncated will be true; narrow the filter rather than raising the cap. Aggregates are intentionally not returned — see above. </instructions>

findActivities

ChatGPT
<usecase> Search/identify specific activities and pick one to drill into. This is a SHORT-LIST tool — recent rides, filtered drill-downs, "find that race" lookups. Returns up to ~25 activity rows with the fields needed to identify a ride (id, name, date, sport, duration, load, NP, avgHr) so the model can either describe a single ride or pass an id to getActivityDetail for streams/laps/equipment. Not for time-aggregated questions like "how much did I train this month" or "how has my volume trended" — call getFitnessHistory (CTL/ATL + per-day load) or getWeekSummary (one week's totals) for those. Returning all rides over a long window so you can sum them is the wrong shape; this tool caps results at 25. </usecase> <instructions> All inputs optional. Date range — pick one: window shorthand ("7d", "14d", "28d", "90d", "180d", "1y", "5y", "all"; default "28d") OR startDate + endDate for an explicit calendar range around a known event. Long windows are usually the wrong call here — if the user is asking about overall volume use getFitnessHistory instead. Sportsport filters by activity type (common: "Ride", "VirtualRide", "GravelRide", "MountainBikeRide", "Run", "Swim"); when omitted, filters to the canonical "all ride" bucket (Ride, VirtualRide, EBikeRide, MountainBikeRide, GravelRide, EMountainBikeRide, Handcycle, Velomobile) so MTB/gravel/virtual surface alongside road. Pass sport to narrow further or look at non-cycling. Match is on sport_type then activity_type. FiltersminDuration (seconds, matched against moving time with elapsed fallback), minLoad (power-based training load), nameContains (case-insensitive substring; when it matches nothing, data.nameSearchFallback carries the same-window candidates so you can locate the ride by date even when the athlete used a personal nickname). OrderorderBy: "recent" (default — newest first), "load_desc" (highest load first — top-N hardest), or "duration_desc" (longest first). Use load_desc / duration_desc with a longer window for "what was my hardest/longest ride this year" without dumping everything. Limitlimit defaults to 10, max 25. If the filter is too broad summary.truncated will be true; narrow the filter rather than raising the cap. Aggregates are intentionally not returned — see above. </instructions>

getAccountStatus

ChatGPT
<usecase> Returns the authenticated athlete's account state — current tier, trial days remaining if on trial, and subscription status if subscribed. Use this when the athlete asks about their trial, billing, or how to upgrade. </usecase> <instructions> Always available — works for trialing, pro, and expired users. tier is one of trialing, pro, or expired. trial.endsAt is unix seconds; trial.daysRemaining is 0 once the window has closed. subscription is populated when the athlete has paid (active, canceling, or past due). reUpUrl is a single-use authenticated link to app.joules.cc/account — present for expired users and trialing users with 9 or fewer days left. </instructions>

getAccountStatus

ChatGPT
<usecase> Returns the authenticated athlete's account state — current tier, trial days remaining if on trial, and subscription status if subscribed. Use this when the athlete asks about their trial, billing, or how to upgrade. </usecase> <instructions> Always available — works for trialing, pro, and expired users. tier is one of trialing, pro, or expired. trial.endsAt is unix seconds; trial.daysRemaining is 0 once the window has closed. subscription is populated when the athlete has paid (active, canceling, or past due). reUpUrl is a single-use authenticated link to app.joules.cc/account — present for expired users and trialing users with 9 or fewer days left. </instructions>

getActivityDetail

ChatGPT
<usecase> Drill into one specific ride. Returns the canonical record (duration, load, NP, FTP at the time, average/max HR, kilojoules) plus stream- derived coaching summaries when streams have been processed: per-quarter splits (avg power/HR/cadence by quartile of moving time), time-in-zone (power and HR), Pw:Hr decoupling between halves of the ride, and the ride's climbs (each with distance, gradient, elevation gain, and time- weighted power/HR/cadence over the climb). Per-activity power curve (best avg watts at 5s, 1m, 5m, 20m, 60m, …) is included when efforts have been computed. The natural follow-up to findActivities whenever the user wants to talk about a single session ("how was that race", "did pacing fall off in the back half", "where was he in zone 4", "what did he do on the climbs"). Use it eagerly — this is the only tool that exposes per-ride streams analysis. </usecase> <instructions> Pass id — the canonical activity id (uuid) printed as the first field of every findActivities row. If you only have a date / sport / nickname, call findActivities first and copy the id from the row you want. Stream summaries depend on streamsStatus === "completed"; when status is "pending" / "skipped" / "failed", summary.hasStreamData is false and the quartile/zone/decoupling fields are null. Climbs are populated for outdoor rides with an altitude stream; indoor rides, flat rides, and rides whose streams are still pending or were skipped by tier policy (free-tier rides > 90 days old) all have segments: [] — always read streamsStatus before concluding "no climbs" from the empty array. The ~360-sample downsampled preview arrays (watts/heartrate/altitude/cadence/distance, time-aligned via streamSamples.timeOffsets) ship in every response — the activity-detail widget renders them as a multi-series chart, and they're the ground truth for shape questions ("where did HR diverge from power?"). decoupling.driftPercent is one signal among many — read it with the climbs and quartiles, not on its own. </instructions>

getActivityDetail

ChatGPT
<usecase> Drill into one specific ride. Returns the canonical record (duration, load, NP, FTP at the time, average/max HR, kilojoules) plus stream- derived coaching summaries when streams have been processed: per-quarter splits (avg power/HR/cadence by quartile of moving time), time-in-zone (power and HR), Pw:Hr decoupling between halves of the ride, and the ride's climbs (each with distance, gradient, elevation gain, and time- weighted power/HR/cadence over the climb). Per-activity power curve (best avg watts at 5s, 1m, 5m, 20m, 60m, …) is included when efforts have been computed. The natural follow-up to findActivities whenever the user wants to talk about a single session ("how was that race", "did pacing fall off in the back half", "where was he in zone 4", "what did he do on the climbs"). Use it eagerly — this is the only tool that exposes per-ride streams analysis. </usecase> <instructions> Pass id — the canonical activity id (uuid) printed as the first field of every findActivities row. If you only have a date / sport / nickname, call findActivities first and copy the id from the row you want. Stream summaries depend on streamsStatus === "completed"; when status is "pending" / "skipped" / "failed", summary.hasStreamData is false and the quartile/zone/decoupling fields are null. Climbs are populated for outdoor rides with an altitude stream; indoor rides, flat rides, and rides whose streams are still pending or were skipped by tier policy (free-tier rides > 90 days old) all have segments: [] — always read streamsStatus before concluding "no climbs" from the empty array. The ~360-sample downsampled preview arrays (watts/heartrate/altitude/cadence/distance, time-aligned via streamSamples.timeOffsets) ship in every response — the activity-detail widget renders them as a multi-series chart, and they're the ground truth for shape questions ("where did HR diverge from power?"). decoupling.driftPercent is one signal among many — read it with the climbs and quartiles, not on its own. </instructions>

getActivityMetricsOverTime

ChatGPT
<usecase> Returns a per-activity time series for one of the computed/stored metrics that getActivityDetail surfaces — but across many rides at once. Use it when the user asks how a per-ride metric has trended across the season ("how has my durability/decoupling trended in long rides", "is my NP holding up on the same loops", "is intensity factor creeping up on Z2 days", "are my average HR numbers drifting") or wants to compare buckets of rides ("is my durability worse on MTB than road", "are gravel days harder than tarmac"). Supports stored metrics (normalized_power, intensity_factor, kilojoules, load, hr_load, average_heartrate, max_heartrate, moving_time, distance_meters) and the stream-derived Pw:Hr decoupling (a durability proxy — first-half vs second-half aerobic drift). Pair with renderChart when the user wants to see the trajectory; narrate from the points directly otherwise. </usecase> <instructions> Pass metric (one of normalized_power, intensity_factor, kilojoules, load, hr_load, average_heartrate, max_heartrate, moving_time, distance_meters, decoupling) plus a date range — either window shorthand ("28d", "90d", "1y", etc.; default "90d") or startDate + endDate for an explicit calendar range. Filters: - sport — string or array. Pass one sport ("MountainBikeRide") to scope to a single discipline, or an array (["Ride","VirtualRide", "MountainBikeRide","GravelRide"]) to scope to several at once. Omit to scope to the canonical "all ride" bucket. Match is on sport_type with activity_type fallback. - minDuration (seconds, against moving time with elapsed fallback) screens out commutes / coffee spins so the trend isn't dragged by short noise. - minLoad — minimum power-based load. Use when the question is about hard rides specifically. Comparison shape: - summary.bySport is always returned — an array of per-sport_type aggregates ({ count, dropped, min, max, mean, last }) over the same series. For road-vs-MTB ("is durability worse on MTB"), pass sport: ["Ride","VirtualRide","MountainBikeRide","GravelRide"] and read the per-bucket mean/last off summary.bySport — no need to re-aggregate the points yourself. The model maps the raw sport_type buckets to "road" vs "MTB" in its read. Results are chronological (oldest first) with each point carrying { date, value, activityId, activityName, sportType, durationSeconds }. Activities that don't have the requested metric populated (e.g. decoupling for a ride with no streams yet, or normalized_power on a Run) are dropped from the series — summary.dropped reports how many. Capped at 250 activities per call — summary.truncated is true when more rides matched; narrow the filter rather than raising the cap. This tool is NOT for "today's CTL" or "this week's total load" — call getFitnessHistory (day-level CTL/ATL/TSB) or getWeekSummary for those. </instructions>

getActivityMetricsOverTime

ChatGPT
<usecase> Returns a per-activity time series for one of the computed/stored metrics that getActivityDetail surfaces — but across many rides at once. Use it when the user asks how a per-ride metric has trended across the season ("how has my durability/decoupling trended in long rides", "is my NP holding up on the same loops", "is intensity factor creeping up on Z2 days", "are my average HR numbers drifting") or wants to compare buckets of rides ("is my durability worse on MTB than road", "are gravel days harder than tarmac"). Supports stored metrics (normalized_power, intensity_factor, kilojoules, load, hr_load, average_heartrate, max_heartrate, moving_time, distance_meters) and the stream-derived Pw:Hr decoupling (a durability proxy — first-half vs second-half aerobic drift). Pair with renderChart when the user wants to see the trajectory; narrate from the points directly otherwise. </usecase> <instructions> Pass metric (one of normalized_power, intensity_factor, kilojoules, load, hr_load, average_heartrate, max_heartrate, moving_time, distance_meters, decoupling) plus a date range — either window shorthand ("28d", "90d", "1y", etc.; default "90d") or startDate + endDate for an explicit calendar range. Filters: - sport — string or array. Pass one sport ("MountainBikeRide") to scope to a single discipline, or an array (["Ride","VirtualRide", "MountainBikeRide","GravelRide"]) to scope to several at once. Omit to scope to the canonical "all ride" bucket. Match is on sport_type with activity_type fallback. - minDuration (seconds, against moving time with elapsed fallback) screens out commutes / coffee spins so the trend isn't dragged by short noise. - minLoad — minimum power-based load. Use when the question is about hard rides specifically. Comparison shape: - summary.bySport is always returned — an array of per-sport_type aggregates ({ count, dropped, min, max, mean, last }) over the same series. For road-vs-MTB ("is durability worse on MTB"), pass sport: ["Ride","VirtualRide","MountainBikeRide","GravelRide"] and read the per-bucket mean/last off summary.bySport — no need to re-aggregate the points yourself. The model maps the raw sport_type buckets to "road" vs "MTB" in its read. Results are chronological (oldest first) with each point carrying { date, value, activityId, activityName, sportType, durationSeconds }. Activities that don't have the requested metric populated (e.g. decoupling for a ride with no streams yet, or normalized_power on a Run) are dropped from the series — summary.dropped reports how many. Capped at 250 activities per call — summary.truncated is true when more rides matched; narrow the filter rather than raising the cap. This tool is NOT for "today's CTL" or "this week's total load" — call getFitnessHistory (day-level CTL/ATL/TSB) or getWeekSummary for those. </instructions>

getAerobicEfficiency

ChatGPT
<usecase> Returns the athlete's aerobic efficiency (EF = watts per heartbeat on Z2 efforts) as a per-ride time series with a coach-facing summary. Use this when the user asks "is my base coming around", "is my Z2 work paying off", "is my aerobic engine improving", or "should I keep doing easy rides". A rising EF trend across weeks is the cleanest signal that Z2 base training is producing real cardiovascular adaptation — the athlete is making more power for the same heart rate, or holding the same power on a lower heart rate. EF is only computed for rides with both power and HR streams and at least 20 minutes inside Z2 by power; sprints, all-out intervals, HR-only rides, and rides without an FTP snapshot are excluded. </usecase> <instructions> Pass an optional window shorthand ("28d", "90d", "180d", "1y"; default "90d") or an explicit calendar range via startDate + endDate (mutually exclusive with window). sport filters by canonical activity type — string or array. Omit to scope to the "all ride" bucket. data.points is chronological (oldest first), capped at 250 entries; each carries { date, value, activityId, activityName, durationSeconds }. summary.trend is one of "improving" | "declining" | "stable" | "insufficient_data" — read this rather than re-deriving from the points. summary.pctChange compares the mean of the first 20% of points (baseline) against the mean of the last 20% (recent), expressed as a percentage (e.g. -6.8 means recent is 6.8% below baseline); null when fewer than 4 points are available. summary.trendSlope is the linear-regression slope in W/bpm per week. Athletes with no power meter, or windows with no qualifying Z2 efforts, return an empty points array and trend: "insufficient_data" — say that plainly rather than guessing. </instructions>

getAerobicEfficiency

ChatGPT
<usecase> Returns the athlete's aerobic efficiency (EF = watts per heartbeat on Z2 efforts) as a per-ride time series with a coach-facing summary. Use this when the user asks "is my base coming around", "is my Z2 work paying off", "is my aerobic engine improving", or "should I keep doing easy rides". A rising EF trend across weeks is the cleanest signal that Z2 base training is producing real cardiovascular adaptation — the athlete is making more power for the same heart rate, or holding the same power on a lower heart rate. EF is only computed for rides with both power and HR streams and at least 20 minutes inside Z2 by power; sprints, all-out intervals, HR-only rides, and rides without an FTP snapshot are excluded. </usecase> <instructions> Pass an optional window shorthand ("28d", "90d", "180d", "1y"; default "90d") or an explicit calendar range via startDate + endDate (mutually exclusive with window). sport filters by canonical activity type — string or array. Omit to scope to the "all ride" bucket. data.points is chronological (oldest first), capped at 250 entries; each carries { date, value, activityId, activityName, durationSeconds }. summary.trend is one of "improving" | "declining" | "stable" | "insufficient_data" — read this rather than re-deriving from the points. summary.pctChange compares the mean of the first 20% of points (baseline) against the mean of the last 20% (recent), expressed as a percentage (e.g. -6.8 means recent is 6.8% below baseline); null when fewer than 4 points are available. summary.trendSlope is the linear-regression slope in W/bpm per week. Athletes with no power meter, or windows with no qualifying Z2 efforts, return an empty points array and trend: "insufficient_data" — say that plainly rather than guessing. </instructions>

getFitnessHistory

ChatGPT
<usecase> Returns the athlete's CTL (fitness), ATL (fatigue), TSB (form), and daily load as a time series across a window — one point per calendar day. Use this whenever the user asks about a trend ("how has my fitness changed", "am I building or losing fitness", "what's my fitness trajectory"). The summary includes start/end CTL, peak CTL, and the net change so you can cite real deltas instead of inferring them from the latest snapshot. When called with endDate alone, the window is anchored to that date instead of today — used to reconstruct fitness state going into a past event. Pass startDate + endDate together for a precise calendar range (training blocks, seasons). Optionally request a comparison series in the same call to answer year-over-year or block-over-block questions. </usecase> <instructions> Range inputs (pick one style): omit both for a rolling window ending today; pass endDate alone with window to anchor a rolling window's end to a past date (use the activity's startDate from findActivities to answer "what was my fitness going into <event>" — pair with eventLabel for an event marker); pass startDate + endDate together for an explicit calendar range (do not also pass window). Comparison inputs (pick at most one): comparisonStartDate + comparisonEndDate for an explicit comparison range; comparisonMode: "same_dates_previous_year" for the identical calendar dates one year earlier; comparisonWindow: "previous_<28d|90d|180d|1y>" for the block of that length immediately preceding the current range. When a comparison is requested, data.comparison holds its window + points and summary carries the comparison-aware deltas (comparisonCtlDelta, ctlDeltaVsComparison, comparisonTotalLoad, totalLoadDeltaVsComparison). For any anchored response (a call with endDate), summary.eventSnapshot reports both preEvent (end-of-day stats from the day BEFORE the anchor — the true going-in state) and postEvent (end-of-day stats ON the anchor day, including that day's load). Use preEvent when reasoning about how the athlete entered the day. Days with no training-load data are omitted (no zero-padding). If summary.pointCount is 0 the athlete has no fitness history in the current range — say so and stop. </instructions>

getFitnessHistory

ChatGPT
<usecase> Returns the athlete's CTL (fitness), ATL (fatigue), TSB (form), and daily load as a time series across a window — one point per calendar day. Use this whenever the user asks about a trend ("how has my fitness changed", "am I building or losing fitness", "what's my fitness trajectory"). The summary includes start/end CTL, peak CTL, and the net change so you can cite real deltas instead of inferring them from the latest snapshot. When called with endDate alone, the window is anchored to that date instead of today — used to reconstruct fitness state going into a past event. Pass startDate + endDate together for a precise calendar range (training blocks, seasons). Optionally request a comparison series in the same call to answer year-over-year or block-over-block questions. </usecase> <instructions> Range inputs (pick one style): omit both for a rolling window ending today; pass endDate alone with window to anchor a rolling window's end to a past date (use the activity's startDate from findActivities to answer "what was my fitness going into <event>" — pair with eventLabel for an event marker); pass startDate + endDate together for an explicit calendar range (do not also pass window). Comparison inputs (pick at most one): comparisonStartDate + comparisonEndDate for an explicit comparison range; comparisonMode: "same_dates_previous_year" for the identical calendar dates one year earlier; comparisonWindow: "previous_<28d|90d|180d|1y>" for the block of that length immediately preceding the current range. When a comparison is requested, data.comparison holds its window + points and summary carries the comparison-aware deltas (comparisonCtlDelta, ctlDeltaVsComparison, comparisonTotalLoad, totalLoadDeltaVsComparison). For any anchored response (a call with endDate), summary.eventSnapshot reports both preEvent (end-of-day stats from the day BEFORE the anchor — the true going-in state) and postEvent (end-of-day stats ON the anchor day, including that day's load). Use preEvent when reasoning about how the athlete entered the day. Days with no training-load data are omitted (no zero-padding). If summary.pointCount is 0 the athlete has no fitness history in the current range — say so and stop. </instructions>

getPlan

ChatGPT
<usecase> Returns the athlete's training program settings (name, notes, principles, available days) plus the live plan — blocks in calendar order, each with its phase, week range, coach notes, and the per-week intent + load target (TSS) that defines the ramp. The shared planContext (current week position, current block, racing season, taper flag) rides on every plan-tool response, so call getPlan only when you need the deeper view: full block structure, full ramp, program-level settings. For one week's prescribed training, call getTrainingWeek instead. </usecase> <instructions> No arguments. The plan is implied by the authenticated athlete. When summary.hasPlan is false, the athlete hasn't set one up yet — direct them to call setPlan (or use the planning-session flow). data.plan.blocks[] is ordered first-to-last; each block's startWeek/endWeek are 1-indexed inclusive bounds into the plan, and each week carries weekStart (Monday YYYY-MM-DD — the natural key under the new schema), intent (base/build/recovery/peak/taper), and targetLoad (TSS). Each block exposes id (e.g. blk-42); pass it to updateBlock to rename the block, change its phase, or refresh its notes. Per-week TSS edits go through setPlan. </instructions>

getPlan

ChatGPT
<usecase> Returns the athlete's training program settings (name, notes, principles, available days) plus the live plan — blocks in calendar order, each with its phase, week range, coach notes, and the per-week intent + load target (TSS) that defines the ramp. The shared planContext (current week position, current block, racing season, taper flag) rides on every plan-tool response, so call getPlan only when you need the deeper view: full block structure, full ramp, program-level settings. For one week's prescribed training, call getTrainingWeek instead. </usecase> <instructions> No arguments. The plan is implied by the authenticated athlete. When summary.hasPlan is false, the athlete hasn't set one up yet — direct them to call setPlan (or use the planning-session flow). data.plan.blocks[] is ordered first-to-last; each block's startWeek/endWeek are 1-indexed inclusive bounds into the plan, and each week carries weekStart (Monday YYYY-MM-DD — the natural key under the new schema), intent (base/build/recovery/peak/taper), and targetLoad (TSS). Each block exposes id (e.g. blk-42); pass it to updateBlock to rename the block, change its phase, or refresh its notes. Per-week TSS edits go through setPlan. </instructions>

getPlanningContext

ChatGPT
<usecase> Opens a weekly-planning conversation. Returns the planning system prompt, the previous week's actuals for opening the conversation, the target week's bounds and TSS target, which days are off-limits because they're already completed or time-off, scheduled races to plan around, and the season-block context. Read-only — no server-side session is created; the conversation itself carries the draft state until you call buildWeek to persist a committable proposal. </usecase> <instructions> weekOf is optional — pass a YYYY-MM-DD inside the desired ISO week and the tool snaps to Monday. Default is the upcoming planning week (Mon–Thu → this Monday, Fri–Sun → next Monday). Requires the joules.write OAuth scope and an active Joules subscription. Always start the conversation by reading the previousWeek field and acknowledging what the athlete actually did before asking what's different this week — do not skip that step. buildWeek takes weekStart as a parameter; you can pass the same weekStart returned here. </instructions>

getPlanningContext

ChatGPT
<usecase> Opens a weekly-planning conversation. Returns the planning system prompt, the previous week's actuals for opening the conversation, the target week's bounds and TSS target, which days are off-limits because they're already completed or time-off, scheduled races to plan around, and the season-block context. Read-only — no server-side session is created; the conversation itself carries the draft state until you call buildWeek to persist a committable proposal. </usecase> <instructions> weekOf is optional — pass a YYYY-MM-DD inside the desired ISO week and the tool snaps to Monday. Default is the upcoming planning week (Mon–Thu → this Monday, Fri–Sun → next Monday). Requires the joules.write OAuth scope and an active Joules subscription. Always start the conversation by reading the previousWeek field and acknowledging what the athlete actually did before asking what's different this week — do not skip that step. buildWeek takes weekStart as a parameter; you can pass the same weekStart returned here. </instructions>

getPowerProfile

ChatGPT
<usecase> Returns the athlete's mean-max power curve — best average watts at standard durations from 5 seconds to 3 hours — over a window, plus the latest fitted CP/W' model. Optionally returns a second curve for any chosen comparison range so a single call can answer "how does my power profile compare to <this time last year | the prior block | a specific season>". Also supports the durability read: pass afterKj as an array of cumulative-work thresholds (any of 1000, 2000, 3000 alongside the default 0) to compare fresh and fatigued curves in a single call — "how does my 5-min power hold up after 2000 kJ of riding?". Use to answer "am I getting stronger", "where am I improving", or "how durable am I". </usecase> <instructions> Range inputs (pick one style): omit startDate/endDate for a rolling window via window ("28d", "90d" default, "180d", "1y", "all"); pass startDate + endDate together for an explicit calendar range (do not also pass window). Comparison inputs (pick at most one): comparisonWindow (legacy) for the immediately preceding block of that length; comparisonMode for a named helper — "previous_period" (same length as current, immediately preceding), "same_dates_previous_year" (identical calendar dates one year earlier), "same_calendar_month_previous_year" (the calendar months the current range overlaps, one year earlier), "custom_range" (requires comparisonStartDate+comparisonEndDate); or comparisonStartDate+comparisonEndDate directly. When a comparison is returned, both current and comparison carry their own cp (the most recent fitted CP/W' model at or before that range's end), and summary.criticalPowerDeltaWatts is current.cp − comparison.cp. summary.pointCount of 0 means no power-meter rides in the current range — say so directly. CP/W' fields are null when the athlete has no fitted model in that period. Pass afterKj: [0, 2000] (or [0, 1000, 2000, 3000] for the full spread) for durability: each entry of the array becomes one curve in data.curves, sharing the same date window. data.current mirrors data.curves[0] so existing readers continue to work. When the request includes threshold 0 alongside at least one fatigued threshold, summary.durabilityDelta reports retention at the anchor durations (5s/1min/5min/20min/60min). A low pointCount for a fatigued threshold means the athlete has done few rides past that much accumulated work. </instructions>

getPowerProfile

ChatGPT
<usecase> Returns the athlete's mean-max power curve — best average watts at standard durations from 5 seconds to 3 hours — over a window, plus the latest fitted CP/W' model. Optionally returns a second curve for any chosen comparison range so a single call can answer "how does my power profile compare to <this time last year | the prior block | a specific season>". Also supports the durability read: pass afterKj as an array of cumulative-work thresholds (any of 1000, 2000, 3000 alongside the default 0) to compare fresh and fatigued curves in a single call — "how does my 5-min power hold up after 2000 kJ of riding?". Use to answer "am I getting stronger", "where am I improving", or "how durable am I". </usecase> <instructions> Range inputs (pick one style): omit startDate/endDate for a rolling window via window ("28d", "90d" default, "180d", "1y", "all"); pass startDate + endDate together for an explicit calendar range (do not also pass window). Comparison inputs (pick at most one): comparisonWindow (legacy) for the immediately preceding block of that length; comparisonMode for a named helper — "previous_period" (same length as current, immediately preceding), "same_dates_previous_year" (identical calendar dates one year earlier), "same_calendar_month_previous_year" (the calendar months the current range overlaps, one year earlier), "custom_range" (requires comparisonStartDate+comparisonEndDate); or comparisonStartDate+comparisonEndDate directly. When a comparison is returned, both current and comparison carry their own cp (the most recent fitted CP/W' model at or before that range's end), and summary.criticalPowerDeltaWatts is current.cp − comparison.cp. summary.pointCount of 0 means no power-meter rides in the current range — say so directly. CP/W' fields are null when the athlete has no fitted model in that period. Pass afterKj: [0, 2000] (or [0, 1000, 2000, 3000] for the full spread) for durability: each entry of the array becomes one curve in data.curves, sharing the same date window. data.current mirrors data.curves[0] so existing readers continue to work. When the request includes threshold 0 alongside at least one fatigued threshold, summary.durabilityDelta reports retention at the anchor durations (5s/1min/5min/20min/60min). A low pointCount for a fatigued threshold means the athlete has done few rides past that much accumulated work. </instructions>

getProfile

ChatGPT
<usecase> Returns the athlete's profile: first name, FTP, weight, units, country, timezone, total activities by sport, available date range, and whether training-load data exists. Useful for grounding responses in the athlete's specifics — concrete watts instead of "% FTP", their name instead of "the athlete", their primary sport when filtering activities. </usecase> <instructions> profile.activityCounts is sorted descending; index 0 is the primary sport and is the natural value for findActivities's sport filter. summary.hasActivities indicates whether findActivities has data; summary.hasFitnessData indicates whether getFitnessHistory has a series. </instructions>

getProfile

ChatGPT
<usecase> Returns the athlete's profile: first name, FTP, weight, units, country, timezone, total activities by sport, available date range, and whether training-load data exists. Useful for grounding responses in the athlete's specifics — concrete watts instead of "% FTP", their name instead of "the athlete", their primary sport when filtering activities. </usecase> <instructions> profile.activityCounts is sorted descending; index 0 is the primary sport and is the natural value for findActivities's sport filter. summary.hasActivities indicates whether findActivities has data; summary.hasFitnessData indicates whether getFitnessHistory has a series. </instructions>

getRiderProfile

ChatGPT
<usecase> Returns the athlete's rider phenotype — sprinter, puncheur, diesel, or all-rounder — together with strength/limiter flags for the five canonical duration buckets (sprint, anaerobic, VO2, threshold, durability). Use this to tailor reads and recommendations to the kind of cyclist the athlete actually is: a sprinter and a diesel rider need very different training emphases even at the same FTP. summary.topStrengths and summary.topLimiters are the quick-reference lists for prose answers; data.buckets carries the per-duration ratios behind the classifications. </usecase> <instructions> No inputs. Profile is derived from the athlete's current mean-max power curve over the last 90 days; falls back to all-time when 90 days is too sparse. summary.hasEnoughData is false when fewer than 3 standard durations have a best effort even after the fallback — in that case data.phenotype.type is null and the model should say so plainly. data.phenotype.confidence is "high"/"medium"/"low" (bucketed from the underlying 0–1 score, which itself discounts when anchor durations are missing). summary.powerCurveWindow echoes "90d" or "all" so the model knows which window the read used. For the underlying watts at each duration, follow up with getPowerProfile. </instructions>

getRiderProfile

ChatGPT
<usecase> Returns the athlete's rider phenotype — sprinter, puncheur, diesel, or all-rounder — together with strength/limiter flags for the five canonical duration buckets (sprint, anaerobic, VO2, threshold, durability). Use this to tailor reads and recommendations to the kind of cyclist the athlete actually is: a sprinter and a diesel rider need very different training emphases even at the same FTP. summary.topStrengths and summary.topLimiters are the quick-reference lists for prose answers; data.buckets carries the per-duration ratios behind the classifications. </usecase> <instructions> No inputs. Profile is derived from the athlete's current mean-max power curve over the last 90 days; falls back to all-time when 90 days is too sparse. summary.hasEnoughData is false when fewer than 3 standard durations have a best effort even after the fallback — in that case data.phenotype.type is null and the model should say so plainly. data.phenotype.confidence is "high"/"medium"/"low" (bucketed from the underlying 0–1 score, which itself discounts when anchor durations are missing). summary.powerCurveWindow echoes "90d" or "all" so the model knows which window the read used. For the underlying watts at each duration, follow up with getPowerProfile. </instructions>

getTrainingWeek

ChatGPT
<usecase> Returns one ISO week (Monday–Sunday) of prescribed training keyed by the week's Monday: each day's events (workouts, races, vacation/sick/ injured days) with planned load, intensity, focus, and the interval structure for each workout inline. The week is identified by weekStart (the natural key under the new training data model — the (app_account_id, week_start) pair on training_weeks). The shared planContext (current block, racing season, taper flag) rides on every response. Use whenever the athlete asks "what's on this week", "what's left", "walk me through Thursday's session", or to read a specific session before discussing whether to do it. </usecase> <instructions> weekOf is required and must be a YYYY-MM-DD inside the desired ISO week — the tool snaps to the Monday. Resolve "this week" / "last week" against the briefing's current-date context before calling. Any week overlapping the live plan is fair game (past, present, future); weeks outside the plan return summary.hasPlanWeek: false (the request is still served — the day-by-day events still appear, just without the plan-level fields). includeNonWorkoutEvents defaults to true so races and time-off days appear on the calendar — set false only when the athlete asked specifically about workouts. For the actual ride on a completed workout, follow up with getActivityDetail using linkedActivity.activityId. For aggregate completed-side comparison, call getWeekSummary for the same week. Numbers belong in charts — describe the week qualitatively in prose. </instructions>

getTrainingWeek

ChatGPT
<usecase> Returns one ISO week (Monday–Sunday) of prescribed training keyed by the week's Monday: each day's events (workouts, races, vacation/sick/ injured days) with planned load, intensity, focus, and the interval structure for each workout inline. The week is identified by weekStart (the natural key under the new training data model — the (app_account_id, week_start) pair on training_weeks). The shared planContext (current block, racing season, taper flag) rides on every response. Use whenever the athlete asks "what's on this week", "what's left", "walk me through Thursday's session", or to read a specific session before discussing whether to do it. </usecase> <instructions> weekOf is required and must be a YYYY-MM-DD inside the desired ISO week — the tool snaps to the Monday. Resolve "this week" / "last week" against the briefing's current-date context before calling. Any week overlapping the live plan is fair game (past, present, future); weeks outside the plan return summary.hasPlanWeek: false (the request is still served — the day-by-day events still appear, just without the plan-level fields). includeNonWorkoutEvents defaults to true so races and time-off days appear on the calendar — set false only when the athlete asked specifically about workouts. For the actual ride on a completed workout, follow up with getActivityDetail using linkedActivity.activityId. For aggregate completed-side comparison, call getWeekSummary for the same week. Numbers belong in charts — describe the week qualitatively in prose. </instructions>

getWeekSummary

ChatGPT
<usecase> Returns one ISO week (Monday–Sunday) of training: per-day load and ride count, weekly totals (load, moving time, rides, average IF), the start/end CTL/ATL/TSB snapshot, and the prior 4-week mean for comparison. Use whenever the user asks about "this week", "last week", or a specific week. </usecase> <instructions> weekOf is required and must be a YYYY-MM-DD inside the desired ISO week — the tool snaps to the Monday. Resolve "this week" / "last week" against the briefing's current-date context before calling. The priorMean block is computed from the 4 weeks immediately before the requested week; summary.loadDeltaVsPriorMean is null when no prior data exists. For drill-in on individual rides, follow up with findActivities filtered to the same date range. </instructions>

getWeekSummary

ChatGPT
<usecase> Returns one ISO week (Monday–Sunday) of training: per-day load and ride count, weekly totals (load, moving time, rides, average IF), the start/end CTL/ATL/TSB snapshot, and the prior 4-week mean for comparison. Use whenever the user asks about "this week", "last week", or a specific week. </usecase> <instructions> weekOf is required and must be a YYYY-MM-DD inside the desired ISO week — the tool snaps to the Monday. Resolve "this week" / "last week" against the briefing's current-date context before calling. The priorMean block is computed from the 4 weeks immediately before the requested week; summary.loadDeltaVsPriorMean is null when no prior data exists. For drill-in on individual rides, follow up with findActivities filtered to the same date range. </instructions>

getZoneDistribution

ChatGPT
<usecase> Returns the athlete's time-in-zone distribution summed across all rides in a window — 7-zone power and 5-zone HR. Use this whenever the user asks how polarized their training has been, whether they're spending enough time in Z2, whether they're doing too much threshold work, or whether the distribution is appropriate for where they are in the training block. polarizationRatio is (Z1+Z2) / (Z5+Z6+Z7) by time on the power axis: a high ratio indicates polarized training, a low ratio indicates threshold-heavy training. Pair the read with the athlete's own training phase context — the appropriate distribution differs across base, build, and peak. </usecase> <instructions> Range inputs (pick one style): omit both for a rolling window ending today (default "90d"); pass startDate + endDate together for an explicit calendar range. Sport filter: pass sport (a single canonical activity type, e.g. "MountainBikeRide", or an array of them) to scope to specific disciplines. Omit sport to scope to the canonical "all ride" bucket — MTB / gravel / virtual surface alongside road. Power and HR aggregates are independent: a ride that has FTP-derived zones contributes to power even if it has no HR signal, and vice versa. data.power is null when fewer than 3 rides in the window carry power-zone data — summary.hasEnoughData reports the same flag. data.hr follows the same gate at 3 rides. polarizationRatio is null when the high-intensity bucket (Z5+Z6+Z7) is zero or when power data is sparse. summary.dominantZone is the highest-share power zone (or HR zone when power is unavailable). For drill into a single ride, follow up with findActivities filtered to the same window and getActivityDetail on the ride id. </instructions>

getZoneDistribution

ChatGPT
<usecase> Returns the athlete's time-in-zone distribution summed across all rides in a window — 7-zone power and 5-zone HR. Use this whenever the user asks how polarized their training has been, whether they're spending enough time in Z2, whether they're doing too much threshold work, or whether the distribution is appropriate for where they are in the training block. polarizationRatio is (Z1+Z2) / (Z5+Z6+Z7) by time on the power axis: a high ratio indicates polarized training, a low ratio indicates threshold-heavy training. Pair the read with the athlete's own training phase context — the appropriate distribution differs across base, build, and peak. </usecase> <instructions> Range inputs (pick one style): omit both for a rolling window ending today (default "90d"); pass startDate + endDate together for an explicit calendar range. Sport filter: pass sport (a single canonical activity type, e.g. "MountainBikeRide", or an array of them) to scope to specific disciplines. Omit sport to scope to the canonical "all ride" bucket — MTB / gravel / virtual surface alongside road. Power and HR aggregates are independent: a ride that has FTP-derived zones contributes to power even if it has no HR signal, and vice versa. data.power is null when fewer than 3 rides in the window carry power-zone data — summary.hasEnoughData reports the same flag. data.hr follows the same gate at 3 rides. polarizationRatio is null when the high-intensity bucket (Z5+Z6+Z7) is zero or when power data is sparse. summary.dominantZone is the highest-share power zone (or HR zone when power is unavailable). For drill into a single ride, follow up with findActivities filtered to the same window and getActivityDetail on the ride id. </instructions>

markTimeOff

ChatGPT
<usecase> Records a stretch of time off (vacation, sick, or injured) on the athlete's calendar from from to to inclusive, and soft-deletes any non-completed workouts already scheduled inside that range — the calendar reflects the truth immediately. Use when the athlete reports they'll be away or unable to train, or when they're returning from a period off and want it logged retroactively. </usecase> <instructions> from and to are YYYY-MM-DD calendar dates (UTC); to must not be before from. Single-day off → pass the same date for both. type is one of vacation, sick, injured. notes is an optional short label shown on the calendar; defaults to the capitalized type. Idempotent on the same range — repeating the call returns the same eventId with wipedWorkoutCount of 0. Overlapping ranges of the same type are merged into the existing event rather than duplicated. Completed workouts are never touched. </instructions>

markTimeOff

ChatGPT
<usecase> Records a stretch of time off (vacation, sick, or injured) on the athlete's calendar from from to to inclusive, and soft-deletes any non-completed workouts already scheduled inside that range — the calendar reflects the truth immediately. Use when the athlete reports they'll be away or unable to train, or when they're returning from a period off and want it logged retroactively. </usecase> <instructions> from and to are YYYY-MM-DD calendar dates (UTC); to must not be before from. Single-day off → pass the same date for both. type is one of vacation, sick, injured. notes is an optional short label shown on the calendar; defaults to the capitalized type. Idempotent on the same range — repeating the call returns the same eventId with wipedWorkoutCount of 0. Overlapping ranges of the same type are merged into the existing event rather than duplicated. Completed workouts are never touched. </instructions>

presentWeek

ChatGPT
<usecase> Surfaces a week built by buildWeek to the athlete as a week-preview card with an Accept button. Takes only the proposalId; the committable draft already lives server-side. Call this once the draft is in tolerance — it is the step that actually shows the athlete the plan. The card's Accept button commits directly, so no further model turn is needed. </usecase> <instructions> proposalId is the wp_… token returned by buildWeek. Present only a pending proposal; a superseded/expired/committed draft is refused — rebuild with buildWeek first if needed. </instructions>

presentWeek

ChatGPT
<usecase> Surfaces a week built by buildWeek to the athlete as a week-preview card with an Accept button. Takes only the proposalId; the committable draft already lives server-side. Call this once the draft is in tolerance — it is the step that actually shows the athlete the plan. The card's Accept button commits directly, so no further model turn is needed. </usecase> <instructions> proposalId is the wp_… token returned by buildWeek. Present only a pending proposal; a superseded/expired/committed draft is refused — rebuild with buildWeek first if needed. </instructions>

removeRace

ChatGPT
<usecase> Removes a race from the athlete's calendar (soft-delete). Pair with addRace when an athlete cancels a signup or signed up to the wrong event. Refuses to delete completed races — those are part of training history. </usecase> <instructions> eventId is the value returned by addRace's data.eventId, or the race row's id from any read tool. Only race events owned by the caller can be removed; workouts and time-off use other deletion paths. Requires the joules.write OAuth scope and an active Joules subscription. </instructions>

removeRace

ChatGPT
<usecase> Removes a race from the athlete's calendar (soft-delete). Pair with addRace when an athlete cancels a signup or signed up to the wrong event. Refuses to delete completed races — those are part of training history. </usecase> <instructions> eventId is the value returned by addRace's data.eventId, or the race row's id from any read tool. Only race events owned by the caller can be removed; workouts and time-off use other deletion paths. Requires the joules.write OAuth scope and an active Joules subscription. </instructions>

renderChart

ChatGPT
<usecase> Renders a chart inline in the user's chat. Relevant when the user asks to see a chart, visualization, or trend — for example a fitness trajectory plot, weekly load shape, or year-over-year comparison they've requested. Pairs with the data tools, which return the underlying numbers for narration. </usecase> <instructions> Pass metric (one of ctl, atl, tsb, daily_tss, cumulative_tss, weekly_tss), ranges (1-4 items, each {label, start, end} with YYYY-MM-DD dates), and optional headline copy (eyebrow, name, stats). Chart type is decided by the metric: daily_tss and weekly_tss render as bars; the rest render as lines. align controls how multiple ranges overlay: - "absolute" (default for one range) — true calendar dates on x-axis. Use for sequential ranges that share a timeline ("last 90d" alone, or "last 90d" vs "prior 90d"). - "doy" — day-of-year overlay so Jan 1 of one year sits on top of Jan 1 of another. Use for year-over-year comparisons. Headline copy guidance — keep all three short, the model narrates the rest. Always use plain terms, never acronyms: "Fitness" (not CTL), "Fatigue" (not ATL), "Form" (not TSB), "Load". - eyebrow — small context line, e.g. "Last 90 days · Fitness", "Apr 27 – May 3". - name — qualitative phrase, e.g. "Productive build", "Ahead of last year". - stats — one anchored number you'll cite in chat, e.g. "Now 67 · +22 since Jan 29". yLabel is optional — defaults to a sensible plain label per metric (e.g. "Fitness", "Load"). Override only when the default is wrong for the question. </instructions>

renderChart

ChatGPT
<usecase> Renders a chart inline in the user's chat. Relevant when the user asks to see a chart, visualization, or trend — for example a fitness trajectory plot, weekly load shape, or year-over-year comparison they've requested. Pairs with the data tools, which return the underlying numbers for narration. </usecase> <instructions> Pass metric (one of ctl, atl, tsb, daily_tss, cumulative_tss, weekly_tss), ranges (1-4 items, each {label, start, end} with YYYY-MM-DD dates), and optional headline copy (eyebrow, name, stats). Chart type is decided by the metric: daily_tss and weekly_tss render as bars; the rest render as lines. align controls how multiple ranges overlay: - "absolute" (default for one range) — true calendar dates on x-axis. Use for sequential ranges that share a timeline ("last 90d" alone, or "last 90d" vs "prior 90d"). - "doy" — day-of-year overlay so Jan 1 of one year sits on top of Jan 1 of another. Use for year-over-year comparisons. Headline copy guidance — keep all three short, the model narrates the rest. Always use plain terms, never acronyms: "Fitness" (not CTL), "Fatigue" (not ATL), "Form" (not TSB), "Load". - eyebrow — small context line, e.g. "Last 90 days · Fitness", "Apr 27 – May 3". - name — qualitative phrase, e.g. "Productive build", "Ahead of last year". - stats — one anchored number you'll cite in chat, e.g. "Now 67 · +22 since Jan 29". yLabel is optional — defaults to a sensible plain label per metric (e.g. "Fitness", "Load"). Override only when the default is wrong for the question. </instructions>

setPlan

ChatGPT
<usecase> Designs the athlete's training plan — the forward trajectory (blocks + weeks with intent and target TSS) plus the program math (progression rates, baseline load) and schedule (availableDays). One tool with a preview/commit pattern: preview: true returns materialized weekly TSS per block and a projected CTL/ATL/TSB curve through the window (distributed across the athlete's availableDays when set, uniform otherwise) without writing; preview: false commits the program patch and the trajectory in one atomic batch. Use preview to iterate on block shape before committing, then commit when the athlete approves. </usecase> <instructions> blocks is required — each block has a phase (base/build/peak/race/recovery) and a list of weeks with an intent (base/build/recovery/peak/taper). Target TSS is computed from the program's progression rates and (for taper weeks) the targetTSB of races on the calendar; pass targetTssOverride on a week to pin a specific value. effectiveFrom is optional — pass YYYY-MM-DD inside the desired ISO week (snaps to Monday) or omit for the upcoming planning week. Program math fields (initialWeeklyLoad, the four rate fields, availableDays) are optional patches — only the fields you pass are written. First commit auto-creates the program row with SQL defaults for unspecified fields; programSnapshot.source indicates whether the math came from the persisted row ("program") or defaults ("default"). preview defaults to false. Requires the joules.write OAuth scope and an active Joules subscription. </instructions>

setPlan

ChatGPT
<usecase> Designs the athlete's training plan — the forward trajectory (blocks + weeks with intent and target TSS) plus the program math (progression rates, baseline load) and schedule (availableDays). One tool with a preview/commit pattern: preview: true returns materialized weekly TSS per block and a projected CTL/ATL/TSB curve through the window (distributed across the athlete's availableDays when set, uniform otherwise) without writing; preview: false commits the program patch and the trajectory in one atomic batch. Use preview to iterate on block shape before committing, then commit when the athlete approves. </usecase> <instructions> blocks is required — each block has a phase (base/build/peak/race/recovery) and a list of weeks with an intent (base/build/recovery/peak/taper). Target TSS is computed from the program's progression rates and (for taper weeks) the targetTSB of races on the calendar; pass targetTssOverride on a week to pin a specific value. effectiveFrom is optional — pass YYYY-MM-DD inside the desired ISO week (snaps to Monday) or omit for the upcoming planning week. Program math fields (initialWeeklyLoad, the four rate fields, availableDays) are optional patches — only the fields you pass are written. First commit auto-creates the program row with SQL defaults for unspecified fields; programSnapshot.source indicates whether the math came from the persisted row ("program") or defaults ("default"). preview defaults to false. Requires the joules.write OAuth scope and an active Joules subscription. </instructions>

updateBlock

ChatGPT
<usecase> Patches metadata on a single block in the athlete's live trajectory — name, phase, and/or notes. Use when getPlan surfaces a block whose label or notes need refreshing (e.g. the athlete's goal shifted, or the coach's framing for a phase needs an edit). Per-week TSS edits and structural rewrites — moving block bounds, adding/removing weeks, changing a week's intent — go through setPlan instead. </usecase> <instructions> blockId — the id returned for the block in getPlan's data.plan.blocks[].id (format blk-<integer>). name, phase, notes — supply only the fields you want to change. Pass an empty string for notes to clear it. phase must be one of base/build/peak/race/recovery. At least one field is required. </instructions>

updateBlock

ChatGPT
<usecase> Patches metadata on a single block in the athlete's live trajectory — name, phase, and/or notes. Use when getPlan surfaces a block whose label or notes need refreshing (e.g. the athlete's goal shifted, or the coach's framing for a phase needs an edit). Per-week TSS edits and structural rewrites — moving block bounds, adding/removing weeks, changing a week's intent — go through setPlan instead. </usecase> <instructions> blockId — the id returned for the block in getPlan's data.plan.blocks[].id (format blk-<integer>). name, phase, notes — supply only the fields you want to change. Pass an empty string for notes to clear it. phase must be one of base/build/peak/race/recovery. At least one field is required. </instructions>

Capabilities

InteractiveWrites

Example Prompts

Click any prompt to copy it.

App Stats

46

Tools

1

Prompts

ChatGPT

Platforms

Works with

ChatGPT

Data refreshed daily