Imported from eschizoid/stride (
skills/stride/SKILL.md). Install upstream withnpx skills add eschizoid/stride --skill stride. Copyright stays with the author.
Stride — local training engine
stride syncs the user's Strava data into ~/.stride/db.sqlite and computes
training metrics deterministically. You are the coach brain; stride is the
data engine. Don't recompute what stride already computed. Consume its JSON.
Never do training math yourself: read stride's numbers, add judgment.
Coaching workflow
stride sync --jsonis the ONE command that pulls data from Strava (#232). It re-lists a rolling 30-day window so recent edits self-heal, then drains every activity still missing streams, paced against Strava's limits. A first run on a fresh install is the whole history pull. It ends in the usual envelope (schemas/v3/sync.json) with progress on stderr. Readresumableto decide whether to run it again. It is usuallypending_streams > 0. It is also true when the LISTING was cut short and the queue is empty, and when the run was refused BEFORE its first request because the day was already spent. Readstoppedfor why the run ended (complete/budget_reached/rate_limited/daily_cap_reached/list_rate_limited/list_daily_cap_reached). The twolist_*tokens mean a 429 on the ACTIVITY LIST rather than a stream. That refusal leaves the listing INCOMPLETE and prunes nothing. Rows are missing, sosyncedandpruneddescribe a partial run.list_daily_cap_reachedis that refusal on a day whose allowance is already gone: both facts at once. THE REMEDY DIFFERS, and the remedy is the point of the distinction.budget_reached,rate_limitedandlist_rate_limitedall clear when Strava's 15-minute window rolls over, so tell the athlete about fifteen minutes.daily_cap_reachedclears at UTC MIDNIGHT, and so doeslist_daily_cap_reached. Tell them tomorrow. Advising fifteen minutes there is an instruction that cannot succeed. This file used to say exactly that ("all non-complete reasons stop on the 15-MINUTE window ... NOT tomorrow") for every stop, including that one. A first sync on a large history takes one run per 15-minute window, bounded by Strava's daily read allowance rather than by a fixed number of runs. Stride counts its own reads against that allowance, so a few thousand activities spans a day or two. The run that exhausts the day says so. Each run says how far it got. Every stored stream is permanent, so re-running is never wasted. The count lives in two config rows,strava_reads_todayandstrava_reads_day. The second is a UTC day number. A stamp that is not today IS the reset, so nothing runs at midnight. The engine maintains both rows. The only reason to touch them is the escape hatch stride names if the count is unreadable:stride config set strava_reads_today 0.completecan still leave work: a stream body that does not decode is skipped WITHOUT storing, so it retries next run and shows up instreams_skipped. An activity Strava has no streams for is NOT that case. A 404 stores an empty marker and retires it permanently (#218). Do not loop onresumablealone. The rule isstreams_fetched == 0withresumable: true. Stop and report. That one condition covers both ways a run can do nothing and still ask for another: unreadable bodies (streams_skipped > 0, which will keep returning the identical envelope), and a rate limit (stopped: "rate_limited", where the wait is ~15 minutes and the command returns immediately rather than blocking). The earlier rule keyed onstreams_skipped > 0alone and did NOT fire on the rate-limited shape, where a loop can issue dozens of Strava requests per second.stride sync --allforces a full re-list from scratch. It is a dev-mode escape hatch and the only way a deletion in OLD history propagates. Normal use never needs it. No API credentials (Strava requires a subscription for API access since June 2026):stride import <export.zip|dir> --jsonloads a Strava account export. The import is summary-level activities only (no streams), idempotent, and limited to English exports.stride analyze --jsoncomputes metrics for new/invalidated activities. It is idempotent and prints a count plus a form verdict. The full report lives insummary. If any stored streams won't decode it says "N had unreadable stream data", and they retry next sync.stride plan --jsonis THE payload for weekly planning: summary + the open sessions + last-14d activities +plan_history_28d+adherence_28d+data_freshness, in one call.plan_history_28dandadherence_28dclose the loop: they record what was targeted and what actually happened. Without them each week's plan would be a fresh guess, so read them before proposing anything.data_freshnessreports how current the rest of the payload is, as four measurements. You decide what counts as too stale to plan from. Usestride summary --jsonalone for quick check-ins.- Reason: polarization, zone gaps, form (TSB), FTP staleness, sport balance.
AND reconcile the open plan against recent activities (match by date/type,
then
stride complete <session_id> <activity_id> --jsonfor each match). - Plan the coming week:
stride week add <YYYY-MM-DD> <type> "<detail>" "<rationale>" ["<target>"] --jsontypeis the INTENSITY INTENT, not the sport: vo2max | threshold | endurance | recovery | strength | rest (free-form ok). The sport/modality goes indetail("easy row...", "outdoor ride..."): type answers why/how hard, detail answers what exactly.- the optional target is a strict literal
<reps>x<mm:ss>@<watts>W(3x12:00@230W, ADR 0014) stored BESIDE the prose, power only. Add it when the session genuinely has numbers; prose stays canonical and a prose-only session is unchanged. The echo carriestarget_knownwith impossible-zero magnitudes (target_reps,target_dur_s,target_watts);plan's open sessions carry the same four. A revise REPLACES the whole prescription, so re-planning a date WITHOUT the target clears it. Malformed literals refuse withbad_target. - an EVENT the athlete is training toward:
stride event add <YYYY-MM-DD> "<name>" --json(future dates only; removable viastride event remove <id>, since a target is not a record).stride events --jsonreturns{projected_from, baseline_known, ftp_known, events:[{id, event_date, name, days_away, ctl, atl, tsb, planned_in_window, sessions_projected}]}. The payload is CTL/ATL/TSB projected onto each event date by the SAME recurrence daily_load runs, folded over the open plan's structured targets. Untargeted sessions contribute zero, and the two counts say how blind the projection is.baseline_known: falsemeans no computed history, so every number is decay over zero. The window is(projected_from, event_date]. A session dated ON the baseline day (usually today) is excluded, because the measured baseline already contains today. Asking what tonight's session does to Saturday means planning it for a day the baseline has not absorbed yet. The projection is ADR 0010 arithmetic: stride projects the recorded plan's consequences and never proposes, ranks, or solves for a plan. Whether to CHANGE the plan in response is your judgment, and "on track" is a sentence only you may write. - TAPER planning:
stride project <YYYY-MM-DD> ["<date>=<target>,..."] --jsonreturns{target_date, days_away, projected_from, baseline_known, ftp_known, plan_source, ctl, atl, tsb, sessions_considered, sessions_projected, hypothetical}, the same walk at any horizon. Bare = the recorded open plan; with the literal, the walk runs over YOUR hypothetical plan INSTEAD (each pair<YYYY-MM-DD>=<RxMM:SS@WWWW>), echoed back inhypothetical(dates verbatim, targets as parsed canonical fields) and never stored. Compare candidate tapers by calling it once per candidate. Stride will not solve for the plan that reaches a TSB, even if you name one. That inverse is prescription (ADR 0010), and the judgment is yours. - completing a TARGETED session against an activity reports the arithmetic:
completereturns{target_known, target_reps, target_dur_s, target_watts, detected_known, detected_reps, detected_mean_dur_s, detected_mean_watts, reps_delta, watts_pct}, the recorded target beside the detected shape.reps_delta/watts_pctmean something only when BOTH flags are true; a session without power segments compares to nothing and says so. Whether the numbers constitute hitting the workout is YOUR judgment. Stride states them. - re-planning a date REVISES its open session in place (the response echoes the same
id). A plan edit is not a skip, so you can just
week addagain to change a day. Re-planning never leaves skipped tombstones. Reserveskipfor a session that was going to happen and didn't (a real adherence miss). complete/skipalso REFUSE unknown ids (error codesession_not_found/activity_not_found/bad_id), so a typo can't silently desync the log. Check for an error field instead of assuming success.- a session that didn't happen:
stride skip <id> "<reason>" [activity_id|none] --json. The optional link names the activity done INSTEAD (a substitution, not a completion; rendered→ idin week).nonereleases a link, and a bare re-skip keeps one. Refusals:activity_already_linked(that activity already tells another session's story, and the error names the blocker and the release path) andsession_done(completions are permanent; fix a mis-link by re-completing, never by skip). Skipped ≠ silently open forever. - a REST day that happened:
stride complete <id> --jsonwith no activity id (rest has nothing to link; any other type still refuses without its activity, error codeactivity_required). - a WRONG LABEL on any session, done ones included:
stride relabel <id> <type> "<detail>" ["<rationale>"] --jsonedits only the descriptive fields and returns{id, session_type, target_date, status}: the same id echoed, with status untouched as proof the edit was cosmetic (links and metrics never move; omitting the rationale keeps the stored one). A common case is a swap of days: an activity completed against Saturday's session whose label still says Sunday's plan. Do NOTweek addthe same date to fix a label, because that inserts a duplicate row you then have to skip. Refuses unknown ids (session_not_found) and non-numeric ids (bad_id), like complete/skip. One label IS behavioral, on any status:restdecides whether a barecompleteneeds an activity id. That path checks the label, not the status, so relabeling to or fromrestchanges what a completion (or re-completion) will demand.
Reading decoupling and detected structure
- Aerobic decoupling (
decoupling_pct+decoupling_knownonactivityand every progress session row;decoupling_signal= "power", "pace" (grade-adjusted), or "speed" (no altitude stream, terrain NOT normalized, read cautiously on hilly routes)): second-half vs first-half efficiency drift. LOWER is better; ≤ +5% on a steady 1h+ effort = solid durability. Only meaningful on STEADY sessions. On intervals it reflects workout shape (checksegments: when work reps are present, don't read drift as durability). Unknown when: no usable signal, the signal covers less than half the session, or |value| > 50% (artifact). - W′ balance (
activity.w_prime_balance):min_jis the lowest the anaerobic tank got during the ride andend_jis where it finished. Both read against the CP fit for that session's OWN sport family (fit_family) from the 90 days STRICTLY BEFORE it, so a session is never inside its own fit (cp_used/w_prime_used/fit_points).min_j: 0means it emptied exactly. A NEGATIVEmin_jis the model failing, not depth achieved. It means more above-CP work was done than the fitted tank holds, so the CP/W′ is stale or degenerate.model_exceeded: trueflags exactly that, and|min_j| / w_prime_usedis how badly. Report it as evidence the fit needs refreshing, NEVER as "how deep they went". Normalize byw_prime_usedbefore comparing sessions. The fitted capacity moves between dates, so raw joules are not comparable. Whenmodel_exceededis true, ONLYmin_jmeans anything: the recovery term drives on the deficit against the CURRENT balance, so a balance far below zero refills faster than the model's own ceiling allows.end_jis then arithmetic on a broken premise.fit_r2grades the fit itself. CP jumps at the window edges as rides enter and leave the 90 days, so these are WITHIN-session measures, not a comparable series.known: falsemeans there was no fit or no power stream. Never read it as "the tank stayed full". - Rep-level progression (
stride reps --json): whether the SAME workout shape is being ridden harder than it used to be, shown as per-rep watts across sessions and the within-session fade. Use it when a structured session repeats;progresswhen comparing whole sessions. - Detected structure (
stride activity <id> --json):interval_summary("3×[12:00 @ 230W / 4:00 easy]"; a pace-routed sport reads as time-per-distance, for example "5×[4:00 @ 4:00/km / 2:00 easy]", and the payload is ALWAYS metric whateverunitsis set to. The human screen follows the setting; this field does not),segments(per-rep kind/duration/avg + HR peak/avg/60s recovery drop),hr_drift+hr_drift_known(rising across reps = fatigue), anddetection_attempted(false = couldn't look, meaning no power/pace signal, which is NOT the same as "verified: no structure"). The detector reports; matching structure to a prescription stays YOUR judgment.
Output modes
PASS --json ON EVERY QUERY YOU RUN. Stride prints human tables by default and
machine JSON only when asked. Nothing infers the mode from your environment, so a
command without the flag gives you a table no matter what your harness exports. The
flag works in any argv position and the last one wins; --human forces tables even for you. STRIDE_FORMAT=json|human
(case-insensitive) sets a shell-session default and the flag beats it. Note each
shell command you run is typically a FRESH shell, so exporting it does not reliably
carry between calls. --json is the one route that always works. -- ends flag
parsing, so stride skip 5 --json -- --json stores the literal string as the
reason and still returns JSON. Note the flag is present BEFORE the terminator,
because -- stops flag parsing for everything after it. If output ever looks like a table when you
wanted data, you left --json off. Every machine response you will
consume is a versioned envelope, including usage errors ({"error":{"code":"usage",…}}) and a
bare stride --json, which answers with the command table rather than the
human help screen (#180). That table DESCRIBES rather than names (#219): one entry per
callable form, each carrying {name, args:[{name,required,example}], mutates, network, interactive, schema}.
week and week add are separate entries, because one reads and one writes. So
you can determine argument shape, whether a call writes, whether it needs Strava, and
which file under schemas/v3 the answer validates against, without reading this document.
interactive: true marks the one form you must never call unattended (auth). Prefer
that table over anything written here if the two ever disagree. The table is
cross-checked against the parser in CI, and this file is not. It is hand-written, not
generated: the verb set, the schema names, mutates, network and interactive are
each checked against the source or against behaviour, while the free placeholder TEXT
(<days> versus <limit>) is declared. Literal
argument names, both arity bounds, and the rule that required arguments come first are
all checked against the parser. One thing is NOT enveloped: stride auth, an interactive browser
flow you run by hand. stride sync narrates progress on stderr while it runs. Its stdout is
the envelope, so you can read it (#218, #232). Platform failures ARE enveloped now (#183): no_database (absent, run init),
unreadable_database (present but unopenable, from permissions or a directory in its
place, which init will NOT fix), corrupt_database, database_error (SQLite
refused the operation, e.g. a lock), network_unreachable (Strava never
answered), strava_error / rate_limited (it answered with a status),
stdin_closed, and internal_error for anything unforeseen, carrying the
clipped tag in its message. A failure without a code is therefore a bug, not a shrug.
An expired token still arrives as not_authenticated, from the boundary as well
as from sync. Success looks like
{"schema_version":3,"data":{…}}, and error looks like
{"schema_version":3,"error":{"code":"…","message":"…"}}. The payloads described in
the table below all live under .data. Every payload here (every query, every action
you branch on, the command list and the envelope itself, one
file per published payload in schemas/v3/, so the directory listing is the inventory)
is described formally in schemas/v3/*.json in the repo (required keys, types,
enums, and the error-code vocabulary, which is diffed against the source in CI so
a code stride can emit cannot be missing from the contract).
error is an OBJECT whose code carries the
in-band error names used throughout this file (unknown_command, missing_config, not_authenticated,
derived_key, …), with the human text nested in error.message. An error
envelope is ALSO an exit status: stride exits 1 whenever it emits one (0 on
success; a bare stride prints help and exits 0). Read either channel; the two
never disagree. rate_limited is the one code that appears on both sides (#227): as
an ERROR code it means the command made no progress and exits 1; as sync's stopped
value it means the drain did real work before Strava capped it, so it is a success
envelope at exit 0 with resumable telling you whether anything is still missing.
Discriminate on the envelope shape, never on the token. sync and analyze
emit JSON results too ({synced, new_activities, updated_activities, pruned, streams_fetched, streams_skipped, pending_streams, stopped, resumable} / {computed, stream_errors, form_tsb, form_tsb_known, form_state, form_delta_7d, form_delta_known, converged}), and stride config get <key> --json emits {key, value} (or the not_set
error envelope). That call is how you read timezone back, which governs what "today"
means for every date below. stride config unset <key> --json REMOVES a stored key, emitting {key, removed}. removed: false means it was already absent, which is not an error. Use it rather than config set <key> "". An empty value is refused for every key class, because it used to mean three different things depending on the key. Two of those left no way to remove a row at all (a per-sport zone override could not be dropped, and an empty write to a token left a row reading as SET).
stride config set units imperial switches HUMAN distance and pace readouts to miles and min/mi; metric is the default. It covers distances and pace columns, NOT the raw m/s rate shown for detected intervals on a pace-routed sport (#351). It does not change JSON at all. Every payload stays SI, so distance_m is metres and stats' km is kilometres whatever the setting says. If the athlete mentions miles, that is a display preference, not a change to the numbers you read.
Query commands (add --json to every one of these)
| Command | Returns |
|---|---|
stride plan --json |
planning bundle: summary + recent_activities_14d + open_sessions + plan_history_28d (EVERY session targeted in the trailing 28d, any status, with skipped_reason, completed_activity_id (plus superseded_activity_id: the completion a re-complete erased, 0 if none, and the only durable record of an overwrite)/substitute_activity_id links and completed_on = the linked activity's date) + adherence_28d {planned, completed, skipped, substituted, still_open, completion_pct, unplanned_activities} (raw counts, planned == completed + skipped + still_open, substituted ⊆ skipped; completion_pct's denominator includes still_open, so an in-window session not yet done counts against it) + data_freshness {newest_activity, last_sync, activities_awaiting_metrics, activities_awaiting_metrics_known, activities_awaiting_streams} (measurements, not a verdict). Read last_sync first, against today. It is the only field that detects an install where nothing has run in days (note it is UTC, so near midnight it can differ by a day from the local today the timezone setting governs). The summary.as_of / newest_activity gap will NOT tell you that: as_of is pinned to the day analyze last ran, so the gap equals days since the last ride only when analyze ran TODAY. Otherwise the gap just stops growing. A gap of 3 is equally consistent with "rode 3 days ago, analyzed today" and "analyzed a week ago". Treat a non-zero activities_awaiting_metrics as "run stride analyze before trusting the numbers". It counts rows never scored plus rows whose FTP, threshold pace, zones, metrics_rev or underlying inputs (a newly arrived stream being the commonest) changed since scoring, so it is never fewer than doctor's unanalyzed and equal to it in the ordinary case. activities_awaiting_metrics_known: false means the count could not be computed at all, so a 0 beside it is not "nothing to do"; run stride analyze to see why. Planned-vs-actual reconstructs from this ONE call |
stride summary --json |
as_of, CTL/ATL/TSB (the keys are fitness_ctl, fatigue_atl, form_tsb, NOT ctl/atl/tsb), plus ctl_warming_up, ramp_7d/ramp_28d_avg, form_delta_7d + form_delta_known (spelled exactly so, no 7d in the flag), form_band_days+_capped, form_state (the stable band id to switch on: high_modeled_fatigue |
stride season --json |
the season at block and month scale: blocks + months + gap_weeks. A BLOCK is a maximal run of training weeks (not necessarily consecutive, it may contain blank weeks) closed by gap_weeks+ calendar weeks with NO load (ADR 0011). Blocks are bounded by absence because absence is the only boundary in the data that is not a judgment call. Blocks are described, never named. There is no base/build/peak field and you should not invent one: load observes volume, not intent. Each block carries start_date/end_date (the first Monday of its first training week, through the last day WITH training; the two ends are not symmetric), weeks (weeks WITH training) and span_weeks (calendar weeks covered; they differ because a block may contain up to gap_weeks-1 blank weeks per interruption), total_load, mean_weekly_load (divides by span_weeks, NOT weeks), sessions (activities inside the block's window; the count does NOT always equal the months[] total, because an activity that scored no load inside an absence belongs to a month and to no block), closed, easy_pct/moderate_pct/hard_pct + polarization_known, and the trend and threshold fields below. Read fitted_start_load/fitted_end_load before slope_tss_per_week or trend_r2. A low trend_r2 means the weeks were SCATTERED around the line, NOT that the block had no trend. On this athlete a block at r2 0.10 still fell from 315 to 214 TSS/week over 71 weeks, and saying it "had no trend" is the most likely false statement this payload can produce. Weigh the slope against weeks. trend_known: false means under three complete weeks. closed: false means no absence has ended the block. Records simply stop, so its numbers are still moving. A trailing week that has not finished yet is excluded from the trend (leaving it in halved the slope on a single session) while still counting in weeks, sessions and total_load. A block is often open with its last week already complete, and in that case nothing is excluded. ftp_start/ftp_end are CHRONOLOGICAL and are what to quote, on blocks AND on months. ftp_lo/ftp_hi are an unordered min/max that reads as a direction it does not have. ftp_family names WHOSE threshold it is (a rowing threshold and a cycling FTP are different quantities) and ftp_family_pct its share. Near 50 the block is genuinely mixed, and the other family's threshold is NOT published. Do not describe such a block by one sport. months[] carries {month, load, sessions, partial, ftp_family, ftp_family_pct, ftp_lo, ftp_hi, ftp_start, ftp_end, ftp_known}; partial: true marks a month still in progress. Never compare its load to a complete month (a partial month's total against a complete one reads as a collapse even when its daily rate is higher). Reach for season for the shape of a year; compare for one window against the one before it, load for the weekly series behind a slope, progress for whether individual sessions got better. In-band errors: no_activities |
stride activities [N] [sport] --json |
last N activities (default 30), optionally filtered by sport (sport FAMILY words, case-insensitive: the words lists in Sports.families widen to their Strava spellings: bike, run, row, swim, walk/hike, strength/weights/lifting, with e-bikes excluded from bike; non-family sport_types filter exactly). Rows carry date, sport, tss, np_w, id, date, name, sport, moving_time, distance_m, tss, np_w, intensity, load_model, hard_s, relative_effort, z1_s, z2_s, z3_s, z4_s, z5_s, avg_hr, avg_hr_scored, and the companions power_known, intensity_known, hr_known, zones_known, date_known, rankable. TWO heart-rate numbers, and picking the wrong one is the mistake this row exists to prevent: avg_hr is what the device reported, avg_hr_scored is what the engine divides by (#311, #319). Score anything from avg_hr_scored and quote avg_hr back to the athlete. Reading the pair: they are EQUAL whenever no usable stream mean exists, which includes the most damaged streams. A stream lossy enough to corrupt Strava's summary also fails the coverage gate and falls back, so the three worst on record publish 0.2/0.2, 31.3/31.3 and 18/18. They differ when a stream did pass the gate and its mean differs, which happens on 669 of 738 rows: 658 of them by a rounded decimal and only 11 by a real correction over 15 bpm. And an out-of-band avg_hr_scored means the EF and speed/HR lenses refused the row, but NOT the converse. The lenses also need a normalized-power load_model or distance and moving time, so 45 in-band sessions are refused anyway. hr_known still answers only whether a reading was recorded at all. Rows carry date_known: false means the stored start_local is unreadable and date is an EMPTY STRING, a hole, not a value. Do not read that as "none on record", which is what '' means for last_hard_session_date; these rows are hoisted to the TOP of the listing so a limit cannot hide them, and each is repaired by deleting it by id and re-syncing. Rows also carry rankable: false means the engine will not give the row a ranking position, which is a WIDER condition than date_known: 2026-08-23T37:00:00Z has a perfectly readable date and an impossible clock, so it publishes date_known: true with rankable: false. rankable is what the hoist keys on, so it is the field that says why row one is row one. It is NOT a _known flag and licenses no magnitude. |
stride top <metric> [n] [sport] --json |
best sessions ranked by hr, tss, power, intensity, distance, time, or output (kJ), the leaderboard to activities' timeline. Rows carry date_known and rankable on the same rules as activities. Note rankable means CHRONOLOGICALLY orderable and says nothing about the metric you ranked by. A row can be unrankable and still lead this leaderboard, and unrankable there still means the row needs repair. In particular rankable is not about time: moving_time on these rows is as trustworthy as on any other. |
stride zones --json (alias pz) |
the 7 power zones as watt ranges from the DERIVED ride FTP: { ftp, zones: [{ z, name, lo_w, hi_w }] } (0 = open-ended bound) |
stride viz --json |
the window's self-published bus capabilities: { protocol, published_at, staleness_seconds, views: [{id, name}], fields: [{name, kind, accepts}] }. The window rewrites it at every launch, so THIS is the discovery source for steering (view numbers, directive fields, staleness), not any doc's copy. In-band error no_viz_capabilities = the window has never run against this database |
stride power-curve [days] [sport] --json (alias pc) |
best mean-max power per ladder duration over the window (default 90 days): { window_days, sport, points: [{dur_s, watts}], cp, w_prime, fit_points, fit_r2 }. Weigh the fit with fit_r2. The regression is power against 1/duration, so a LOW value means the 5/10/20-min bests do not fall on a line and CP/W' are drawn through scatter. A HIGH value says the line fits, not that the model fits the athlete: bests taken from steady rather than maximal efforts sit on a near-flat line that scores well and yields an implausibly small W'. It is 1 by construction at two points and 0 when there is no fit (cp 0). The payload is the CP curve behind FTP |
stride pace-curve [days] [sport] --json (alias cs) |
the pace-sport twin of power-curve: best mean-max GRADE-ADJUSTED speed per ladder duration over the window (default 90 days), returning { window_days, sport, points: [{dur_s, speed}], cs, d_prime, fit_points, fit_r2, available_sports }. cs is METRES PER SECOND and d_prime is METRES, always. Both are SI, and they do not move with the units setting even though the human screen renders min/km or min/mi. Convert for the athlete yourself: cs 3.6 m/s is 4:38/km. d_prime is a DISTANCE where the power model's w_prime is an energy, and it is the finite capacity spendable above cs. Naming a sport is required in practice: with [sport] omitted the command REFUSES and fits nothing, returning cs 0 with empty points and sport: "". An empty result there means no sport was named, not "no pace data". available_sports then lists the sport_types that DO hold a speed ladder in the window, most-trained first, so you can re-ask without a second call; it is empty when a sport was named. Sport matching is EXACT, not by family as power-curve matches: a pool swim, an open-water swim and a trail run do not share a speed model, and pooling them would fit one curve through unrelated populations (the ladder is stored for anything with a distance stream, so an unfiltered fit is mostly RIDES and comes back at bike speed). Ask for the one you mean. The curve is grade-adjusted so a hilly interval is comparable with a flat one. fit_r2 carries exactly the caveats it carries for power-curve: 1 by construction at two points, 0 when there is no fit (cs 0), and a quality signal only at 3 points |
stride activity <id> --json |
one session in depth: flat z1_s to z5_s + hard_s, hard minutes, power bests (1/3/5/20min) from streams, plus streams_unreadable (true = the 0s are corrupt data, NOT a real zero). Use it to review whether a planned session hit its targets before complete-ing it; baselines compares this ride with the athlete's OWN prior comparables (90d before the activity, same sport family + duration band via ONE shared rule): per metric (ef, np, decoupling) {current, baseline_median, percentile, delta_pct, sample_count, known}; percentile is a rank whose direction is already handled (higher is better for ef/np, lower for decoupling), so weigh it by sample_count; w_prime_balance {min_j, end_j, known, model_exceeded, cp_used, w_prime_used, fit_points, fit_r2, fit_family}; see the W′ balance note above, since a negative min_j diagnoses the FIT, not the athlete; splits is per-kilometre rows from the distance stream, each {n, distance_m, elapsed_s, elev_gain_m, elev_known, avg_hr, hr_known} — SI always (the human table re-segments at the athlete's unit), elapsed_s is wall seconds so a stop inside a split belongs to it, one row can span several kilometres across a recording dropout (n then leaves a gap so later rows keep their distance position), and empty means no distance stream |
stride stats --json |
career + year-to-date totals per sport (sessions, hours, km) |
stride load [days] --json |
daily tss/ctl/atl/tsb series, chronological (default 90) |
stride week --json |
this week (Mon-Sun) PLUS unplanned rows for activities no session references. Statuses are open/done/skipped/unplanned. Rows carry substitute_activity_id ("did this instead" links, rendered → id). Unplanned rows carry their id in activity_id, NOT completed_activity_id, so discriminate on status. stride week all --json = full session log, no unplanned rows. |
stride doctor --json |
dataset health: coverage counts, per-model load provenance (scored_by), strength_unrated (strength sessions awaiting a rating), and two nested counts about date health: undateable_activities (rows with date_known: false) and unrankable_activities (rows with rankable: false, a SUPERSET, since it also counts readable dates with unusable clocks). Each corresponds to one row flag, so both can be cross-checked against stride activities. Two counts about metrics, and they answer different questions: unanalyzed is rows NEVER scored, a coverage number; awaiting_metrics is what analyze would recompute right now, the same figure plan reports, and it is the one to act on. A changed FTP or a new stream leaves unanalyzed at 0 while awaiting_metrics is the whole history. awaiting_metrics_known: false means it could not be computed at all, and config_error says why (a zone key that will not parse); the 0 beside it is not "nothing to do" |
stride compare [week|month] --json |
rolling window vs the prior one: {period, window_label, current, prior}, each side with tss/sessions/hard_min/easy_pct/ctl + has_data + polarization_known. has_data: false is the discriminator for an empty window (do not read its 0s as training); polarization_known: false means the window holds no zone seconds, so that side's hard_min and easy_pct are not measurements (the human table renders them -) — same name and predicate class as season's block-level flag, and deliberately NOT zones_known, which on other payloads means an HR stream existed |
stride tte <watts> --json |
time to exhaustion at a power YOU name, from a CP model fitted on the Ride family over a hard 90 days, returning {watts, seconds, known, status, cp, w_prime, fit_points, fit_r2, window_days, sport_family, demonstrated_s, demonstrated_w, demonstrated_known, contradicts_model}. Reach for it when the athlete names a target power and asks how long; power-curve gives the whole curve, zones gives prescriptive bands, tte answers ONE point and says nothing about pacing. Weigh the FIT before the number. fit_r2 is how well the line fits its own points and is the only quality signal that does not depend on what you asked (below ~0.9 the model is describing noise; it is 1 by construction when fit_points is 2, where the count is the signal instead). demonstrated_* is the longest effort at or above watts the athlete already has on record in the same window the fit came from, so contradicts_model: true means the model predicts less than its own inputs prove. The fit understates this rider, and the number is not usable. contradicts_model: false is NOT reassurance. The check only fires where a recorded effort happens to overlap the queried power, which on real data is a few watts of a fifty-watt band. status qualifies the rest: in_model (2-20min, where the two-parameter model holds), outside_model (real arithmetic, but the model overshoots, so read it as a direction, not a number), below_cp (the model says indefinitely; bodies do not). known here is the ONE _known flag in stride that does NOT license trusting the magnitude. It only means the arithmetic ran; status and contradicts_model are what qualify the number. fit_points counts how many of the 5/10/20-min bests existed (max 3) and is 3 for a perfect fit AND a degenerate one, so it cannot detect a bad fit alone. Note the fit EXCLUDES its own anchor date and tte anchors on today, so a ride synced today is NOT in the fit that answers today's question. activity.w_prime_balance applies the same rule anchored on each session's date, so the two differ by anchor and family, not by whether today counts. In-band errors: bad_watts, no_cp_fit |
stride reps [date] --json |
rep-level comparison: the anchor session's detected work blocks beside the same-shaped blocks of earlier sessions, returning {anchor_date, anchor_activity_id, shape, sport_family, sessions}. shape {rep_count, mean_dur_s, band_lo_s, band_hi_s, signal} IS the comparability rule (same sport family, same rep count, same rep-duration band, and the same signal, so watts never sit beside m/s, and never later than the anchor). matched_total is how many sessions matched BEFORE the 12-row window, so you can see what you are not seeing. Sessions carry per-rep avg_signal/avg_hr, mean_signal (unweighted mean of the reps), fade_signal (last rep minus first, signed), hr_rise_bpm+_known (known only when BOTH end reps carry HR), and min_dur_s/max_dur_s/uniformity. That last trio is each row's own spread, because whether an uneven session is "the same workout" is YOUR judgment, not the engine's. In-band errors: no_detected_intervals, no_intervals_on_date, irregular_anchor (the anchor's own blocks vary too much to be one repeated shape) |
stride progress [date] [asc|desc] --json |
{anchor_date, anchor_scored, groups:[{name, grouped_by, lens, sessions, hidden, hidden_lens, hidden_scope}]}. lens is ef|speed_hr|rpe (sport-aware), and each session carries a score in that lens. grouped_by says which key built the group. structure applies when the anchor-day session has detected work segments AND passes the same uniformity gate reps applies to anchors (1.6× rep-duration spread). Its mates are found by reps' shape predicate (sport family, exact rep count, mean rep duration band, signal), so the trend spans every session of that WORKOUT whatever the classes were named. It excludes different workouts sharing a name. name is the fallback for sessions without detected structure, for irregular anchors reps would refuse as irregular_anchor, and for a shaped session whose structure group has nothing the lens can score. Trust a structure trend over a name trend when both could answer the question. Class names are evidence of neither sameness nor difference. Bare = latest analyzed workout; desc lists newest first without changing the trend. anchor_scored: false means a workout anchored on that date could not be scored by its group's lens, so it is absent from that group's sessions[] and the trends exclude it. Do not read the trend as covering the session you asked about. In-band errors: no_workout_on_date, unscorable, no_scorable_workouts. hidden is how many sessions of that workout are NOT in sessions, withheld either by the group's distance SCOPE (auto-named groups match by distance) or by its LENS (EF needs power+HR, speed/HR needs distance+HR, RPE needs a rating). Read sessions as the whole history ONLY when hidden is 0. A group holding one session with hidden: 10 is a workout done eleven times, not once. hidden_lens and hidden_scope split it by cause and sum to it. The LENS half is USUALLY the fixable one: the session is in this workout's history and the lens cannot score it, so supplying what it needs (a power stream, a heart-rate strap, a rating) normally brings the row into the table. The exception is a session that already carries the field but records an impossible value: an average heart rate outside 35 to 220 bpm is refused by the EF and speed/HR lenses. No amount of wearing the strap changes that, because the strap was worn and the reading itself is broken. Before telling the athlete to go fix their kit, read avg_hr_scored on the progress sessions. That field is the number the lens divided by, and from #311 it is the in-band mean of the session's HR stream whenever one spans at least half the longer of its own extent and the session's moving time. A stored avg_hr outside 35 to 220 is therefore NOT sufficient to conclude the row was refused: if the stream is good the row scores anyway, and the two fields will disagree. The lens refused when avg_hr_scored itself is out of band, which happens when there was no usable stream to fall back from. For rows missing from sessions[] entirely, stride activities --json gives you ids to work from and now carries avg_hr_scored too (#319), so one call narrows it. An avg_hr_scored outside 35 to 220 means those lenses refused the row. NOT the converse: the lenses also need a normalized-power load_model (EF) or distance and moving time (speed/HR), so 45 sessions sit inside the bound and are refused anyway. An in-band reading is necessary, not sufficient. A LARGE gap between the two HR fields says Strava's summary came off a lossy stream; any gap does not, since 669 of 738 rows differ, almost all by a stored decimal and only 11 by more than 15 bpm. progress gives a count and no ids. If avg_hr is already present and implausible, the remedy is repairing or deleting that activity, not equipment. stride activity <id>'s baselines.ef refuses such a row (known: false with current: 0), so an absent EF there is corroboration that the reading is broken rather than a verdict about fitness (#305). Corroboration about avg_hr_scored, not about avg_hr: a session with an impossible stored reading and a healthy stream publishes a normal EF, and that is correct. Note it does NOT tell you which: hr_known and power_known both still read true on that payload, so an impossible heart rate and a genuinely missing signal look the same. Check avg_hr yourself. The SCOPE half is not fixable and is not about the same training: those sessions belong to a different distance bucket of the same auto-named workout. Branch on the split, not on the total. |
Conventions & gotchas
- Training weeks run Monday to Sunday by default. Plan and present weeks with Monday first; when computing day-of-week from dates, verify against a known anchor (2026-07-27 was a Monday).
- The window's game layer is display-only. No command surfaces it, so know
the rules when the athlete asks. Athlete LEVEL comes from career hours
across every sport (reaching level L costs 5·L·(L+1) cumulative hours, so
each level costs ten hours more than the last; tiers every ten levels:
rookie / grinder / engine / machine / legend). The hours are the same
career totals
statsreports, so you can derive the level and the hours to the next level yourself. The STREAK is consecutive Mon to Sun weeks with any load, every sport counting, and a loadless week still in progress does not break it. The plan view's quest pips are the week's done/total from the plan bundle. Gold celebrations fire for a ride power record set in the last seven days (power view) and for any family whose threshold peak IS the current month (career view). None of this is steerable over the bus, and none of it appears in any--jsonpayload. It lives in the window, documented in docs/viz.md. - Missing-value contract (ADR 0009): JSON null is not expressible (encoder
stringifies tags), so absence is flagged, not nulled. Impossible-zero fields
(
np_w,avg_hr,intensity,ftp_used): 0 = not available.activity,activities, andplan.recent_activities_14drows carrypower_known/intensity_known/hr_known/zones_known+load_model;toprows carry the first three (they are separate flags because np can exist while intensity does not: power stream, no FTP yet).tss: 0is AMBIGUOUS, so readload_model:""/"none"= unscored, anything else = a scored near-zero effort. Zone secondsz1_s..z5_smean their 0 literally ONLY whenzones_known: true; all-zero withzones_known: false= no HR stream (summaryavg_hrcan exist without one, andhr_knowndoes not cover zones).distance_m0 is always literal. Fields that are BOTH possible-zero and possibly-absent carry a_knownflag (decoupling_known,form_delta_known,hr_drift_known,rec_drop_known, andform_tsb_knowninanalyze). Trust the flag, never the magnitude. The rule is PER PAYLOAD, not per field name:summaryshipsform_tsbbare because it is always computable there. Read the schema for the command.progresssessions carry onlydecoupling_knownand no other flag on purpose: rows exist only because the group lens scored them. - Zone seconds are HR-based (universal across sports). Power feeds TSS/NP, and pace feeds the intensity split for any DISTANCE sport without power (a pool swim qualifies, since only a distance stream is needed, not GPS or altitude).
- Load ladder for ENDURANCE sports: stream-NP → Strava weighted watts → avg watts →
pace (rtss) → hrTSS (zone-weighted) → avg-HR → session-RPE → relative_effort.
Strength-class sports rank session-RPE ABOVE heart rate (
Sports.class, ADR 0003), because the athlete is the better sensor there. The ladder is a mixed model, not "TSS";load_modelon each row records which rung scored it. - FTP is DERIVED, never configured. Per sport, per activity era: best 20-min power
× 0.95 over the 60 days up to each activity (
summary.ftp={best_20min_w_60d, estimated_ftp_w}for rides). Theftp/ftp_<sport>config keys are REFUSED with error codederived_key. There is nothing to fix when FTP moves; INTERPRET the trajectory instead (a risingestimated_ftp_wis fitness, a falling one is detraining or a power-data gap; checkdoctorcoverage before concluding). - Metric recompute triggers (what invalidates metrics): derived-FTP change (metrics
store
ftp_used), derived threshold-pace change (threshold_pace_used, the pace analog, keyed per sport), HR-zone change, stream arrival, rating change, and activity-input edits (analyze compares each row's stored inputs; sync itself never deletes metrics). So after a sync, alwaysanalyzeto pick up recomputes. - CTL/ATL/TSB are as of today (daily_load extends through today with 0-TSS rest
days), so
form_tsbis current, with no mental decay adjustments needed. "Today" is the LOCAL day, anchored by configtimezone(IANA, e.g.America/Chicago, DST-correct automatically; preferred) or a fixedutc_offset_minutesfallback (e.g. -300); precedence is timezone > offset > UTC. Without either, users west of UTC get a phantom "tomorrow" row each evening.doctorshows which anchor is active. - Session-RPE: after a strength/HIIT/yoga session, ask the user how hard it felt
(1-10) and run
stride rate <activity_id|latest> <n> --json. Load = hours × RPE × 10 (on the same scale as TSS). For strength-class sports the rating outranks HR in the load ladder; for endurance, measured power/HR outrank it. Rating an activity invalidates its metrics (re-analyzerescores). Ratings live in their own table and survive re-syncs. - Junk HR (outside 35 to 220 bpm) is filtered at analyze time for stream SAMPLES, so sessions with bad straps
(common on Peloton strength workouts) get near-0 TSS. That result is honest
"no data", not zero effort. Weigh strength by session count, not TSS.
avg_hrinactivitiesoutput is raw (unfiltered). - The same 35 to 220 bound is applied again at REPORT time by four consumers, each with a different consequence. TWO of them apply it to
avg_hr_scored: the mean of the session's in-band HR stream when the surviving samples span at least half the LONGER of the stream's own extent and the session's moving time, and the stored average otherwise (#311). Those two areprogress's lenses andactivity'sbaselines.ef. The other two read the STOREDavg_hr:top hrbounds that column by design (#315), and the load ladder'shr_avgrung reads it. That rung is reachable only when the stream yields no zone seconds, which FORCESavg_hr_streamto be NULL as well. The implication runs one way, not both: a stream can yield zone seconds and still be too short to produce a mean. Either way the ladder can never divide by a number that differs fromavg_hr_scored. So a session whose stored reading is impossible can still be scored, from its stream, and one whose stream is a dead strap's first few minutes is refused even though its stored reading looks fine.progress,activityandactivitiesall publishavg_hr_scoredbesideavg_hr(#319), so the divisor behind any EF verdict is readable rather than inferred.progresscounts the refused session inhidden_lensand omits it fromsessions[], while the HUMAN table still shows the row with its lens cells blank and the columns that never needed a heart rate filled (#286). The agent readshidden_lensand the athlete sees the ride.activitypublishesef.known: falsewithef.current: 0. The load ladder falls to its next rung and names it inload_model, so this one DOES change TSS. It is the consumer that propagates, into CTL, ATL and every form verdict.top hrdoes not rank the reading at all: it is absent from that list. Absence there is NO LONGER corroboration that a reading is broken, becausetop hrbounds the stored value while the lenses score the corrected one. A session the engine RESCUED (EF published, load scored from its stream) is missing fromtop hrtoo. Checkavg_hr_scoredrather than reading absence there. A refused reading's id is not recoverable from the JSON, and if every reading is out of band the list comes back EMPTY.{"data":[]}then reads as "never recorded a heart rate" for an athlete who has 672 of them. To find the broken sessions themselves usestride activities --jsonand look for anavg_hr_scoredunder 35 or over 220 ANDhr_known: true. That field is the one the lenses divide by, and testingavg_hrinstead stopped identifying refused rows the moment the engine began scoring from streams (a session can store 18 bpm and score fine). Thehr_knownhalf is what separates a broken reading from a session that simply never wore a strap: on the reference database 69 rows are out of band and only 3 of them recorded anything. Without it you report 23 times too many and tell the athlete to repair activities that need equipment instead. A LARGE gap between the two fields is a further signal (Strava computing its summary from a lossy stream), but any gap is not: 669 of 738 rows differ, almost all by a rounded decimal, while only 11 differ by more than 15 bpm.planechoes the rawavg_hronly, andactivitypublishes it besidehr_known: true. created_atin planned sessions is an ISO datetime string (UTC, e.g.2026-07-27T18:04:22Z).- Streams are drained by
syncuntil the read budget stops it; older activities gain zone data over repeated syncs.sqlite3 ~/.stride/db.sqlite "SELECT COUNT(*) FROM streams"shows progress.
Setup & credentials
Day-to-day: none. stride auth stores the Strava client id/secret and tokens in the
db, and sync auto-refreshes. STRAVA_CLIENT_ID/STRAVA_CLIENT_SECRET env vars act as
overrides if set. A locked/corrupt db surfaces as a real error, not a false
"not authenticated".
First-time on a new machine: create a Strava API app (strava.com/settings/api), then
stride init --json → STRAVA_CLIENT_ID=... STRAVA_CLIENT_SECRET=... stride auth
(browser paste flow, interactive, the one command you run WITHOUT --json) →
stride config set hr_z1_max..hr_z4_max --json (+ timezone, IANA) →
stride sync --json → stride analyze --json. Each step's failure is an envelope
you can branch on (not_authenticated, missing_config), which is the point of
flagging a setup chain. The db self-migrates on any command, so upgrading the binary against
an existing db is safe.
Development
just test is the single entry point (pure expects → fresh build → just e2e, the
sandbox-HOME suite embedded in the justfile, and the same pipeline CI runs). The ordering
matters: a failed build leaves a stale binary that the e2e suite would happily "pass"
against, which is why just test builds in between.
Toolchain: Roc's new (Zig) compiler (one nightly, pinned as the default of
.github/actions/setup-roc; no per-job overrides, and tools/pin-check.sh enforces
it) + basic-cli 0.23 (the line named in src/main.roc's platform URL; e2e
holds this sentence to that header) + builtin JSON (roc-json dropped). The
full just test (expects, build, and e2e) runs green; the roc#10469 perf gate is
fixed. Build flags take = (--output=, --main=) and always --opt=dev, since the
optimized backend was miscompiling (issue #32, fixed on the 2026-08-17 pin; dev is now
kept for build time). A Roc gotcha that keeps recurring: floats have
no Eq, so never x == 0.0 in an expect; use (x).abs() < 0.001 (Num.abs does not exist). Compiler syntax/stdlib
reference: docs/roc-new-compiler-notes.md.