Workout Schema
Last updated: 2026-05-29
Your Trainer is a multi-rider indoor cycling app for Android tablets. Smart-trainer control with local data + local control. One-time purchase.
The .ytw workout file is plain JSON — author it in any text editor, import via the share sheet, share with friends. The visual editor handles common cases; the schema is the escape hatch when you need full control.
When to hand-author
Most riders never need this page — the visual workout editor and the AI Workout Coach cover everything from a 4×8 threshold session to a microburst stack. Reach for the schema when:
- You want a structure the editor doesn't expose (very long warm-ups, elaborate cue scripting, mixed cadence targets per interval).
- You're translating a workout you already have in another tool's format.
- You want to ship a workout to someone else as a single file.
- You want to bulk-generate workouts programmatically.
Minimal example
The shortest valid .ytw file is one program with one interval. Save with a .ytw extension and share into Your Trainer.
{
"programId": "my-sweet-spot",
"programName": "My Sweet Spot 30",
"description": "A short sweet-spot workout.",
"totalDuration": 1800,
"workoutType": "POWER",
"primaryLocale": "en",
"intervals": [
{ "id": "warmup", "duration": 300, "targetPowerPercent": 50, "intensityZone": "Z1", "label": "Warmup", "intervalType": "WARMUP" },
{ "id": "work", "duration": 1200, "targetPowerPercent": 88, "intensityZone": "Z3", "label": "Sweet Spot", "intervalType": "INTERVAL" },
{ "id": "cooldown", "duration": 300, "targetPowerPercent": 50, "intensityZone": "Z1", "label": "Cooldown", "intervalType": "COOLDOWN" }
]
}
Top-level fields
The top-level object describes one workout program. Required fields are flagged.
| Field | Type | Description |
|---|---|---|
programId required | string | Stable identifier. Use kebab-case (my-sweet-spot). The unique key for the workout in your library — re-importing a file with the same programId updates the existing entry rather than creating a duplicate. |
programName required | string | Display name in the rider's primary locale. |
description required | string | One- or two-sentence description shown on the workout card. |
totalDuration required | integer (seconds) | Total workout length. The app recomputes this from the intervals on save, so it's safe to leave inconsistent during authoring. |
intervals required | array | Ordered list of interval objects or repeat groups. |
workoutType | string | Workout family: POWER (default), HR_ZONE, or ROUTE. See Workout types. |
variant | string | Sub-shape inside the family: STANDARD (default) or RAMP_TEST. |
primaryLocale | string (BCP-47) | Locale the strings were authored in. Default "en". Drives the cross-locale fallback chain. |
category | string | Free-text categorisation (e.g. "threshold", "endurance"). Optional. |
difficulty | integer (1–5) | Subjective difficulty. Surfaces on the workout card. |
isUserCreated | boolean | True for workouts authored in-app by the rider; false for imported / bundled workouts. Default false. |
isFavorite | boolean | Pinned-to-top flag. Riders flip this in-app; usually omitted in shared files. |
routeProfile | array of { distanceMeters, elevationMeters } | Full elevation profile for ROUTE workouts. Null for POWER and HR_ZONE. |
strings | object (locale → LocaleStrings) | Per-locale translations of name, description, interval labels, and cues. |
Interval fields
Each interval object describes one block of the workout. The block's shape depends on the parent's workoutType — Power blocks use power percentages, HR-Zone blocks use a zone target.
| Field | Type | Description |
|---|---|---|
duration required | integer (seconds) | Block length in seconds. |
targetPowerPercent power-required | integer (% of FTP) | Power target for POWER workouts. Mutually exclusive with targetHrZone. |
targetPowerEndPercent | integer (% of FTP) | Optional ramp end-power. When present, the target wattage interpolates linearly from targetPowerPercent → targetPowerEndPercent across the block. |
targetHrZone hr-required | integer (1–5) | HR zone for HR_ZONE workouts. Mutually exclusive with targetPowerPercent. |
intensityZone required | string | Visual zone token: Z1–Z5. Drives the colour on the terrain visualization. See Training zones. |
intervalType | string | WARMUP, COOLDOWN, or INTERVAL (default). Warm-up and cooldown blocks are excluded from work-only summaries (average-power-of-work-blocks, time-in-zone for the work portion, etc.). |
label required | string | Display text on the block in the rider's primary locale. Cross-locale variants live in strings.<locale>.labels. |
id | string | Stable slug — the key used in strings.<locale>.labels and in cue-key composition. Recommended for any workout that ships with translations. |
autoLabel | boolean | True when the label was generated by an editor preset rather than typed by the rider. Auto-labels are localised by Your Trainer itself and don't need per-locale entries in strings. Default false. |
cadenceTarget | integer (RPM) | Optional cadence target for the block (e.g. 60 for low-cadence climbing, 100 for spin-up drills). |
cues | array of CoachingCue | Coaching cues that fire during the block. |
Coaching cues
A coaching cue is a short text overlay that appears on the cockpit during a ride. Each cue has an offset within its parent interval, the text to show, and how long to leave it on screen.
| Field | Type | Description |
|---|---|---|
offsetSec required | integer (seconds) | Seconds from the start of the parent interval at which the cue fires. |
text required | string | Cue text in the workout's primary locale. Cross-locale variants live in strings.<locale>.cues, keyed by <intervalId>:<cueIndex>. |
durationSec | integer (seconds) | How long the cue stays on screen. Default 5. |
Example interval with three cues (key composition uses the parent interval's id + the cue's index in the array):
{
"id": "work",
"duration": 600,
"targetPowerPercent": 95,
"intensityZone": "Z4",
"label": "Threshold",
"intervalType": "INTERVAL",
"cues": [
{ "offsetSec": 0, "text": "Settle in — find your rhythm." },
{ "offsetSec": 300, "text": "Halfway. Stay smooth.", "durationSec": 8 },
{ "offsetSec": 540, "text": "One minute. Hold form." }
]
}
Localized strings & fallback chain
The strings block carries per-locale translations of every rider-visible string in the workout. Each locale entry has the same shape:
"strings": {
"en": {
"name": "Sweet Spot 30",
"description": "A short sweet-spot workout.",
"labels": { "warmup": "Warmup", "work": "Sweet Spot", "cooldown": "Cooldown" },
"cues": { "work:0": "Settle in", "work:1": "Halfway" }
},
"de": {
"name": "Sweet Spot 30",
"description": "Ein kurzes Sweet-Spot-Training.",
"labels": { "warmup": "Aufwärmen", "work": "Sweet Spot", "cooldown": "Ausrollen" },
"cues": { "work:0": "Locker einrollen", "work:1": "Halbzeit" }
}
}
Cue keys follow the pattern <intervalId>:<cueIndex> — so the first cue on the work interval is keyed work:0.
For each rider-visible string, the app picks the best locale match in this order:
strings[<rider-locale>]— the rider's own locale.strings[primaryLocale]— the author's locale.strings["en"]— universal fallback.- The top-level field (
programName, intervallabel, cuetext).
Strings shown from any locale other than the rider's own appear italicised on workout cards and the cockpit, so the rider can tell which strings haven't been translated yet.
strings blocks for additional locales when prompted.Repeat groups
For repetitive structures, a repeat group authors the unit once and tells the app how many times to play it. Repeat groups are expanded into individual blocks on import, so the rider sees each block in the upcoming-intervals strip during the ride.
{
"intervals": [
{ "id": "warmup", "duration": 600, "targetPowerPercent": 50, "intensityZone": "Z1", "label": "Warmup", "intervalType": "WARMUP" },
{
"repeat": 4,
"intervals": [
{ "id": "on", "duration": 480, "targetPowerPercent": 95, "intensityZone": "Z4", "label": "Threshold" },
{ "id": "off", "duration": 240, "targetPowerPercent": 55, "intensityZone": "Z1", "label": "Recovery" }
]
},
{ "id": "cooldown", "duration": 600, "targetPowerPercent": 50, "intensityZone": "Z1", "label": "Cooldown", "intervalType": "COOLDOWN" }
]
}
The example above expands to 1 warm-up + 4×(threshold + recovery) + 1 cooldown = 10 blocks. Repeat groups can nest, but flat is preferable for readability.
Workout types & variants
The workoutType field selects the paradigm; variant selects a sub-shape inside it.
workoutType | What it is | Required interval shape |
|---|---|---|
POWER (default) | FTP-anchored intervals. The trainer rides target wattage in ERG, or follows resistance curves in SIM. | Each interval has targetPowerPercent (and optionally targetPowerEndPercent for ramps). |
HR_ZONE | Heart-rate-driven intervals. The trainer adjusts wattage live to keep the rider's HR in the target zone — useful when cardiovascular load is the training metric (recovery, base, polarised work). | Each interval has targetHrZone (1–5). The START button reads LINK HRM until the HRM is connected. |
ROUTE | Slope-driven simulation rides. The trainer follows the elevation profile from routeProfile; the rider chooses cadence and gearing. | Intervals are usually empty or contain a single full-length placeholder. The actual ride content lives in routeProfile. |
The variant field selects a sub-shape:
STANDARD(default) — ordinary structured workout.RAMP_TEST— flagged as an FTP test. Prompts the rider to update FTP at the end of the ride and is excluded from training-load summaries (so a maximal effort doesn't skew the weekly load chart).
Packs (.ytwpack)
When you want a curated batch of workouts in one go, the .ytwpack format bundles many .ytw files together with a per-pack manifest. A .ytwpack is a ZIP archive under the hood — rename to .zip and unzip to see the contents, or import it via Your Trainer's pack installer to get every workout installed in one tap.
A .ytwpack archive holds:
manifest.jsonat the archive root — the pack-level metadata described below.- One
<slug>.ytwper workout in the pack, where each file follows the schema documented above.
The pack manifest carries enough info for an install-decision sheet without needing to open every workout. Top-level fields:
| Field | Type | Description |
|---|---|---|
schema_version required | integer | Currently always 1. |
pack_id required | string | Stable kebab-case identifier (e.g. sweet-spot). |
name required | string | Display name shown in the pack catalog. |
description required | string | One- or two-sentence summary visible before the rider taps to install. |
version required | string (SemVer) | MAJOR.MINOR.PATCH, optional -prerelease. Patch for content fixes; minor for additive workouts; major for breaking schema changes. Embedded in the published filename (v1.0.2.ytwpack). |
content_hash required | string | sha256: over the sorted-by-slug concatenation of every .ytw file's bytes. Stable across regenerations of unchanged content; bumps every time the workouts inside change. |
generated_at required | string (ISO 8601) | UTC timestamp. |
set required | string | power or hr-zone — the workout family this pack belongs to. |
category required | string | Sub-taxonomy inside the set (e.g. sweet-spot). |
workout_count required | integer | Number of .ytw entries in the pack. |
total_ride_time_seconds required | integer | Sum of the durations of every workout in the pack. |
experience_level required | string | Computed from the contents' difficulty range — one of beginner / intermediate / advanced / mixed. Lowercase wire format; the app capitalises for display. |
hrm_required required | boolean | True if any workout in the pack uses HR_ZONE. |
type_mix required | object | Per-category percentage of total ride time (sums to 100). Drives the in-app type-mix donut on the install sheet. |
duration_histogram required | object | Workout count per duration bin: 0-30, 30-60, 60-90, 90+ (minutes). Drives the install sheet's duration chart. |
contents required | array | Full per-workout entries — superset of the library-manifest entry shape; each carries slug, name, duration_seconds, summary metrics, plus a sparkline array for thumbnail rendering. |
Two install paths for a downloaded .ytwpack:
- One-tap install in Your Trainer — open the
.ytwpackin the share sheet, Your Trainer reads the pack manifest, shows you what's inside (type-mix, duration histogram, total ride time), and installs every workout at once. Available once the in-app pack installer ships. - Manual unzip — rename to
.zip(or unzip directly with any archive tool), then share each.ytwinto Your Trainer one at a time via the share sheet.
Sample pack manifest (truncated contents for readability):
{
"schema_version": 1,
"pack_id": "sweet-spot",
"name": "Sweet Spot",
"description": "26 sweet-spot sessions across classic intervals, sustained stacks, and over-unders.",
"version": "1.0.2",
"content_hash": "sha256:9432a3a76015158dc71ec63…",
"generated_at": "2025-05-28T00:00:00Z",
"set": "power",
"category": "sweet-spot",
"workout_count": 26,
"total_ride_time_seconds": 119340,
"experience_level": "intermediate",
"hrm_required": false,
"type_mix": { "sweet-spot": 100 },
"duration_histogram": { "0-30": 0, "30-60": 5, "60-90": 12, "90+": 9 },
"contents": [
{ "slug": "sweet-spot-3x10min-at-88pct-ftp", "name": "Sweet Spot 3×10min @ 88% FTP", "duration_seconds": 3300, "tss": 55.8, "intensity_factor": 0.764, "sparkline": […] },
{ "slug": "sweet-spot-3x15min-at-90pct-ftp", "name": "Sweet Spot 3×15min @ 90% FTP", "duration_seconds": 4500, "tss": 81.6, "intensity_factor": 0.808, "sparkline": […] }
/* … 24 more workouts … */
]
}
Library & pack manifests
Two manifests are published alongside the downloadable artefacts. Both are plain JSON; both are described by JSON Schema documents you can fetch directly.
| URL | What it lists | JSON Schema |
|---|---|---|
library/manifest.json |
Every curated .ytw in the library — per-workout metadata for browse / search / filter clients. Also lists the available .ytwpack downloads (file path, version, content hash, type-mix summary, icon URL). |
/schemas/workout-manifest.json |
packs/manifest.json |
Pack-catalog endpoint: every published .ytwpack with summary metadata. Same per-pack entry shape as the library manifest's packs array; the in-app Pack Library fetches this on rider-initiated refresh. |
/schemas/workout-manifest.json |
(inside each .ytwpack) |
Per-pack manifest carried as manifest.json at the archive root — the table above documents its shape. |
/schemas/workout-pack-manifest.json |
If you're building tooling that consumes the library — a custom workout browser, a workout-converter that targets .zwo, a coach's dashboard that surfaces packs — these are the contracts to validate against. The same per-workout entry shape appears in both manifests' contents / workouts arrays, so a client that handles one handles the other.
Worked examples
Ramp interval
A 5-minute warm-up that ramps from 40 % FTP to 75 % FTP via linear interpolation:
{
"id": "rampup",
"duration": 300,
"targetPowerPercent": 40,
"targetPowerEndPercent": 75,
"intensityZone": "Z1",
"label": "Ramp up",
"intervalType": "WARMUP"
}
Over-under
Three sets of 2 minutes at 95 % FTP / 1 minute at 105 % FTP, expressed as a repeat group:
{
"repeat": 3,
"intervals": [
{ "id": "under", "duration": 120, "targetPowerPercent": 95, "intensityZone": "Z4", "label": "Under" },
{ "id": "over", "duration": 60, "targetPowerPercent": 105, "intensityZone": "Z5", "label": "Over" }
]
}
HR-Zone workout
30-minute Z2 endurance ride with a 3-minute Z4 surge in the middle:
{
"programId": "hr-z2-with-surge",
"programName": "Z2 with a Z4 surge",
"description": "Steady Zone 2 with a single 3-minute Zone 4 surge.",
"totalDuration": 1800,
"workoutType": "HR_ZONE",
"primaryLocale": "en",
"intervals": [
{ "id": "warmup", "duration": 300, "targetHrZone": 1, "intensityZone": "Z1", "label": "Warmup", "intervalType": "WARMUP" },
{ "id": "endure1", "duration": 600, "targetHrZone": 2, "intensityZone": "Z2", "label": "Endurance" },
{ "id": "surge", "duration": 180, "targetHrZone": 4, "intensityZone": "Z4", "label": "Surge" },
{ "id": "endure2", "duration": 420, "targetHrZone": 2, "intensityZone": "Z2", "label": "Endurance" },
{ "id": "cooldown","duration": 300, "targetHrZone": 1, "intensityZone": "Z1", "label": "Cooldown", "intervalType": "COOLDOWN" }
]
}
Multilingual
The minimal sweet-spot example with EN + DE + NL strings blocks. Same workout, three native experiences:
{
"programId": "my-sweet-spot",
"programName": "Sweet Spot 30",
"description": "A short sweet-spot workout.",
"totalDuration": 1800,
"workoutType": "POWER",
"primaryLocale": "en",
"intervals": [
{ "id": "warmup", "duration": 300, "targetPowerPercent": 50, "intensityZone": "Z1", "label": "Warmup", "intervalType": "WARMUP" },
{ "id": "work", "duration": 1200, "targetPowerPercent": 88, "intensityZone": "Z3", "label": "Sweet Spot", "intervalType": "INTERVAL",
"cues": [
{ "offsetSec": 0, "text": "Settle in" },
{ "offsetSec": 600, "text": "Halfway" }
]
},
{ "id": "cooldown", "duration": 300, "targetPowerPercent": 50, "intensityZone": "Z1", "label": "Cooldown", "intervalType": "COOLDOWN" }
],
"strings": {
"en": {
"name": "Sweet Spot 30",
"description": "A short sweet-spot workout.",
"labels": { "warmup": "Warmup", "work": "Sweet Spot", "cooldown": "Cooldown" },
"cues": { "work:0": "Settle in", "work:1": "Halfway" }
},
"de": {
"name": "Sweet Spot 30",
"description": "Ein kurzes Sweet-Spot-Training.",
"labels": { "warmup": "Aufwärmen", "work": "Sweet Spot", "cooldown": "Ausrollen" },
"cues": { "work:0": "Locker einrollen", "work:1": "Halbzeit" }
},
"nl": {
"name": "Sweet Spot 30",
"description": "Een korte sweet-spot-training.",
"labels": { "warmup": "Inrijden", "work": "Sweet Spot", "cooldown": "Uitrijden" },
"cues": { "work:0": "Rustig inrijden", "work:1": "Halverwege" }
}
}
}
Common pitfalls
- Missing
idon intervals. The runtime accepts intervals without anid, but you'll lose your translation entry-points —strings.<locale>.labelsand the cue-key map both rely on it. If you intend to ship a workout withstrings, give every interval anid. - Mismatched cue keys. The cue-key pattern is
<intervalId>:<cueIndex>— zero-indexed. The third cue on theworkinterval iswork:2, notwork:3. - Both
targetPowerPercentandtargetHrZoneon the same interval. They're mutually exclusive — keep one or the other based on the parent'sworkoutType. - Forgetting
intervalTypeon warm-ups and cooldowns. The default isINTERVAL— explicitly setWARMUP/COOLDOWNso the analytics layer doesn't roll those blocks into the work total. - Inflated
totalDuration. Harmless during authoring — Your Trainer recomputes it fromintervalson save. But it's worth getting right before sharing, because some external tools display the field verbatim. - Wrong
intensityZonetoken. Must be one ofZ1–Z5as a string."Z6"or"3"won't render the right colour on the terrain visualization.
Reference
- The AI Workout Coach generates valid
.ytwJSON from a plain-text description — useful as a starting point you then hand-edit. - For deterministic programmatic authoring (no LLM): Your Trainer MCP — Integrator Documentation covers
build_workout_from_intentanddecompose_workout, which compose to / from this schema. - For workouts that won't import: Troubleshooting → Workout & route imports.
- For zone definitions: Training zones.
- For unfamiliar terms: Glossary.
- The workouts bundled with Your Trainer exercise every shape the schema supports — examining one in a text editor (export from the visual editor) is a quick way to see a real example of any field.
For programmatic format-spec lookups (field tables, examples, constraints, glossary) the canonical machine-readable source is the Your Trainer MCP — call get_format_spec, get_canonical_examples, get_format_constraints, or get_format_glossary from any MCP client. These tools serve from the same knowledge registry that drives this page; if a tool's answer ever drifts from this page, the MCP is canonical and this page is stale.
JSON Schemas (machine-readable contracts for clients building on these formats):
/schemas/workout-manifest.json— library manifest + per-pack entry shape (covers bothlibrary/manifest.jsonandpacks/manifest.json)./schemas/workout-pack-manifest.json— themanifest.jsoncarried inside every.ytwpack./schemas/workout-intent.json— structured-intent shape used by workout-authoring tools that compose to.ytw.