Skip to content

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 you switch over.
  • useWorkoutControls() — the always-available lifecycle verbs (start, pause, resume, end, abandon, dismiss).

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.

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.activeDeviceId is null.
  • 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, and progress.activeDeviceId names whose turn it is. The hand-off to the next machine happens on the completed leg’s rest — 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.
  • result is present only on the completed phase. There is no session.result to read-as-undefined mid-workout — narrowing to phase === '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.

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 wait
  • timed counts down seconds and advances on its own. A resting phase with rest.kind: 'timed' carries endsAt/remainingSeconds for a countdown UI. { seconds: 0 } is equivalent to none.
  • untilUserContinues is a genuine HOLD: the workout waits and never auto-advances. This is what an omitted rest defaults 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 with rest.continueFromRest() (or, in a sequential relay, by moving to the next machine).
  • none is an immediate, back-to-back hand-off: the next interval starts with no wait and no Continue tap. It never produces a resting phase — 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 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.

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.

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: a frames-gap (a device drop null-fills the series over [fromT, toT]) and a memory-pressure downsample. 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>
);
}

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.

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.