- Brand
- PaceKeeper AI
- Category
- Health & Wellness
- Primary Subcategory
- Endurance Sport Training Planners
Integration details
Description
PaceKeeper helps athletes review their profile and sport settings, inspect planned and completed training, understand wellness and recovery trends, and analyze compressed activity streams from their connected intervals.icu account.
- Integration type
- Plugin
- Verification status
- Not applicable
- Platform
- ChatGPT
- Primary Subcategory
- Endurance Sport Training Planners
- Secondary Subcategories
- None listed
- Brand
- PaceKeeper AI
- Access
- Account required
- First tracked
- 2026-08-23
- Tool count
- 42
- Market
- US
The Primary Subcategory used for this profile’s headline score.
Other Subcategories where the Integration is listed.
Your score is coming
ChatGPT now suggests Plugins on its own when they match a user's request.Your Plugin Discovery Score measures how often yours appears, and it will show here as soon as it’s ready.
What discovery looks like

Get alerts for PaceKeeper AI
Get updates when PaceKeeper AI’s Discoverability Score or category rank changes.
Competing in ChatGPT Endurance Sport Training Planners
View Category42 tools agents can invoke
Appends a note to an activity's comment log. PaceKeeper stores the structured comment (source of truth) and QUEUES a flat, human-readable "── PaceKeeper ──" block for the intervals.icu activity description (one-way; that text is never read back). The block is written when the queue is delivered, not during this call: the response says intervals_queued (true/false) and intervals_queue_reason — for an event-anchored note that reason is "nur_event_notiz", because there is no activity to write to yet. APPEND-only. Give comment.text (required); optional kind ("during"|"post"|"retrospective"|"note") and elapsed_s — the seconds INTO the activity the note refers to (e.g. 18000 = the 5-hour mark), which lines the note up with the minute rows from activity_get_compressed_streams so it can be read against power/cadence/torque; and optional at (ISO-8601 write time, default now). Instead of activity_id you may pass event_id (a planned workout from training_get_range planned_events[].id) to attach the note WHILE riding, before the finished activity exists — it links to the activity automatically once it syncs (paired_event_id); event-anchored notes get no intervals write-back until then. Returns the created comment incl. its comment_id.
activity_add_comment
Writes the athlete's SUBJECTIVE self-report for one day (intervals.icu wellness fields). MERGE, not replace: only the fields you pass are changed; the rest of the day (incl. other subjective fields) stay intact. Settable fields: soreness, fatigue, stress, mood, motivation, injury (each an integer 1-5, or null to clear), and comments (free text). MEASURED fields (restingHR, hrv, sleepSecs, weight, ctl, atl, …) come from devices/model and are REJECTED with an error if included. Use this to log things like rising soreness or a niggling injury so they become a data series instead of only chat history. Response echoes updated:{} and previous:{} (the prior values of all subjective fields, read from PaceKeeper's own store — the same place the write goes). WHERE THE WRITE LANDS: in PaceKeeper's own store, and the delivery to intervals.icu is QUEUED (response data_source="pk_pending", seq = the queue entry). When this call returns the change has NOT reached intervals.icu yet — say it is saved and syncing, never that it is already in the athlete's calendar. comments is silently truncated to 2000 characters. The date is only checked for the YYYY-MM-DD shape: past days are the normal case, and a future day is accepted too — nothing enforces "today or past".
wellness_set_day
Changes ONLY the given fields of an EXISTING meal without re-sending its ingredients or preparation — a safe partial update (merge). Use this instead of nutrition_set_meal when you only need to move the meal time, rename it, correct its kcal/macros, tags, or its pre/post-session link. Patchable: time (HH:MM), name, k (kcal number), m ({f,c,p} grams), tags (string[]), ref (a training event id it belongs to), rel ("pre"|"post"). ing and prep are NOT patchable — to change ingredients or steps re-send the whole meal with nutrition_set_meal. The merged meal is re-validated (schema, delivery rules, diet/allergy preferences, meal-time policy) exactly like nutrition_set_meal, so an invalid patch is rejected. THE WEEKLY MEAT/FISH LIMIT IS ENFORCED HERE TOO: the server counts the whole ISO WEEK FROM STORAGE (the day you touch replaces its stored version) and refuses the write only when it would push that week ABOVE meat_per_week/fish_per_week. Lowering the count is always accepted, even while the week is still over the limit. Returns patched:{} and previous:{}. The meal must already exist on that day. RESPONSE FIELDS: patched, previous, the merged meal, stored_at_utc, preference_warnings and training_context.
nutrition_patch_meal
Changes ONLY the given scalar fields of a planned calendar event, WITHOUT re-sending the whole workout — a safe partial update (merge). Use this instead of training_update_single when you only need to move the start time, rename, re-date, change the load target, description or sport: rewriting the full object risks silently dropping a forgotten field (e.g. a repeat block in w). Patchable: st (HH:MM start time), d (YYYY-MM-DD date), n (name), ts (load target, number), desc (description text), sp (sport, e.g. "Ride"/"Run"). The workout structure w is NOT patchable — it is all-or-nothing; to change the steps use training_update_single. Returns patched:{} — the fields you sent, except that sp comes back as the translated intervals type and desc:null comes back as "". The stored event is NOT echoed back — use training_get_range to read it. Omitted fields are left unchanged. WHERE THE WRITE LANDS: in PaceKeeper's own store, and the delivery to intervals.icu is QUEUED (response data_source="pk_pending", seq = the queue entry). When this call returns the change has NOT reached intervals.icu yet — say it is saved and syncing, never that it is already in the athlete's calendar. If the event is not in PaceKeeper's store yet (the ingest has not caught up), the call is refused with that reason — which is NOT the same as "the event does not exist".
training_patch_single
Changes day-level fields without rewriting the day's meals — currently the day note. Use this to fix a stale day note (e.g. after you shortened a session or changed a meal, so the note's "Sweet Spot 18:00–20:08" / "~4471 kcal" no longer matches) without re-sending every meal. Patchable: note (free text, silently truncated to 500 characters; null or "" clears it). targets are DERIVED from the day's meals and training load and are NOT settable here. Returns patched:{} and previous:{}. Response also carries stored_at_utc.
nutrition_patch_day
CALL THIS FIRST WHEN ANOTHER TOOL FAILS OR RETURNS NOTHING: it is the one place that explains WHY. Returns the state of this connection and what to do about it. status is one of: "linked" (everything works) — "nutrition_only" (no intervals.icu account at all; a complete, supported setup, not a fault) — "intervals_expired" (this connection's intervals.icu key has expired; relink_required is then true) — "intervals_not_in_this_connection" (the ACCOUNT has intervals.icu, this AI connection does not — do NOT tell the athlete to connect intervals.icu; they need a new connect code) — "needs_consent" or "needs_consent_and_intervals" (health-data consent missing or declined) — "no_pacekeeper_account" (authorized with intervals.icu but no PaceKeeper account behind it) — "undeterminable" (a lookup on our side failed; temporary, nothing needs reconnecting). available_tools says what is usable: all | nutrition_and_training_writes | nutrition_only | training_only | unknown. what_to_do is a ready-made sentence for the athlete — relay it instead of inventing advice. Also returned: health_data_consent.state (granted | declined | not_decided | undeterminable | not_required), pacekeeper_account.known (true/false/NULL, where null means "not determinable", not "no"), intervals.connected (this connection), intervals.expired, intervals.account_has_intervals (the ACCOUNT, which is a different question), intervals.session_expires_utc and token_issued_utc (when this token was issued — NOT when the connection was first made).
account_link_status
Answers the "connection_test" task and nothing else (build 2026.10.23.0709). Pass the "nonce" line from the payload verbatim. Returns confirmed:true when it matched. A confirmed:false answer is final — do NOT retry and do NOT do anything else; the test simply is not running any more. THE NONCE IS CONSUMED ON A MATCH: a second call with the same one answers confirmed:false, which is the expected outcome and not an error. Comparison is constant-time; detail carries a short plain-text reason.
pacekeeper_confirm_connection
Creates one planned training. It is written into a PLAN as a session and only uploaded to intervals.icu if that plan is ACTIVE — for a draft/inactive plan it is stored in the plan and uploaded later when the athlete activates it. Pass plan_uid to target a specific plan; if you omit it the server AUTO-DETECTS the plan whose window covers the training date and routes into it (so a training in a plan window is always gated by that plan's active state and never uploaded past the guard). Only a date that lies in NO plan becomes a standalone intervals.icu calendar event — and that is the one path in this tool that can call intervals.icu DURING the call, because the event id has to come back from there. When PaceKeeper is configured to mint its own event ids, that create is queued like every other write instead. READ delivery_state RATHER THAN ASSUMING: "uploaded" = it is at intervals.icu, "queued" = accepted by PaceKeeper and still on its way (uploaded_to_intervals is then false), "not_sent" = stored in the plan only. An ACTIVE plan is therefore not a guarantee that the event is already outside. RESPONSE FIELDS: stored_in_plan, plan_uid, session_id, plan_active, updated_existing (true when an existing session was overwritten in place), version_id (the new plan snapshot), validated_with_schema, applied_payload (what was sent on), and for the standalone path created_event (the raw created event). Re-calling with the same date+name+sport updates that session in place. The server validates the training object against day_training_schema_hf. REQUIRED training fields: d (YYYY-MM-DD), n (name), c=WORKOUT, sp (sport), tp (training type), st (HH:MM), desc, w (at least one work block). Workout structure in w: each item is either a step block (l/du/ta/ca, optional in=intensity) or a repeat block (l/n/steps), with steps as the repeated pattern only once (no nested repeats). Duration du accepts combined units, e.g. "1h40m" = 100 min. Set the optional in (warmup/interval/recovery/active/cooldown/rest) on interval work/rest steps so they are classified correctly; if omitted the server derives it from ta. Target examples for ta: "65%-75%", "Z4", "ramp 50%-75%", "4:45/km", "150-160 bpm", "Z2 HR", "90% LTHR", "70% HR", "Z2 Pace", "70% Pace". "Z4" alone is always a POWER zone; for heart rate use "Z2 HR"/"Z2-Z3 HR" (zone), "90% LTHR"/"90-95% LTHR" (percent of threshold HR — most common), or "70% HR"/"75-80% HR" (percent of max HR — a different basis than LTHR, not interchangeable). Absolute bpm ("150-160 bpm", "155 bpm") is converted to %LTHR server-side and requires LTHR to be configured for the sport in intervals.icu — if it isn't, the request is rejected with a clear error naming the target; prefer "% LTHR" directly when possible. Pace also accepts /100m, /100y, /mi, /500m, /400m, /250m instead of /km (e.g. "1:30/100m" for swimming — there is no power fallback for swim, pace is the only realistic target there). Cadence example for ca: "85-95 rpm".
training_create_single
Creates a training plan with periodization phases, and SENDS A PUSH NOTIFICATION to the athlete's devices. "New" is not enforced: a plan_uid that already belongs to THIS athlete is continued rather than refused (only one belonging to another athlete is refused), and plan_uid is at most 36 characters. The server validates before writing: phases MUST be contiguous (no gaps, no overlaps), cover the whole plan window (first phase starts on plan_start_date, last ends on plan_end_date), and use only the allowed phase types. Recommended phase order/min length: base_1 (>=21d) -> base_2 (>=21d) -> build_1/build_2 (>=21d) -> peak (>=10d) -> race_week (7d) -> recovery (>=7d); splice a_prep/a_recovery, b_prep/b_recovery or c_prep/c_recovery around a secondary race instead of leaving a gap. main_goal is the ONE A race the plan is anchored to. A plan may contain ANY NUMBER of A, B and C races: add every further race -- including further A races -- as an event with type a_race/b_race/c_race. Non-race periods (vacation/illness/injury/training_camp/work_travel/rest_block/other) are events too; "other" requires a description. RESPONSE FIELDS: plan_uid, version_id (the new snapshot), created, plan (the summary), and on an update path pruned_session_count / pruned_sessions / pruned_events_queued_for_removal.
plan_create
Deletes a PLANNED calendar event / plan session (e.g. a workout that was scheduled but did not happen and still sits in the calendar). WHERE THE WRITE LANDS: in PaceKeeper's own store, and the delivery to intervals.icu is QUEUED (response data_source="pk_pending", seq = the queue entry). When this call returns the change has NOT reached intervals.icu yet — say it is saved and syncing, never that it is already in the athlete's calendar. HARD INVARIANT: an activity is a recorded measurement and is NEVER deletable via this MCP — passing an activity id (e.g. "i170860714") is rejected with no fallback. Requires confirm=true. Activity ids ("i" + digits) are refused by shape. Everything else is looked up in PaceKeeper's own store, and that lookup CANNOT tell apart "not ingested yet", "already gone" and "never existed" — a refusal names that ambiguity instead of claiming the event does not exist. Response: deleted_event_id.
training_delete_single
Revokes AI-assistant access for this account. DESTRUCTIVE AND WIDER THAN IT SOUNDS: it does not end only THIS conversation's connection — it revokes EVERY assistant grant on the account, deletes the access AND refresh tokens, and closes any active link rows. Every assistant the athlete has connected stops working and each one needs a new connect code from the app. Requires confirm=true, and you must confirm with the user before calling — telling them the full scope, not just "this chat". The response always says status:"revoked"; what actually happened is in the counters (token_deleted, updated_rows, access_tokens_deleted, refresh_tokens_deleted, revocation_recorded) — all zeroes means there was nothing left to revoke, not that the call failed.
account_disconnect
Corrects or soft-deletes ONE comment in an activity's log (never a hard delete — a corrected/removed comment keeps a deleted_at marker). Give text to replace the wording and/or delete=true to soft-delete. Regenerating the intervals.icu "── PaceKeeper ──" block is QUEUED, not done in this call (response: intervals_queued, intervals_queue_reason). Get comment_id from activity_get_comments; an unknown one is refused with "comment_id not found".
activity_edit_comment
START HERE. A compact orientation for this MCP (roughly 5-7 KB of JSON, more once the orientation map is filled): version, server date + athlete timezone, where the AUTHORITATIVE data lives (climb/ramp performance, sport intensity, nutrition constraints), units, cache + write rules, recommended tool flows, supported sports, and what is app-only. Read it once at the start of a session; it prevents recomputing values that already exist and avoids app-only dead ends. Tool list built 2026.10.23.0709 — if this differs from the mcp_version this tool returns live, your cached tool list is STALE: reconnect the connector to refresh it. Response also carries context_version, mcp_version/server_build_utc (the build), server_datetime_utc and server_date_utc (UTC), server_date (the ATHLETE's local today -- use this one for "today"), and discretion_brief (the version and scope of the Bio wording rules).
pacekeeper_get_context
Returns the comment log (chronological), each with comment_id, text, kind, elapsed_s, at (event-anchored ones also carry event_id + pending:true). Pass activity_id for a finished activity, or event_id for a planned workout (notes written before its activity exists). READING AN ACTIVITY ALSO WRITES: it links pending planned-workout notes to this activity, retires notes whose planned workout is gone, marks notes pointing at an unknown activity as orphaned, and where it linked something it queues a refresh of the intervals.icu note block. Harmless, but it is not a pure read. Read the log before editing or to answer questions about earlier notes. Soft-deleted comments are omitted unless include_deleted=true. Response also carries count.
activity_get_comments
Returns per-minute averages and NP for an activity. ONE ROW IS ONE MINUTE ONLY IF YOU ASK FOR IT: max_minutes defaults to 120, so a longer ride is bucket-averaged down to ~120 rows (each row then averages several minutes). Pass max_minutes = the ride's length in minutes (max 1440), or use from_minute/to_minute, to get true per-minute resolution. Channels: power_w, hr_bpm, speed_mps, cadence_rpm, altitude_m, distance_m, pace_s_per_km, temperature_c, torque_nm, smo2_pct, thb_g_dl, left_right_balance_pct, w_prime_balance_j, grade_pct, core_temperature_c, respiration_brpm, left_pedal_smoothness_pct, right_pedal_smoothness_pct, left_torque_effectiveness_pct, right_torque_effectiveness_pct, rmssd_ms, sdnn_ms, rr_mean_ms — whichever of these the activity actually recorded (a field is simply absent from a row when its sensor gave no value; summary.samples reports the non-null raw-sample count per SENSOR channel (0 = not recorded); the three per-minute HRV channels AND pedalling_time_s/coasting_pct have no raw stream of their own — they are derived (HRV from raw R-R intervals, pedalling_time_s/coasting_pct from the cadence channel) and are counted separately in summary.derived_minutes as the number of minutes that carry a value, so a derived channel with real data never shows a misleading 0 in samples). avg cadence_rpm and torque_nm are averaged over PEDALLING only — freewheeling 0-rpm samples are excluded, so the value is the real pedalling cadence/torque, not dragged down by coasting; power_w keeps its zeros (coasting is real 0 W). Because the zeros are dropped from those averages, each minute also carries pedalling_time_s (≈ seconds actually pedalled that minute — raw streams are 1 Hz) and coasting_pct (percent of the minute spent freewheeling), so a pause in a minute stays visible. Pedal-smoothness and torque-effectiveness (left/right, percent) reveal how force is distributed through the crank revolution — useful for steep low-cadence ramps where average torque_nm alone hides the split. rmssd_ms and sdnn_ms are real per-minute heart-rate variability computed from the raw beat-to-beat R-R intervals BEFORE averaging (the hrv stream carries an array of R-R intervals per sample; artifacts that jump >20% from the previous interval are dropped). rr_mean_ms is only the mean R-R interval (≈ 60000/hr_bpm, not variability). A minute is dropped from ALL three HRV fields when too few valid intervals survive filtering, or when rr_mean_ms disagrees with 60000/hr_bpm by more than 10% (merged/missed beats). sdnn_ms is additionally omitted in transition minutes (heart rate moving more than 10 bpm within the minute), where it would measure the trend rather than the variability; rmssd_ms is trend-immune and is always present when the minute is valid. During moderate/hard efforts HRV collapses toward zero (parasympathetic withdrawal); the signal is in the recovery — how fast rmssd_ms comes back in the rests between hard intervals is a useful fatigue marker across an interval session. Response includes summary (NP, per-channel sample counts) always; with detail_level=summary_only that is ALL it includes — no "climbs" array and no per_minute rows (use activity_get_climbs for climbs alone). Otherwise also a "climbs" array (auto-detected ascents ≥500m length, ≥3% avg grade, score=length_m×avg_grade_pct≥1500; each has length_m, elevation_gain_m, avg_grade_pct, max_grade_pct, score, category Cat4/Cat3/Cat2/Cat1/HC/Cat5, duration_s (the actual elapsed span over the climb from the stream, INCLUDING any standing time — note that activity_get_descents defines duration_s as the MOVING time instead), estimated_kj (TWO different derivations under one name: the integral of the MEASURED power when the ride has a power channel, otherwise the potential-energy floor mass*g*height, which ignores air and rolling resistance and is therefore a lower bound, not a measurement), expected_carbs_g, the ridden performance over the climb (avg_power_w, np_w, avg_hr_bpm, avg_cadence_rpm; null when that channel was not recorded, cadence excludes freewheeling zeros), the pedalling-phase torque at the crank (torque_avg_nm, torque_p95_nm, torque_max_nm) taken from the MEASURED torque_nm channel -- present only when the ride recorded it and the section was pedalled for at least half its duration, absent otherwise rather than 0, and never derived from avg_power/avg_cadence because the quotient of two means is the mean torque only at constant power and cadence; prefer torque_p95_nm over torque_max_nm, which at 1 Hz is a single sample and can be a pothole. torque_source says where the number came from: "gemessen" = the device recorded a torque channel, "hergeleitet" = it was computed per second as power / angular velocity because the device recorded none. Never mix the two silently in a comparison across rides. When the ride reports its crank length, pedal_force_avg_n / pedal_force_p95_n are also given, with crank_length_mm naming the divisor actually used -- and this is the TANGENTIAL force that turns the crank, not the total force on the pedal: force pushed straight down at the dead spot does no work and is not in this number (that is what left/right_torque_effectiveness_pct describes). Torque itself is crank-length independent and is the field to compare across bikes; pedal force differs by the crank length alone -- this athlete's rides span 160 to 172.5 mm, which is 7.8 %, cadence_penalty, heat_risk, climb_difficulty [terrain only], athlete_difficulty [terrain + this ride's fatigue/heat/gearing], difficulty_components breakdown, WHERE IN THE RIDE the climb sat (climb_index 1-based, elapsed_before_s, distance_before_m, elevation_before_m, kj_before = mechanical work accumulated before the climb started; all four are UNCAPPED, unlike difficulty_components.fatigue which saturates at 20 after roughly eight hours and then no longer distinguishes hour 8 from hour 23 -- use these to compare the same climb ridden fresh versus pre-fatigued, e.g. ramps that came after more than 4000 kJ; kj_before is null when the ride has no power channel), and steepest — the steepest sliding window of each length in metres: steepest.{50,100,250,500,1000} each = {grade_pct, start_distance_m, elevation_gain_m, plus the same avg_power_w/np_w/avg_hr_bpm/avg_cadence_rpm over that ramp}, windows longer than the climb omitted, steepest.smoothing_m = the altitude pre-smoothing distance in metres. Prefer steepest over avg_grade_pct/max_grade_pct when the question is about short steep ramps or whether a gear ratio is enough: avg_grade_pct averages ramps away and max_grade_pct says nothing about how long the steep part lasts, whereas steepest gives grade-by-length), and per_minute rows (each tagged with its `minute` offset into the activity; use from_minute/to_minute to fetch just one segment at full resolution). Climbs are only detected for activities whose stream data came from intervals.icu directly (data_source "cron"/"live"), not from app uploads. For extremely long activities (>1440 min / 24h) data is automatically downsampled; up to that length every minute is available. Check "streams_cached" on the activity in training_get_range first: true returns data immediately, false queues a background fetch and returns data_source="not_cached" — retry this call in about a minute. Streams are cached 30 days; once fetched via the PaceKeeper app they are available immediately. Pass force_refresh=true to rebuild an already-cached activity with the latest processing (the cache holds the previously compressed result, so newer per-minute fields appear only after a rebuild) — it queues a fresh raw fetch + recompress and returns data_source="refreshing"; retry in ~1 min.
activity_get_compressed_streams
Returns the lap/interval breakdown of a completed activity from PaceKeeper's own store — the per-block summary that per-minute streams do not give you directly. Each interval: label, type (WORK/RECOVERY/…), start_s, duration_s, avg_w, np_w (normalized power for the block), max_w, avg_hr, max_hr, avg_cadence_rpm, avg_torque_nm, intensity_pct, w_prime_depletion_j (W' used across the block), zone, decoupling_pct, distance_m (fields absent when not recorded). Use this for questions like "how did HR drift block-to-block at the same power?" — it gives the athlete's real interval averages instead of reconstructing them from per-minute rows (which mixes in the ramp-up). Served from the PaceKeeper stream store, filled by the ingest worker. Two empty results mean different things and the response says which: data_source \"not_cached\" = the activity is not ingested yet (a job was queued, retry in ~1 min); data_source \"laps_not_ingested\" = the streams are here but the laps were not fetched at the time (a refresh was queued); an empty list with a normal data_source means the ride genuinely has no laps. force_refresh queues a re-ingest instead of fetching inline. Otherwise data_source is whatever the stream store recorded ("cron"/"live") or "pk_stream_cache". duration_s here is the moving time where intervals.icu reported one and the elapsed time otherwise — a third meaning of that field name next to activity_get_climbs (full span) and activity_get_descents (moving time only). The figures are intervals.icu's own per-lap computations, stored and passed through, not recomputed by PaceKeeper. Without a usable intervals.icu key no ingest job can be queued, and then the call is refused instead of answering "not_cached". Response also carries interval_count and fetched_at_utc (when the store was filled, not now).
activity_get_intervals
Returns a small, curated athlete profile. EVERY FIELD IS OPTIONAL and an empty one is LEFT OUT rather than returned as null: display_name, sex ("M"/"F", normalised to the intervals.icu spelling), birth_year, height_cm, weight_kg, timezone, locale, measurement_system, sports[] (the activity types taken from the stored sport settings, not from the profile itself). If the account has not been ingested yet, profile is null and note says so. Intentionally omits secrets (API keys, email, tokens), integration/notification settings and internal intervals.icu fields. For per-sport FTP/zones/LTHR use athlete_get_sport_settings. Served from PaceKeeper's own store, not fetched from intervals.icu per call — there is no cache in front of it. fetched_at_utc is when that store was last filled, so it is normally EARLIER than your call; that is expected, not stale data. data_source is "pk_domain_projection" when the data is there and "not_synced_yet" when this account has not been ingested yet. force_refresh is still accepted and HAS NO EFFECT — passing it does not make the answer newer.
athlete_get_profile
Returns ONLY the detected climbs for an activity — no per-minute streams. Each climb has length_m, elevation_gain_m, avg_grade_pct, max_grade_pct, category, duration_s, the ridden performance (avg_power_w, np_w, avg_hr_bpm, avg_cadence_rpm) and steepest[50|100|250|500|1000] (grade + the same performance per ramp), plus a small summary (NP). Prefer this over activity_get_compressed_streams for climb/ramp questions ("how were my climbs?") — it loads no stream rows. WHERE THE NUMBERS COME FROM, and data_source always says which: "pk_db" = the version the processing pipeline stored (the SAME one the PaceKeeper app shows, and the only one carrying segment_id and name) — "cache"/"cron"/"live" = the 30-day compressed-stream cache the climbs were computed into — "not_cached" = nothing here yet, a background fetch was queued, retry in ~1 min — "refreshing" = force_refresh queued a fresh fetch and recompute, retry in ~1 min. Climbs are only computed for activities whose streams came from intervals.icu (not from app uploads); for an app-upload cache the climbs key is absent and a cron fetch is queued to fill it. BEYOND THE FIELDS NAMED ABOVE the stored (pk_db) rows also carry score, climb_difficulty, athlete_difficulty, difficulty_components, cadence_drift_pct, cadence_penalty, heat_risk, estimated_kj, expected_carbs_g, torque_avg/p95/max_nm with torque_source, pedal_force_avg_n/pedal_force_p95_n with crank_length_mm, and where the climb sat in the ride (climb_index, elapsed_before_s, distance_before_m, elevation_before_m, kj_before) — ask for them with fields if you need them. duration_s here is the FULL elapsed span including any standing time (activity_get_descents defines duration_s differently — there it is the moving time).
activity_get_climbs
Returns detected DESCENTS for an activity — the downhill counterpart to activity_get_climbs, deliberately NOT its mirror image. The question on a descent is not "how hard" but "how well did the body recover, and did the rider actually rest or keep working". Each descent has start_distance_m, end_distance_m, length_m, elevation_loss_m, avg_grade_pct, max_grade_pct (both negative — a steeper descent is more negative), duration_s, avg_speed_kmh, max_speed_kmh, speed_cv (coefficient of variation of speed, computed from the raw 1 Hz stream — a proxy for braking/technical terrain; higher = more speed variation = more technical or more hesitant), hr_start_bpm/hr_end_bpm (mean of the first/last 30 s), hr_min_bpm, hr_drop_bpm (hr_start - hr_end — the CORE metric: a clean drop means the body is recovering; a flat or rising HR despite near-zero effort is an early-warning sign — heat, dehydration, fatigue still working, worth surfacing to the rider), hr_recovery_rate_bpm_per_min (null below 180 s of SPAN, where a per-minute rate is not comparable, and computed over the span rather than over duration_s because a pulse falls in real time even while standing), kj_spent (mechanical work done DURING the descent — should be near zero on a cleanly-coasted descent; a high value means the rider pedalled instead of resting, which shows up later at the next climb), avg_power_w (informational only, not a performance measure), coasting_pct (percent of time freewheeling — the single most informative number on a descent), pedalling_time_s, and temperature_start_c/temperature_end_c/temperature_delta_c. Detection: candidate descents come from the activity's raw distance/altitude stream (peak-to-valley), length >= 1000 m, elevation_loss_m >= 50 m, avg_grade_pct <= -2. There is NO minimum-speed criterion: a descent is the TERRAIN, not how it was ridden, so a rest stop does NOT split one and standing time stays INSIDE the reported descent. The standing seconds are taken out of the FIGURES instead: duration_s is the MOVING time, stillstand_s the standing time within the same stretch, and the wall-clock span is duration_s + stillstand_s. avg_speed_kmh and coasting_pct are computed over the moving samples only, while max_speed_kmh is over all of them (a maximum cannot arise while stopped). A short counter-climb within an otherwise-descending stretch does not split it, as long as the net elevation trend stays negative. WHERE THE NUMBERS COME FROM, in this order, and data_source always says which: "pk_db" = the version the processing pipeline stored (the SAME one the PaceKeeper app shows; only this one carries segment_id and name, so only here can a descent be referred to by its name) — "cache" = this tool's own result cache, 30 days — "live"/"recomputed" = computed now from the locally stored raw 1 Hz stream, where "recomputed" means force_refresh was used — "not_cached" = the raw stream is not here yet, a background job was queued, descents is null, retry in ~1 min. force_refresh skips pk_db and the cache and recomputes from the locally stored raw stream; it NEVER calls intervals.icu, so there is no rate-limit exposure. If the raw stream exists but is unreadable, that is an error, not a queue (a refetch would fail on the same object). fetched_at_utc is present on the cache/live answers and names when the result was computed.
activity_get_descents
Reads the stored nutrition day (YYYY-MM-DD) from PaceKeeper. Returns found (false when nothing is stored for that day), day (the whole day object, or null) and server_date (the athlete's local today). fuelling[] repeats the day's on-the-move fuelling entries separately, filtered out of day.meals — it is a view on the same data, not extra data. Reading is not limited to today or later; any date may be read.
nutrition_get_day
Returns the athlete's nutrition constraints. ALWAYS call this before generating any meals and stay within it. Fields: diet_type + diet_rule (vegan/vegetarian/pescetarian/"" ); meat_per_week / fish_per_week (max meals of that diet per week); allergies[] + intolerances[] (HARD — never include these ingredients); dislikes[] (avoid where possible); preferences (free text to honour); cuisine_style, preparation_style[], nutrition_goal, nutrition_focus[]; meals[] (the athlete's configured meal slots: each {type, time} — use these EXACT slot types for each meal's "t" and honour the slot time); upcoming_training[] (planned sessions for the next ~8 days; BEST EFFORT — on any internal failure it is an EMPTY list, which does not mean "nothing planned": {date, start, end, duration_min, name, type, load} in the athlete's local time — use start/end to time pre/post-workout meals relative to the slot times, e.g. a carb-rich meal before an early session, protein after; each session MAY carry meal_flexibility {flexible, displaced_slots[], guidance} — the key is absent entirely when the athlete has strict slot times or has switched training-day flexibility off, so check for it rather than assuming it — when flexible=true its window displaces those slots, so THAT day you may set meal times freely (no max_deviation limit) and omit the displaced meals per the guidance, keeping at least one meal); work_schedule {shift_work, work_days (ISO weekday ints 1=Mon…7=Sun), work_from, work_to (HH:MM local)} — on a work day, any meal that falls within work_from…work_to should be portable / meal-prep-friendly / pre-cookable (no fresh on-the-spot cooking); schedule heavier cooking outside work hours; if shift_work=true the hours vary day to day, so keep meals flexible and meal-prep-friendly. fuel_products[] (the athlete's OWN fuelling products: {id, kind, name, url?, size?, per-unit carbs_g/fluid_ml/sodium_mg/kcal?, macros_known, and for containers is_container=true + capacity_ml}) — THREE kinds: CONTAINERS (is_container=true, kind bottle/flask — a capacity you fill with a mix, no own macros), SELF-MIXED (have a components[] array — built from ingredients, totals summed), and BOUGHT products (ready-to-use, macros set or resolvable from url). When planning a fuelling entry's items[], PREFER these over inventing new products and set the item's product_id to the matching id; if a product has macros_known=false, look its macros up from its url, fill carbs_g/fluid_ml/sodium_mg (and kcal) on the item, and the server persists them back onto the product. CONTAINERS (kind bottle/flask, is_container=true) have NO macros of their own — they hold capacity_ml; to use one, fill it with a drink_mix/self-mixed product and compute the item's carbs_g/fluid_ml from capacity_ml + that mix (a bottle item = the filled bottle: fluid_ml≈capacity_ml, carbs_g = the mix amount for that volume). SELF-MIXED products carry components[] (each {name, url?, amount?, per-component carbs_g/fluid_ml/sodium_mg/kcal?}) — if the product's macros are unknown or you need to estimate the mix, resolve EACH component's macros from ITS component url (+ its amount), SUM them for the product total, and put that total on the item; the product has no central url. The server refuses a meal that breaks diet_rule or contains an allergy/intolerance term; a dislike only produces a warning. THE WEEKLY meat_per_week/fish_per_week LIMIT IS ENFORCED ON EVERY WRITE PATH (nutrition_set_week, nutrition_set_day, nutrition_set_meal, nutrition_patch_meal, recipe_update_single): the server counts the ENTIRE ISO WEEK FROM STORAGE, not just your payload, and the days you are writing REPLACE their stored contribution. A write is refused only when it would push a week ABOVE the limit; a write that LOWERS the count is always accepted, even while that week is still over the limit — so a week that was already over it before this check existed can still be repaired day by day.
nutrition_get_preferences
Reads stored nutrition days starting at week_start_local_date, default 7 days, at most 14. MIND THE TWO SIMILAR KEYS: days in the response is the COUNT that was asked for, while days_payload is the LIST of [{local_date, found, day}] — one entry per date in the window, with found=false and day=null for a date that has nothing stored. server_date is the athlete's local today. Any dates may be read.
nutrition_get_week
Reads ONE meal of a stored day, selected by meal_type (the athlete's configured slot types, e.g. breakfast/lunch/dinner — take the exact spellings from nutrition_get_preferences.meals[].type; the server normalises common variants). Returns found (false when that slot is empty or the day is not stored) and meal (the meal object, or null). Any date may be read.
nutrition_get_meal
Returns planned calendar events and completed activities for a date range (max 21 days). "planned_events" contains the workouts, races and notes scheduled in the calendar, as PaceKeeper stored them from intervals.icu. "activities" contains completed activities, each with a "streams_cached" boolean: true means activity_get_compressed_streams can return per-minute data for it right away, false means calling it will only queue a background fetch (retry in ~1 minute). Served from PaceKeeper's own store, not fetched from intervals.icu per call — there is no cache in front of it. fetched_at_utc is when that store was last filled, so it is normally EARLIER than your call; that is expected, not stale data. data_source is "pk_domain_projection" when the data is there and "not_synced_yet" when this account has not been ingested yet. force_refresh is still accepted and HAS NO EFFECT — passing it does not make the answer newer. By default (resolve=true) each planned workout step whose power/hr/pace target is a PERCENTAGE is enriched with a "resolved" object of ABSOLUTE values, computed server-side from the athlete's per-sport settings. Accepted unit spellings are "%ftp", "%lthr", "%hr", "%maxhr"/"%max_hr", "%pace", AND the bare "percent"/"%" (whose basis is then the channel: FTP for power, LTHR for hr, threshold pace for pace). "%hr"/"%maxhr" resolve against MAX HR, "%lthr" and a bare percent against LTHR. power → {low_w, high_w, ftp_used, source}, where source is "indoor_ftp" only for an indoor event that HAS a positive indoor_ftp and otherwise "ftp"; hr → {low_bpm, high_bpm, lthr_used|max_hr_used, source}; pace → {low_mps, high_mps, threshold_pace_used, source}. Each resolved event also carries a top-level "targets_resolved_basis" ({ftp_used, ftp_source, lthr, max_hr, indoor}) so it is visible which FTP was applied. Percentages alone are ambiguous (indoor vs outdoor FTP differ) — prefer the resolved absolute values. Set resolve=false to skip this and return only the raw percentages. THE RESOLUTION IS BEST EFFORT AND FAILS SILENTLY: if the athlete has no sport settings at all, if none matches the event's type, if the event has no workout_doc with steps, or if the needed basis (FTP/LTHR/max HR/threshold pace) is missing, the event comes back UNCHANGED — with no "resolved" object and no targets_resolved_basis, and nothing in the response says so. Check for the key instead of assuming it; and in targets_resolved_basis, ftp_source reads "ftp" even when ftp_used is null, so it is a default there and not a measurement. An activity's fields come from TWO layers with different freshness: the canonical ones are kept up to date by the ingest, while fields that only exist in the captured intervals.icu original are a SNAPSHOT OF THE FIRST CAPTURE and are deliberately not refreshed afterwards — if intervals.icu later changes such a field, the value here stays as first seen. For completed activities: compliance is set to null when there is no paired planned workout (a 0 there means "unknown", not "0% completed"); and to judge aerobic decoupling/cardiac drift prefer icu_power_hr_z2 (together with icu_power_hr_z2_mins) over decoupling — decoupling is returned for every activity even when it was not a steady aerobic effort (intervals, recovery spins), where it is meaningless.
training_get_range
Returns the athlete's best-power-vs-duration curve(s) computed by PaceKeeper from its own stored per-activity curves — the maximum mean power sustained for each duration over a time window. The sport is treated as a GROUP: \"Ride\" covers every activity type sharing that sport setting (Ride, VirtualRide, Cyclocross), which is what the returned sport_types_used lists. 🔴 THE CURVE MAY RISE in places. That is not an error: two efforts separated by a recovery mean a longer window can capture both while every shorter one captures only one. Do not smooth it. Answers "what is my 5-min / 20-min / 1-hour best power" in one call, instead of triangulating from individual activities. It does NOT answer "what is my FTP/CP/W'" -- see power_models below. Each requested curve carries best_watts_at_s at key durations (5s,15s,30s,1m,2m,5m,8m,10m,20m,30m,60m → {at_s, w, wkg}), weight_kg, computed_stops (how many support points the curve has) and the window dates. A duration KEY IS OMITTED when the stored curve has no support point for it, and wkg is omitted when no weight is known -- an absent key is not a zero. weight_kg itself may be null. curves selects the windows: comma-separated from 42d, 1y, 2y, s0 (current season), s1 (previous), all, or a custom range r.YYYY-MM-DD.YYYY-MM-DD (default "42d,1y,all"). sport_type defaults to "Ride". Served from the PaceKeeper store; there is no cache to bypass and force_refresh has no effect. KEYS vs at_s: the response keys stay the familiar durations (5,15,30,60,120,300,480,600,1200,1800,3600), but at_s names the stop that was ACTUALLY measured — PaceKeeper computes 1..300 s second by second and logarithmic stops above that, so key 1200 carries at_s 1170. Nothing is interpolated. This is now the SAME grid activity_search filters on, so a duration read here can be used there as long as you pass at_s, not the key. power_models[] is ALWAYS AN EMPTY ARRAY, and there is NO vo2max_5m field at all: CP/W-prime/Pmax/FTP estimates are model fits, not maxima, and PaceKeeper does not compute them. Do not wait for them to appear and do not derive an FTP from this curve yourself; models_note in the response says the same thing. Season windows s0/s1 are NOT supported (no season start is stored): they are skipped and listed in unsupported_windows instead of being silently mis-ranged. A window with no stored curve is left out of curves[] entirely; if NO window has data, curves is empty, data_source is "not_synced_yet" and note explains that the curves are built during stream ingest -- that is NOT a statement that the athlete did not ride. fetched_at_utc is the state of the ingest, not the time of this call. grid_note repeats the key-vs-at_s rule in the response.
athlete_get_power_curve
Returns the athlete's per-sport settings as PaceKeeper stored them from intervals.icu — a near-verbatim copy of the intervals.icu record, around 60 fields per entry, not a selection. The ones usually wanted: FTP (and indoor_ftp), power zones, sweet-spot range, LTHR, max HR, HR zones, threshold_pace, and types[] — which activity types each configuration applies to (e.g. "Ride", "Run"). An EMPTY list with data_source "pk_domain_projection" means "this sport is not configured"; empty with "not_synced_yet" means the ingest has not run. Needed to interpret raw numbers from activity_get_compressed_streams or wellness_get_range (e.g. whether 250W or a given HR was easy or hard for this athlete). Reads the same own store as athlete_get_profile. Served from PaceKeeper's own store, not fetched from intervals.icu per call — there is no cache in front of it. fetched_at_utc is when that store was last filled, so it is normally EARLIER than your call; that is expected, not stale data. data_source is "pk_domain_projection" when the data is there and "not_synced_yet" when this account has not been ingested yet. force_refresh is still accepted and HAS NO EFFECT — passing it does not make the answer newer.
athlete_get_sport_settings
Returns the numbers PaceKeeper has already computed for one activity, assembled as a single document — the exact same input the server-side AI analysis uses. Use this before writing an activity analysis: it saves you from fetching streams and recomputing anything, and it guarantees your analysis rests on the same figures the app shows. Contains: sport_family (leistung|tempo|wasser|kraft|ebike|unbestimmt — pick your wording and emphasis from this, an e-bike ride's wattage is not a fitness statement), summary (sport, duration, distance, elevation, average/normalised power, average/max HR, cadence, calories, training load, intensity factor, decoupling, lap count, indoor flag, how it felt), zones (power_zone_times, hr_zone_times_s, hr_zone_bounds_bpm), metrics (tss, normalized_power_w, hrss, trimp_s, pw_hr, power_curve_w at 5/60/300/1200/3600 s), climbs and descents with their ridden performance, recoveries (HR drop after each climb: at the end, after 1 and 2 minutes, plus the rate over the first minute), and load_context (ctl/atl/tsb for the day, plus any activity within the previous 4 hours with its duration and load — the athlete may have done real work shortly before, whatever the sport). The response also carries `missing`: the reasons certain pages cannot be served (no_power, no_heartrate, no_intervals, too_short, no_streams, merged_ride). READ `missing` — an absent figure means it was never computed, not that it is zero, and a page listed there should be reported as "no statement possible", not guessed at. Fields that do not apply are ABSENT rather than null or 0. Numbers arrive as JSON numbers; a value stored as 54.0 may come back as 54, so compare numerically. Note `load_source` on previous activities: `trimp_sekunden` is scaled roughly 60x against intervals.icu's own `trimp`, so never compare it against a TSS. The document also carries `input` and `input_hash`; that hash is what activity_set_analysis later compares to decide whether your figures went stale. If the document cannot be read at all the call is refused — that is a lookup failure, not an empty result. fetched_at_utc on this response is the time of THIS call, not of the computation.
activity_get_analysis_input
FIRST CALL for a PaceKeeper routine run (build 2026.10.23.0709). Takes the value of the "pacekeeper_task" line from the payload and returns the exact steps, the tools to use, and whether the steps repeat per activity_id. Call this before anything else and follow what it returns; do not infer the steps from the task name. An unknown task is rejected with the list of known ones — never guess. Today there are exactly two: "activity_analysis" (repeat_per "activity_id" — work through EVERY activity_id in the payload one at a time and write each one separately) and "connection_test" (repeat_per null). The response carries task, steps[], tools[] and repeat_per; follow steps[] literally rather than reading intent into the task name.
pacekeeper_task_steps
Reads the athlete's training plan(s). WITH plan_uid: the full plan — window, main goal (A race), periodization phases and manual events. WITHOUT plan_uid: only an INDEX of the plans, each with plan_uid, title and updated_at and NOTHING else — no window, no phases, no goal; fetch the one you want by its plan_uid afterwards. Nutrition containers (plan_uid starting with "nutrition_") are left out of the index. A plan that exists but has no snapshot version yet answers found:true with a note and no phases. Use this before plan_update to see the current phases/events.
plan_get
Returns stored daily wellness days for a date range (max 90 days), in intervals.icu field names: hrv, hrvSDNN, restingHR, avgSleepingHR, sleepSecs/sleepScore/sleepQuality, weight, ctl, atl, ctlLoad, atlLoad, rampRate, plus the self-reported scales (soreness, fatigue, stress, mood, motivation, injury), comments and further device values (spO2, systolic/diastolic, steps, respiration, bloodGlucose, lactate, bodyFat, vo2max, readiness, baevskySI, menstrualPhase, hydration). THERE IS NO TSB FIELD — derive it yourself from ctl - atl if you need it. carbohydrates, protein, fatTotal and locked are ALWAYS null: they are not ingested, so null here means "not known to PaceKeeper", not "nothing eaten". The list is SPARSE: only days that exist in the store are returned, a day without an entry is simply missing (it is not returned as an empty row). Served from PaceKeeper's own store, not fetched from intervals.icu per call — there is no cache in front of it. fetched_at_utc is when that store was last filled, so it is normally EARLIER than your call; that is expected, not stale data. data_source is "pk_domain_projection" when the data is there and "not_synced_yet" when this account has not been ingested yet. force_refresh is still accepted and HAS NO EFFECT — passing it does not make the answer newer.
wellness_get_range
Diagnostic ping (build 2026.10.23.0709). Returns the live server build (mcp_version + server_build_utc). Use it to test tool-list propagation: its very PRESENCE in your tool list means the client refreshed to a recent version, and the returned mcp_version tells you the live build. If this tool is missing, or the build baked in this description differs from the returned mcp_version, the cached tool list is stale — reconnect. Response: pong, mcp_version, server_build_utc and server_datetime_utc (the server clock now).
pacekeeper_ping
Writes SUBJECTIVE ratings for a completed activity: feel (1-5), rpe (1-10 rate of perceived exertion), tags (string[]). MERGE, not replace: passing only rpe leaves an existing feel untouched. This is the only write that can overwrite something on an activity, so it deliberately merges. Response echoes updated:{} and previous:{} — previous is read from PaceKeeper's own store (the same place the write goes), so it can lag behind intervals.icu. WHERE THE WRITE LANDS: in PaceKeeper's own store, and the delivery to intervals.icu is QUEUED (response data_source="pk_pending", seq = the queue entry). When this call returns the change has NOT reached intervals.icu yet — say it is saved and syncing, never that it is already in the athlete's calendar. Pass a field as null to clear it; tags:[] stores an empty list rather than clearing. An unknown field is refused by name. (To DELETE an activity: not possible — activities are recorded measurements and are never deletable via this MCP.)
activity_set_feedback
Writes a week nutrition payload as validated day objects. days_payload is an array of day objects (typically 7). Each day object uses the key "d" (YYYY-MM-DD) plus "meals" — do NOT add a "date" key. Same RULES per meal as nutrition_set_day: each meal requires t, name, tags, diet (meat|fish|vegetarian|vegan), srv, p, k, m, ing, prep; ingredient amounts in q (number, >0) + u ("g"|"ml"|"piece"), or u="pinch" with NO q for a pinch (eine Prise; dry spices/salt/pepper only, never converted to grams) with n the NAME ONLY (never "200 g Skyr"); every meal MUST include prep (array of at least 3 concrete steps) and realistic macros m (f/c/p grams). Any day violating these is rejected with a specific error — fix and resend. Call nutrition_get_preferences first: the whole payload is checked against the athlete's diet form, allergies/intolerances and the weekly meat/fish limits before anything is stored. THE WEEKLY LIMIT IS COUNTED PER ISO WEEK AND FROM STORAGE: the days you send replace their stored versions, the rest of each touched week is read from the store, and a payload spanning two ISO weeks is counted as two weeks (not one). The write is refused only when it would push a week ABOVE the limit; lowering the count is always accepted. week_start_local_date does NOT bind which dates you may send — each day is assigned to the ISO week of its own d. Every day's d must lie in the window below; one day outside it rejects the whole write. THE DATE WINDOW, as the server actually enforces it: from YESTERDAY (one day of grace for timezones west of UTC) up to 366 days ahead; outside that the write is refused and the error names the limit. Use server_date from nutrition_get_preferences as "today". EDIT IN PLACE — do NOT clear and recreate: keep the SAME meal slots (t) across re-writes so each meal keeps its revision history; to change a single meal, use nutrition_set_meal instead. RESPONSE FIELDS: stored_days, stored_count, preference_warnings and training_context.
nutrition_set_week
Writes/replaces the on-the-move FUELLING (Verpflegung) for a day — intake DURING a session, NOT a meal (no ingredients, no prep). Use THIS for fuelling instead of nutrition_set_day/set_meal. Give a per-hour rate {carbs_g_per_h, optional fluid_ml_per_h, sodium_mg_per_h} and EITHER a window {start,end} OR a ref (a training event id from nutrition_get_preferences.upcoming_training[].id — the window then follows that session). The server derives the totals from rate × window duration: carbs_g, kcal (= carbs_g × 4), fluid_ml, sodium_mg and duration_min. IT DOES NOT DERIVE FAT OR PROTEIN — both are stored as 0 for a fuelling entry, so fat and protein in a bar or gel do not count towards the day. The figures come from the RATE, never from the sum of items[]. They are returned under the key _fuel on the fuelling entry. ALSO give items[] — the CONCRETE carry objects the athlete takes and consumes (bottles with mix, flasks, gels, bars, chews, salt caps, water, real food): each {kind, optional name, count, product_id?, per-unit carbs_g/fluid_ml/sodium_mg/kcal}; together they should roughly meet the rate. PREFER the athlete's own fuel_products[] (set product_id); if a product has macros_known=false resolve its macros from its url and put them on the item (the server persists them back). CONTAINERS (bottle/flask, is_container) have no macros — fill them with a drink_mix and compute the amount from capacity_ml. The fuelling is stored inside the day's meals with kind="fuelling" so it counts toward the day total; it is exempt from slot times, diet form and the weekly meat/fish count. RESPONSE FIELDS: the stored fuelling entry (with its derived _fuel totals), the whole day, stored_at_utc and training_context. Re-writing with the same ref (or day, if no ref) replaces the existing fuelling for that session in place. THE DATE WINDOW, as the server actually enforces it: from YESTERDAY (one day of grace for timezones west of UTC) up to 366 days ahead; outside that the write is refused and the error names the limit. Use server_date from nutrition_get_preferences as "today".
nutrition_set_fuelling
Writes/replaces one meal (recipe) at a given day + meal type. The recipe input schema below documents every field. Required recipe fields: name, tags, diet, srv, p, k, m, ing, prep (t is set from the meal_type argument). diet must be one of meat|fish|vegetarian|vegan (use meat/fish for meat/fish dishes). Each ingredient's amount goes into q (number, >0) + u ("g"|"ml"|"piece"), or u="pinch" with NO q for a pinch (eine Prise; dry spices/salt/pepper only, never converted to grams); n is the NAME ONLY (never "200 g Skyr"); the meal MUST include prep (array of at least 3 concrete steps); macros m (f/c/p grams) must be realistic. Violations are rejected with a specific error — fix and resend. Call nutrition_get_preferences first and respect the athlete's diet form and allergies/intolerances — the server rejects meals that break them. THE WEEKLY MEAT/FISH LIMIT IS ENFORCED HERE TOO: the server counts the whole ISO WEEK FROM STORAGE (the day you touch replaces its stored version) and refuses the write only when it would push that week ABOVE meat_per_week/fish_per_week. Lowering the count is always accepted, even while the week is still over the limit. The date THE DATE WINDOW, as the server actually enforces it: from YESTERDAY (one day of grace for timezones west of UTC) up to 366 days ahead; outside that the write is refused and the error names the limit. Use server_date from nutrition_get_preferences as "today". Prefer this over nutrition_set_day when changing a SINGLE meal: it overwrites exactly that one slot in place and leaves the rest of the day RESPONSE FIELDS: stored_at_utc, validated_with_schema, preference_warnings, the whole stored day and training_context. — and that meal's revision history — intact.
nutrition_set_meal
Writes ONE meal of ONE day — the same operation as nutrition_set_meal, under the older name; prefer nutrition_set_meal in new code. It does NOT maintain a reusable recipe library: what is written is the meal slot of the given local_date, and it replaces whatever was in that slot. THE SLOT MUST BE NAMED: this tool's schema has no meal_type, so the slot has to come from the recipe's own t field — without it the call is refused with "meal_type is required". recipe_id is carried through and echoed back. The server validates the recipe against the day_nutrition meal schema, and enforces the athlete's diet form and allergies, before storing anything. THE WEEKLY MEAT/FISH LIMIT IS ENFORCED HERE TOO: the server counts the whole ISO WEEK FROM STORAGE (the day you touch replaces its stored version) and refuses the write only when it would push that week ABOVE meat_per_week/fish_per_week. Lowering the count is always accepted, even while the week is still over the limit.
recipe_update_single
Updates exactly one planned training event. WHERE THE WRITE LANDS: in PaceKeeper's own store, and the delivery to intervals.icu is QUEUED (response data_source="pk_pending", seq = the queue entry). When this call returns the change has NOT reached intervals.icu yet — say it is saved and syncing, never that it is already in the athlete's calendar. The server validates the training object against day_training_schema_hf before writing anything (response: validated_with_schema, applied_payload = what you sent). REQUIRED training fields: d (YYYY-MM-DD), n (name), c=WORKOUT, sp (sport), tp (training type), st (HH:MM), desc, w (at least one work block). Workout structure in w follows the OpenAI training prompt rules: each item is either a step block (l/du/ta/ca, optional in=intensity) or a repeat block (l/n/steps), with steps as the repeated pattern only once (no nested repeats). Duration du accepts combined units, e.g. "1h40m" = 100 min. Set the optional in (warmup/interval/recovery/active/cooldown/rest) on interval work/rest steps so they are classified correctly; if omitted the server derives it from ta. Target examples for ta: "65%-75%", "Z4", "ramp 50%-75%", "4:45/km", "150-160 bpm", "Z2 HR", "90% LTHR", "70% HR", "Z2 Pace", "70% Pace". "Z4" alone is always a POWER zone; for heart rate use "Z2 HR"/"Z2-Z3 HR" (zone), "90% LTHR"/"90-95% LTHR" (percent of threshold HR — most common), or "70% HR"/"75-80% HR" (percent of max HR — a different basis than LTHR, not interchangeable). Absolute bpm ("150-160 bpm", "155 bpm") is converted to %LTHR server-side and requires LTHR to be configured for the sport in intervals.icu — if it isn't, the request is rejected with a clear error naming the target; prefer "% LTHR" directly when possible. Pace also accepts /100m, /100y, /mi, /500m, /400m, /250m instead of /km (e.g. "1:30/100m" for swimming — there is no power fallback for swim, pace is the only realistic target there). Cadence example for ca: "85-95 rpm".
training_update_single
Replaces the plan definition (title, window, main goal, phases, events) of an EXISTING plan identified by plan_uid. Sessions/workouts and nutrition already attached to the plan are preserved. The same phase validation as plan_create applies (contiguous, no gaps, full window coverage, valid types). Read the plan first with plan_get. CONFIRMATION: if the new phases move FUTURE trainings into a different phase family, the first call returns applied=false, requires_confirmation=true and the list of trainings that would be removed WITHOUT changing anything — present that list to the user, and only after they confirm, call plan_update again with the same arguments plus confirm_prune=true. Past trainings are never removed. ON CONFIRMATION the removed trainings' calendar entries are also QUEUED FOR DELETION at intervals.icu (response: pruned_events_queued_for_removal) — queued, not yet deleted. Updating also sends a push notification to the RESPONSE FIELDS: applied (false while a confirmation is outstanding), requires_confirmation, pruned_session_count, pruned_sessions, pruned_events_queued_for_removal, version_id and plan. athlete's devices.
plan_update
Writes/updates ONE entry in the athlete's fuel-product library (nutrition_get_preferences.fuel_products[]). Use this to TEACH the library a product's macros once you have resolved them — especially a drink_mix or self-mix whose macros_known was false: the implicit day write-back only reaches a bought single-unit product via an item's product_id and never overwrites, so the composition of a mix could not be persisted. Give kind + name (required). To UPDATE an existing product pass its id; omit id to create one (or to match+update by kind+name). Put the resolved macros for one reference unit into carbs_g/fluid_ml/sodium_mg/kcal and note that unit in size (e.g. "60 g serving" or "1014 g batch"). For a CONTAINER (bottle/flask) set capacity_ml and leave the macros empty — containers have no intrinsic macros. For a SELF-MIX also pass components[] (each name + amount + its resolved macros) so the composition is remembered; the product-level macros should be the sum over the reference size. macros_known flips to true automatically once any macro is stored. This only edits the product library — it does not change any day; the next fuelling can then reuse the product by product_id without re-deriving it.
nutrition_set_fuel_product
Finds the athlete's activities in a date window by ANY stored metric — not just the date. Use this instead of pulling a whole range and filtering yourself. Runs the SAME server-side filter engine as the PaceKeeper app's history page, so results are identical to what the athlete sees. Returns a compact row per hit (activity_id, name, sport, start_local, distance_m, moving_time_s, elevation_gain_m, training_load, intensity_factor, power_avg_w, hr_avg_bpm, is_indoor, gear_id) plus total_count/returned_count/has_more/next_offset — call activity_get_climbs or activity_get_compressed_streams for the detail of ONE hit. IT CAN ALSO COUNT AND AGGREGATE instead of listing: pass `aggregate` for figures over the WHOLE match set (unaffected by limit) and `group_by` to split them per day/week/month/quarter/year/sport/segment — use that for "how much did I ride in August" instead of paging through rows. data_source is "pk_domain_projection", or "not_synced_yet" with a note when this account has no stored history at all — which looks exactly like "no matches" and is not the same statement. ALL UNITS ARE STORAGE UNITS: metres and seconds, never km or minutes. intensity_factor is a FRACTION (0.82 means 82%) — sending 82 matches nothing and reports no error. Quick filters go in `filter` (ranges as {min,max}, both ends optional and inclusive). Arbitrary rules go in `groups`: each group has conditions and a mode (alle = all must hold, eine = any); `groups_mode` joins the groups the same way. Operators: gt (strictly greater), lt (strictly less), bt (between, INCLUSIVE both ends), leer (value is missing — the only operator that matches a missing value; every other operator treats missing as no-match, never as 0), is/not (for sport, gear_id and tags — on tags, `is` means the activity carries ANY of the given tags and `not` means it carries NONE of them, which deliberately includes activities with no tags at all; matching ignores case, at most 20 tags), ja/nein (for is_indoor and is_planned). CLIMB AND DESCENT CONDITIONS DESCRIBE ONE SECTION: in a group with mode=alle, the SAME climb must satisfy all climb conditions — a ride with one long flat climb and one short steep climb does NOT match "longer than 1000 m AND steeper than 10%". Ramps: climb_steepest_pct is the steepest window WITHIN a climb and needs window_m of 50, 100, 250, 500 or 1000 — other lengths are not computed and are rejected rather than silently matching nothing. So "activities with a 100 m ramp of at least 10%" is climb_steepest_pct gt 10 with window_m 100; filtering on climb_grade_pct (the climb AVERAGE) would miss it. Descent grades are NEGATIVE, so "steeper than 8% downhill" is descent_grade_pct lt -8. power_curve_w needs duration_s and it must be one of the 322 COMPUTED points: 1..300 s second by second, then logarithmic — 364, 443, 538, 653, 793, 963, 1170, 1421, 1726, 2096, 2546, 3092, 3755, 4561, 5540, 6728, 8172, 9926, 12055, 14642, 17784, 21600. THERE IS NO 1200: \"20-minute power\" is 1170 s (19:30) or 1421 s (23:41), and sending 1200 is rejected with the nearest point named rather than silently matching nothing. Climb, ramp and power-curve data exist only for activities the pipeline has already computed; activities without them simply do not match those conditions. THE RESPONSE CARRIES A "warnings" ARRAY of objects {code, field, suggested_field, group, text} — an empty list is the normal case, and you should read it whenever a search returns fewer hits than you expected. A warning does NOT mean the query failed: it was valid and was executed unchanged. It means the query probably measures something other than what was meant — the usual case being an activity-wide field placed next to a climb condition in the same mode:alle group (cadence_avg_rpm is the average over the WHOLE ride, so "steep ramps AND under 65 rpm" matches nothing, because no ride averages 65 rpm; climb_cadence_rpm is the per-climb field that was meant). Act on it by re-running with suggested_field in place of field, and say so rather than reporting the empty result as an answer. Read suggested_field rather than parsing text: text is German prose meant for a human reader and may be localised or reworded, while code and the two field names are stable.
activity_search
Stores YOUR written analysis of one activity in PaceKeeper, where the app displays it. Call activity_get_analysis_input first and write from those figures — do not recompute them. There is ONE valid analysis per activity: this replaces whatever was there, whether written by you or by the server. Shape: {schema:1, overview:{text}|null, pages:{power|heart|intervals|technique: {summary, fatigue?} | {skipped:"<reason>"} | null}, coach:{strength|finding|next_step: {title,text}|null, more:[{title,text}]}}. 🔴 THREE STATES PER PAGE, and the difference matters to the reader: {summary,…} = you have something to say · {skipped:"no_power"} = NO STATEMENT IS POSSIBLE, the data does not exist · null = you checked and there is nothing worth reporting. Never write a summary for a page whose data is listed under `missing` in the input — use skipped with that exact reason (no_power, no_heartrate, no_intervals, too_short, no_streams, merged_ride). Leaving a page key out entirely means "this schema did not know that page" and is not for you to use. overview and coach are expected for every sport; the four pages only where data exists. Write in the athlete's language (the input carries it) and pass it as `locale`. Pass `model` so later readers know which model wrote this. WRITING ALSO SENDS A PUSH NOTIFICATION to the athlete's devices so an open screen refreshes, and it books progress for the backfill run — so do not call it to "try" a draft. The response tells you the stored state and whether the input data changed while you were thinking (`stale`, with `stale_reason`) — if it did, the figures you used are no longer current and the analysis should be redone. Response also carries source:"mcp" -- this tool always records itself as the writer, it does not take an origin from you.
activity_set_analysis
Writes one full nutrition day (day_nutrition schema). The input schema below documents every field. The meals[] array holds two ENTRY KINDS. (1) A normal MEAL (kind omitted or "meal") requires: t, name, tags, diet, srv, p, k, m, ing, prep — a point in time with a portion. Optionally add rel ("pre"|"post") + ref (a training event id from nutrition_get_preferences.upcoming_training[].id) to mark it as the pre-session or recovery meal. (2) A FUELLING entry (kind "fuelling") — on-the-move intake DURING a session (a window + per-hour rate, not a meal) — is better written with the dedicated nutrition_set_fuelling tool; see that tool for the fuelling schema (rate/window/ref/items). A day can hold BOTH a fuelling and normal meals. Put the per-hour numbers into rate, never as prose in prep. diet must be one of meat|fish|vegetarian|vegan (use meat/fish for meat/fish dishes, never mislabel as vegetarian). For every ingredient the amount goes into q (number, >0) + u ("g"|"ml"|"piece"), or u="pinch" with NO q for a pinch (eine Prise; dry spices/salt/pepper only, never converted to grams) and n is the NAME ONLY (e.g. "Skyr (natur)", never "200 g Skyr"); every meal MUST include prep (array of at least 3 concrete steps); macros m (f/c/p in grams) must be realistic, never 0/0/0. Payloads violating these are rejected with a specific error message — fix and resend. ONE CASE COMES BACK AS A SUCCESS, NOT AS AN ERROR: if an ingredient carries a unit the server does not know, nothing is written and the response carries needs_user_input:true with the question to put to the athlete. Check for that key before you report the day as saved. Call nutrition_get_preferences first. WHAT THE SERVER ENFORCES HERE: diet form and allergies/intolerances are refused hard; a DISLIKE is only a warning (see preference_warnings in the response, which also lists meal times that were dropped). THE WEEKLY MEAT/FISH LIMIT IS CHECKED ON THIS PATH TOO: the server counts the whole ISO WEEK FROM STORAGE (your day REPLACES its stored version) and refuses the write only when it would push that week ABOVE the limit. Lowering the count is always accepted, even while the week is still over it. THE DATE WINDOW, as the server actually enforces it: from YESTERDAY (one day of grace for timezones west of UTC) up to 366 days ahead; outside that the write is refused and the error names the limit. Use server_date from nutrition_get_preferences as "today". EDIT IN PLACE — do NOT clear and recreate: to change an existing day, re-send it with the SAME meal slots (t) as the athlete's configured meals; each slot overwrites its meal and keeps that meal's revision history. By DEFAULT omitting a slot KEEPS it (safe partial update) — set delete_missing_meals=true to instead delete every slot not in this payload. To change just ONE meal, use nutrition_set_meal instead of rewriting the whole day. RESPONSE FIELDS: stored_at_utc, validated_with_schema, preference_warnings (dislikes hit and meal times that were dropped) and training_context (the sessions around this day).
nutrition_set_day
PaceKeeper AI ChatGPT Plugin FAQ
How the directory, categories and Discoverability Score work.
Read the methodologyHow do I improve PaceKeeper AI's ChatGPT Plugin discoverability?
The levers are the listing surface agents actually read: names, descriptions, keywords, tool metadata, and registry health. Which lever matters depends on where discovery breaks, which is what continuous measurement shows.
What are PaceKeeper AI alternatives on ChatGPT?
As of 2026-11-02, PaceKeeper AI competes with AgentRunCoach, AI Endurance, Athlete Space, Basketball Assistant, Boulie, Coach to Wrist, COROS, Eixo Run and 24 more in ChatGPT Endurance Sport Training Planners, ranked by public Discoverability Score.
Where is this profile measured?
This profile uses the geography attached to the latest public registry snapshot: US. Locale tags are intentionally omitted.