Executing workouts
The session engine is the heart of the SDK: one provider-owned state machine
covering free workouts, structured intervals, and multi-machine circuits. There
is exactly one session at a time — a single athlete works out on one or more
machines — and it is fleet-shaped from the first line: everything below maps over
session.devices, so the same code runs a lone rower and a rower + bike pair.
Two hooks are the whole surface:
useWorkoutSession()— the reactive snapshot youswitchover.useWorkoutControls()— the always-available lifecycle verbs (start,pause,resume,end,abandon,dismiss).
Programming a workout: StartWorkoutInput
Section titled “Programming a workout: StartWorkoutInput”start() takes a StartWorkoutInput, and there are exactly two kinds — free
and intervals. This is the complete programming vocabulary: higher-level
workout models (the Rogue app’s WPT prescriptions, a coach’s template) live in
your app and resolve to this input before they call start(). The SDK never
learns their vocabulary.
This guide covers what the engine does with the input. For the authoring
side — the exact StartWorkoutInput for each of the six workout types
(single-target, constant and variable intervals, multi-machine broadcast and
turn-based relays, and the Mikko’s Triangle preset), assembled from builder UI
state — see the Building a workout guide. The
example app ships that as one screen (WorkoutBuilderScreen) over a pure
builderState.ts model, and it is the worked example this guide’s snippets are
drawn from.
import { useWorkoutControls } from "@rogue/console-sdk";import type { DeviceId } from "@rogue/console-sdk";
function StartButtons({ rower }: { rower: DeviceId }) { const { start } = useWorkoutControls();
// Free workout with a whole-workout target. Omit `target` for just row/ride/ski. const rowForCalories = () => start({ kind: "free", devices: rower, target: { kind: "calories", calories: 50 }, });
// Structured intervals: 4 × 500 m, rest until the athlete continues. const fourByFiveHundred = () => start({ kind: "intervals", devices: rower, intervals: [ { work: { kind: "distance", meters: 500 }, rest: { kind: "untilUserContinues" }, }, ], rounds: 4, });
return ( <> <Button title="Row 50 cal" onPress={rowForCalories} /> <Button title="4 × 500 m" onPress={fourByFiveHundred} /> </> );}devices is either a single DeviceId (the N=1 convenience) or a modality-keyed
RoleAssignment. A whole fleet becomes a RoleAssignment through the pure
roleMap() helper — that is the multi-console form, covered in the
multi-console guide. rounds: 'unlimited' hides the
firmware’s ≥255 encoding; splitEvery (free workouts) places a split boundary
every N meters, seconds, or calories.
Broadcast vs sequential: mode
Section titled “Broadcast vs sequential: mode”An intervals plan carries an explicit mode discriminator that picks its
choreography — the two are first-class and never mix in one plan:
mode: 'broadcast'(the default) — every device in the session runs each interval simultaneously. Intervals are role-less. This is valid at any N: the same role-less input that drives one rower drives a rower + bike pair with zero changes (the binding constraint — see the multi-console guide).progress.activeDeviceIdisnull.mode: 'sequential'— one athlete moves machine-to-machine; only the active role’s console runs while the others hold (the turn-based relay). Every interval is role-scoped, andprogress.activeDeviceIdnames whose turn it is. The hand-off to the next machine happens on the completed leg’srest— see Rests below.
mode is chosen explicitly, never inferred from role. The normalizer defaults
it to 'broadcast' only when it is omitted and every interval is role-less,
so existing broadcast callers are unaffected; a role-scoped plan with mode
omitted rejects up front with 'session/invalid-plan'. The other coherence
violations reject the same way: a broadcast plan that scopes any interval to a
role, or a sequential plan with an interval missing its role.
// Broadcast (default): role-less, runs everywhere at once.start({ kind: "intervals", devices: roleMap(machines), intervals: [{ work: { kind: "calories", calories: 20 } }], rounds: 3,});
// Sequential relay: one athlete, bike → rower → ski, one leg live at a time.start({ kind: "intervals", mode: "sequential", devices: { bike: bikeId, rower: rowerId, ski: skiId }, intervals: [ { role: "bike", work: { kind: "distance", meters: 1000 } }, { role: "rower", work: { kind: "distance", meters: 500 } }, { role: "ski", work: { kind: "distance", meters: 500 } }, ],});The multi-console guide covers both modes at the fleet level, including how the sequential hand-off is signalled.
The session lifecycle: switch (session.phase)
Section titled “The session lifecycle: switch (session.phase)”useWorkoutSession() returns a discriminated union, and the intended
consumption pattern is an exhaustive switch over session.phase — the
compiler enforces totality, so a new phase can never be silently unhandled. Two
compile-time gates are load-bearing: continueFromRest exists only on the
continuable-rest variant, and result exists only on completed.
import { useWorkoutSession, useWorkoutControls } from "@rogue/console-sdk";
function WorkoutScreen() { const session = useWorkoutSession(); const { pause, resume, end, dismiss } = useWorkoutControls();
switch (session.phase) { case "idle": return <Redirect to="Setup" />; case "arming": return <ArmingSpinner devices={session.devices} step={session.step} />; case "running": case "resting": case "paused": return ( <> <IntervalHeader progress={session.progress} /> {session.devices.map((d) => ( <MachineTile key={d.deviceId} deviceId={d.deviceId} role={d.role} link={d.link} /> ))} {session.recording.issues.length > 0 && ( <CaptureWarningChip issues={session.recording.issues} /> )} {session.phase === "resting" && session.rest.kind === "untilUserContinues" && ( <Button title="Continue" onPress={session.rest.continueFromRest} /> // legal ONLY here )} {session.phase === "paused" ? ( <Button title="Resume" onPress={resume} /> ) : ( <Button title="Pause" onPress={pause} /> )} {session.progress.canEndEarly && ( <Button title="Finish" onPress={() => end()} /> )} </> ); case "finalizing": return <Spinner label="Saving splits…" />; case "completed": return <SummaryCard result={session.result} onDone={dismiss} />; // result ONLY here case "aborted": return ( <EndedEarly cause={session.cause} partial={session.partialResult} onDone={dismiss} /> ); case "failed": return ( <SessionFailed error={session.error} partial={session.partialResult} onDone={dismiss} /> ); }}Every non-idle phase carries the same SessionCommon fields — id, plan,
devices, timing, and recording — so the header, the tiles, and the capture
chip render the same way across running, resting, and paused. The
running/paused/resting phases add a progress object with the
interval/round position, the current work target, the active performance
currentTargets bundle, and canEndEarly.
Type-gated actions: continue-from-rest and result
Section titled “Type-gated actions: continue-from-rest and result”Two things exist on exactly one variant, and the compiler enforces it:
rest.continueFromRest()is present only on the{ kind: 'untilUserContinues' }rest — you cannot wire a Continue button on a timed countdown or outside a rest phase, because the method is not in the type there. It is identity-stable for its rest window and phase-checked internally, so a stale captured reference is a dev-warned no-op, never an action on the wrong interval.resultis present only on thecompletedphase. There is nosession.resultto read-as-undefinedmid-workout — narrowing tophase === 'completed'is what makes it exist. Reading it is covered in reading results.
There is no “which continue API do I use?” ambiguity and no nullable result
field to guard: the union shape is the guard.
Rests: three honest behaviors
Section titled “Rests: three honest behaviors”Every interval’s rest is one of three kinds, and each is honored faithfully by
both execution strategies (the app-clock scheduler for all-time-based plans, the
console-driven strategy for distance/calorie plans):
type Rest = | { kind: "timed"; seconds: number } // fixed countdown, then auto-advance | { kind: "untilUserContinues" } // HOLD — waits for the athlete | { kind: "none" }; // IMMEDIATE — back-to-back, no waittimedcounts downsecondsand advances on its own. Arestingphase withrest.kind: 'timed'carriesendsAt/remainingSecondsfor a countdown UI.{ seconds: 0 }is equivalent tonone.untilUserContinuesis a genuine HOLD: the workout waits and never auto-advances. This is what an omittedrestdefaults to. On the app clock the drift-free schedule truly stops at the hold — no timer is armed until the athlete continues, and the plan-end instant shifts forward by exactly the held span, so a countdown UI stays correct across the hold. You advance it withrest.continueFromRest()(or, in a sequential relay, by moving to the next machine).noneis an immediate, back-to-back hand-off: the next interval starts with no wait and no Continue tap. It never produces arestingphase — it is a straight advance. Use it for a seamless intra-round transition (e.g. a bike → rower → ski relay’s within-round hand-offs).
The final leg’s trailing rest is trimmed to none automatically, so a
workout always ends on work, never on a dangling rest — even when you use the
compact rounds: N form instead of pre-expanding the intervals.
Rounds are never lost
Section titled “Rounds are never lost”rounds repeats the interval block, and every round always runs — a
multi-interval program is expanded once, up front, into a flat leg sequence, so
neither execution strategy can silently drop a round. A single repeated interval
stays compact (the firmware repeats it natively); a pyramid or mixed circuit is
tiled ×rounds into the plan you can read back on session.plan.intervals.
Two firmware ceilings bound the expansion, each surfaced as a clean
session/invalid-plan start error (never an unhandled codec error):
- a multi-interval (variable) program expands to at most 255 legs total;
- a single repeated interval accepts 1–254 rounds, or
'unlimited'(AMRAP).'unlimited'is only valid on a single repeated interval — an unlimited multi-interval circuit rejects, since a variable list cannot be materialized infinitely.
Performance targets
Section titled “Performance targets”An interval (or a free workout) can carry a targets bundle. Only pace is
programmed to the console and enforced by the firmware; the rest — watts,
caloriesPerHour, strokeRate, hrZone — are app-scored pass-through
values the SDK surfaces but never writes to the machine and never scores itself:
start({ kind: "intervals", devices: rower, intervals: [ { work: { kind: "time", seconds: 120 }, rest: { kind: "timed", seconds: 60 }, targets: { pace: { per500mSeconds: 110 }, // Tier 1 — programmed to the console watts: 250, // Tier 2 — app-scored, displayed only hrZone: 4, // app-scored, displayed only }, }, ], rounds: 5,});The active bundle is surfaced on progress.currentTargets during the
running, paused, and resting phases alike, so a target tile keeps
rendering through a rest. These are inert values: the SDK ships no scoring or
color helper — your app owns all scoring and color logic (the reference apps
score pace lower-is-better, watts and stroke rate higher-is-better, and HR zone
in-band).
The flat shape does not enforce the reference apps’ one-pacer convention (pace, watts, and cal/hr are mutually exclusive there): setting more than one pacer is a representable choice the SDK deliberately does not forbid, because only pace reaches the wire. Mirror the exclusivity in your own UI if you want it.
Degraded devices are data, not events
Section titled “Degraded devices are data, not events”A device dropping mid-workout is degraded, not dead — the session keeps
running. There is no onDisconnect to subscribe to. Each SessionDeviceState
carries a link that is the session’s degraded view of the fleet connection:
link: 'ready'— streaming normally.link: 'reconnecting'— dropped but still part of the workout; the engine is re-establishing the link and the interval choreography holds.link: 'lost'— the link is exhausted.
Render it straight from the snapshot with a selector — a degraded banner is a filter over the device list, not an event handler:
import { useWorkoutSession } from "@rogue/console-sdk";
function DegradedBanner() { const lostDevices = useWorkoutSession((s) => s.phase === "running" || s.phase === "resting" || s.phase === "paused" ? s.devices.filter((d) => d.link !== "ready") : [], ); if (lostDevices.length === 0) return null; return <Banner text={`${lostDevices.length} machine(s) reconnecting…`} />;}A machine tile does the same locally: pass link in and grey the tile when it is
not 'ready', while the live-metrics selector keeps showing the last value.
Capture is a property of being in a session
Section titled “Capture is a property of being in a session”The 0.5 s sampler runs automatically inside every session. There is no flush
API to misuse and no chunk store — the series lives and dies with the session,
and it is delivered whole on WorkoutResult.metrics. Capture health renders as
continuously readable data on session.recording:
recording.sampleCount— how many 0.5 s samples have been captured.recording.issues— a typed list of visible partial-capture events. After the re-scope there are only two: aframes-gap(a device drop null-fills the series over[fromT, toT]) and amemory-pressuredownsample. A gap is visible, never a silent hole.
import { useWorkoutSession } from "@rogue/console-sdk";import type { RecordingIssue } from "@rogue/console-sdk";
function CaptureWarningChip({ issues }: { issues: readonly RecordingIssue[] }) { return ( <Chip tone="warning"> {issues .map((issue) => issue.kind === "frames-gap" ? `Gap ${issue.fromT}–${issue.toT}s` : `Downsampled at ${issue.at}s`, ) .join(" · ")} </Chip> );}Ending, aborting, abandoning
Section titled “Ending, aborting, abandoning”The lifecycle verbs are a flat, always-available hook so buttons wire without
narrowing on the phase — onPress={pause}, onPress={() => end()}. Per the
paired-verb contract, the bare verbs never
reject: a wrong-phase pause/resume is a dev-warned no-op, and a real
start/end failure lands in the snapshot (startError, phase 'failed'), so
a fire-and-forget handler can never float an unhandled rejection. Each has an
*Async twin (startAsync, endAsync) that rejects with a typed
SessionError for imperative composition.
There are three ways a session ends, and every one is a phase you render — never an event you might miss:
| How it ends | Phase | What you get |
|---|---|---|
end() — graceful finish |
completed |
result (fully reconciled) |
| Athlete presses HOME on a console | aborted |
cause: 'console-stopped', partialResult |
| Bluetooth turns off | aborted |
cause: 'bluetooth-off', partialResult |
abandon() |
aborted |
cause: 'user'; partialResult only with keep: 'partial' |
| Engine fault | failed |
typed SessionError, partialResult |
end() runs a parallel stop-all, a stop-verify, a final split commit, and record
reconciliation, then resolves the WorkoutResult and moves the snapshot to
completed. abandon() walks away without a normal finish: the default
keep: 'discard' drops the capture, while keep: 'partial' applies partial
semantics and populates partialResult with whatever reconciled.
import { useWorkoutControls } from "@rogue/console-sdk";
function EndControls() { const { end, abandon } = useWorkoutControls(); return ( <> <Button title="Finish" onPress={() => end()} /> <Button title="Quit — keep partial" onPress={() => abandon({ keep: "partial" })} /> <Button title="Quit — discard" onPress={() => abandon()} /> </> );}dismiss() returns a terminal session (completed/aborted/failed) to idle
and releases the session-scoped streams. It is optional when your next action is
another start() — which implicitly dismisses a terminal session first — and
required only to release those streams while staying on a terminal screen.
From one machine to many
Section titled “From one machine to many”Nothing above singled out “the console.” session.devices.map(...) renders one
tile at N=1 and two at N=2; the role-less intervals broadcast to whatever is in
the roster; the result attributes per device through the same arrays. Scaling
from one machine to a fleet is covered next.
Reference
Section titled “Reference”useWorkoutSession·WorkoutSessionSnapshot·SessionCommon·SessionDeviceState·SessionProgressuseWorkoutControls·WorkoutControlsStartWorkoutInput·IntervalSpec·WorkoutTarget·Rest·Quantity·PerformanceTargets·PaceTargetRecordingState·RecordingIssue- Building a workout · Multi-console · Reading results · Error handling