Building a workout
start() takes one value — a
StartWorkoutInput. Everything a
Rogue console can run is expressible in that single input, so a workout
builder is just a UI that assembles the input and calls start(). This guide
shows the exact input for each of the six workout types, the way a builder screen
produces them.
The companion Executing workouts guide covers what
the SDK does with the input (the mode discriminator, the three rest behaviors,
rounds expansion, and target tiers); this guide is the authoring side — how a
consumer turns builder state into each shape. The example app ships exactly this
as a reusable, pure model (src/screens/builder/builderState.ts and the
controlled WorkPicker / RestPicker / TargetsEditor / IntervalEditor /
RoundsPicker / ModeToggle components).
The one seam: state → input → start()
Section titled “The one seam: state → input → start()”Keep the builder’s UI state pure and project it to a StartWorkoutInput in one
place. devices is either a single DeviceId
(the N=1 convenience) or a modality-keyed
RoleAssignment you build from the
connected fleet with roleMap — it takes the
useDevices() Device objects, not ids.
import { roleMap, useDevices, useWorkoutControls } from "@rogue/console-sdk";import type { Device, StartWorkoutInput } from "@rogue/console-sdk";
function useStart() { const machines = useDevices({ connected: true }); const { start } = useWorkoutControls();
return async function run(input: StartWorkoutInput) { const ok = await start(input); // false on failure; never rejects return ok; };}
// One console → the bare-id form. Two or more → roleMap(machines).function devicesField(selected: readonly Device[]) { return selected.length === 1 ? selected[0]!.id : roleMap(selected);}Pre-flight: gate Start without arming
Section titled “Pre-flight: gate Start without arming”start() never rejects — it routes failure into the snapshot. But a builder
usually wants to disable Start and show why before the user taps it. Two
value-returning helpers give you that without a try/catch or copying the SDK’s
rules:
tryRoleMap(devices)— the non-throwing companion toroleMap:{ ok: false, conflict }when two consoles share a modality, so you can render “which rower?” instead of catching.validateStartInput(input, modalityOf)— a dry-run of the exact resolutionstart()uses. It returns{ ok: true, plan } | { ok: false, error }so you can validate mode/role coverage and the leg/round ceiling (MAX_EXPANDED_LEGS) against the SDK itself — no drift from copied magic numbers.
import { validateStartInput } from "@rogue/console-sdk";
const check = validateStartInput(input, (id) => deviceById(id)?.modality ?? null);const canStart = check.ok; // else show check.error.message1. Single-target (free)
Section titled “1. Single-target (free)”A free workout with an optional whole-workout
WorkoutTarget — omit target for an
open “just row/ride/ski” — plus an optional
splitEvery boundary.
// distance, with a split every 500 mawait start({ kind: "free", devices: rowerId, target: { kind: "distance", meters: 2000 }, splitEvery: { kind: "distance", meters: 500 },});
// time (drift-free app clock)await start({ kind: "free", devices: rowerId, target: { kind: "time", seconds: 1200 },});
// caloriesawait start({ kind: "free", devices: rowerId, target: { kind: "calories", calories: 100 },});
// open — omit the target entirelyawait start({ kind: "free", devices: rowerId });2. Constant intervals
Section titled “2. Constant intervals”One IntervalSpec repeated rounds
times. A single-entry program is the compact constantInterval form — pass the
spec once with a rounds count, not N copies.
await start({ kind: "intervals", devices: rowerId, intervals: [ { work: { kind: "time", seconds: 60 }, rest: { kind: "timed", seconds: 30 }, }, ], rounds: 8, // 1–254, or "unlimited" (AMRAP) — single-entry only mode: "broadcast",});3. Variable / pyramid intervals
Section titled “3. Variable / pyramid intervals”A multi-entry list, each entry independently editable, run for rounds rounds.
The SDK materializes the rounds (tiling the legs) so every round runs — you
author the list and the count, nothing else.
await start({ kind: "intervals", devices: rowerId, rounds: 2, mode: "broadcast", intervals: [ { work: { kind: "distance", meters: 250 }, rest: { kind: "timed", seconds: 30 }, }, { work: { kind: "distance", meters: 500 }, rest: { kind: "timed", seconds: 45 }, }, { work: { kind: "distance", meters: 250 }, rest: { kind: "untilUserContinues" }, }, ],});Each work interval can carry a
PerformanceTargets bundle — a
pace target (the only one the firmware enforces) plus app-scored watts /
calories-per-hour / stroke-rate / HR-zone. A builder mirrors the reference
one-pacer convention (one of pace / watts / cal, with stroke-rate and HR-zone as
independent siblings):
{ work: { kind: "time", seconds: 60 }, rest: { kind: "timed", seconds: 30 }, targets: { pace: { per500mSeconds: 110 }, strokeRate: 28, hrZone: 3 },}4a. Multi-machine broadcast
Section titled “4a. Multi-machine broadcast”Role-less intervals across the connected consoles, mode: "broadcast" — every
machine runs the same program at once (two-athletes-racing). Build devices
from the fleet with roleMap.
await start({ kind: "intervals", mode: "broadcast", devices: roleMap(machines), // { rower: rowerId, bike: bikeId } intervals: [ { work: { kind: "calories", calories: 20 }, rest: { kind: "timed", seconds: 30 }, }, ], rounds: 3,});In broadcast mode intervals must be role-less — a builder strips any per-row role before it emits the input.
4b. Multi-machine turn-based (relay)
Section titled “4b. Multi-machine turn-based (relay)”Role-scoped intervals with mode: "sequential" — one athlete moves
machine-to-machine, only the active console runs. Every interval carries a
role, and every role must be filled by a connected console.
await start({ kind: "intervals", mode: "sequential", devices: roleMap(machines), // { bike, rower, ski } intervals: [ { role: "bike", work: { kind: "distance", meters: 1000 }, rest: { kind: "untilUserContinues" }, }, { role: "rower", work: { kind: "distance", meters: 500 }, rest: { kind: "untilUserContinues" }, }, { role: "ski", work: { kind: "distance", meters: 500 }, rest: { kind: "untilUserContinues" }, }, ],});5. Rests everywhere
Section titled “5. Rests everywhere”Rest is a property of a work interval, not a separate entry, and every interval
gets its own Rest — so “rests everywhere” is
just authoring a rest kind on each row. A builder exposes the three kinds as a
per-interval Timed / Hold / None selector:
await start({ kind: "intervals", devices: rowerId, rounds: 1, mode: "broadcast", intervals: [ // timed: a fixed countdown, then auto-advance { work: { kind: "distance", meters: 500 }, rest: { kind: "timed", seconds: 45 }, }, // hold: waits for the athlete (never auto-advances) { work: { kind: "distance", meters: 500 }, rest: { kind: "untilUserContinues" }, }, // none: immediate, back-to-back with no wait { work: { kind: "distance", meters: 500 }, rest: { kind: "none" } }, ],});An omitted rest defaults to untilUserContinues (hold). The three behaviors
and how each executor honors them are in
Executing workouts → Rests.
6. Mikko’s Triangle (a preset)
Section titled “6. Mikko’s Triangle (a preset)”Mikko’s Triangle is a named 3-machine relay: each round is three 60-second time
legs in order bike → rower → ski with no rest between machines (none), and
a 60-second timed rest between rounds attached to the ski leg. It is a
sequential plan with rounds = 3 (Mini) or 10 (the ~39-minute full variant); the
SDK trims the final round’s trailing rest so the workout ends on work. A builder
ships it as a one-tap preset:
const WORK = { kind: "time", seconds: 60 } as const;await start({ kind: "intervals", mode: "sequential", devices: roleMap(machines), // { bike, rower, ski } rounds: variant === "mini" ? 3 : 10, intervals: [ { role: "bike", work: WORK, rest: { kind: "none" } }, // immediate → rower { role: "rower", work: WORK, rest: { kind: "none" } }, // immediate → ski { role: "ski", work: WORK, rest: { kind: "timed", seconds: 60 } }, // between rounds ],});Validate before you start()
Section titled “Validate before you start()”start() never rejects — a bad plan lands in startError as a typed
session/invalid-plan. A good builder catches the same conditions up front and
shows a friendly message instead:
- Mode ↔ role coherence — broadcast plans are role-less; sequential plans give every interval a role; every role is filled by a connected console.
- One device per modality —
roleMapthrowssession/invalid-planif two consoles share a modality; surface that as a disabled state. - Leg ceilings — a single-entry program allows 1–254 rounds (or
unlimited); a multi-entry program’s expanded leg count (entries × rounds) must stay ≤ 255, andunlimitedis not allowed for it.
On success, navigate into your session screen and render
useWorkoutSession() — the same
screen serves every type, at any machine count.
Composing the screen
Section titled “Composing the screen”The example ships the whole thing as one screen —
src/screens/builder/WorkoutBuilderScreen.tsx — that is a thin composition
shell: a useReducer(builderReducer), a device picker over
useDevices({ connected: true }), the controlled pickers, a
presets row (4 × 500 m, a pyramid, and Mikko’s Triangle), and a Start button.
The one projection is called once for the live preview and again as the Start
guard:
const [state, dispatch] = useReducer(builderReducer, undefined, initialBuilderState);const machines = useDevices({ connected: true });const { start, isStarting, startError } = useWorkoutControls();
// The single seam: builder state → a valid input, or a reason to show first.const build = toStartWorkoutInput(state, machines);
async function onStart() { if (!build.ok) return; // the button is disabled; render build.reason inline const ok = await start(build.input); if (ok) navigation.navigate("ActiveSession"); // reuse the shared session screen}Every control is (value, onChange) over the reducer, so the screen never holds
workout state itself — it renders the reducer and dispatches actions. start()’s
startError is surfaced inline alongside the pre-start() validation reason.