Open spec · v0.1 draft

The Open Swim Workout format & SwimText

An open, JSON-based interchange format for prescribed swimming workouts — the workout a coach writes, not the activity a watch records. Built around what actually lands on whiteboards: sections with totals, nested repeats, stroke / activity / effort descriptors, and per-lane send-offs, which no existing format expresses.

.swimworkout · JSON (UTF-8) · application/vnd.openswimworkout+json · JSON Schema 2020-12 · MIT licensed

Design principles

Faithful to the whiteboard

Faithful

Represent real coach notation — per-lane intervals @1:30/1:40/1:50, brace repeats, footnotes — not an idealized model.

Layered semantics

Structure everything that has meaning, and keep verbatim free text (note, sourceText) at every level — nothing is ever lost to parsing.

Computable

Per-group distances and duration estimates derive mechanically; stated totals act as checksums — useful for validating OCR imports.

Open

Versioned spec, published JSON Schema, MIT-licensed reference implementation (SwimWorkoutKit, Swift).

Two syntaxes, one model. The canonical encoding is JSON (.swimworkout). Its human companion, SwimText, reads like coach shorthand and round-trips losslessly for the canonical subset — for quick entry, iMessage sharing, and LLM input/output.
The same workout, twice

SwimText ⇄ JSON

A real example from the spec repository: friday-sprint, a 3,200-yard short-course sprint workout for three speed groups. The JSON below is abridged to the interesting parts — the full file is downloadable.

friday-sprint.swimtext
# Friday — Sprint
course: scy
categories: sprint
total: 3200

== Warmup: 300
3x100 as swim/kick/pull
== Main Set: 2800
3x300 free descend to 80% @4:40/4:50/5:00 (ideally :40 rest)
100 kick easy r1:00
4x200 free descend to 90% @3:10/3:20/3:30 (ideally :30 rest)
100 kick easy r1:00
5x100 choice descend to 95% @1:30/1:40/1:50 (ideally :20 rest)
100 kick easy r1:00
6x50 choice descend to 100% @1:00/1:10 (ideally :15 rest)
== Cool Down: 100
100 choice easy
friday-sprint.swimworkout (abridged)
{
  "format": "open-swim-workout",
  "version": "0.1",
  "workout": {
    "title": "Friday — Sprint",
    "course": { "length": 25, "unit": "yd" },
    "categories": ["sprint"],
    "groups": [ { "id": "A" }, { "id": "B" }, { "id": "C" } ],
    "sections": [
      { "name": "Warmup", "statedDistance": 300, "items": [  ] },
      { "name": "Main Set", "statedDistance": 2800, "items": [
        … the 3×300 and 4×200 sets, easy kick 100s …
        {
          "type": "set",
          "reps": 5,
          "distance": 100,
          "stroke": "choice",
          "effort": { "shape": "descend", "percent": 95 },
          "interval": {
            "mode": "sendoff",
            "sendoffs": { "A": "1:30", "B": "1:40", "C": "1:50" },
            "targetRest": ":20"
          },
          "sourceText": "5x100 choice descend to 95% @1:30/1:40/1:50 (ideally :20 rest)"
        },
        … the 6×50 set …
      ] },
      { "name": "Cool Down", "statedDistance": 100, "items": [  ] }
    ],
    "statedTotal": 3200
  }
}

Repeat blocks

Brace repeats nest, carry per-round variations, and can even run a different number of rounds per group. From the wednesday-im-nested example:

SwimText
== Main Set: 2000
2x {
  4x75 imo as kick/drill/swim r:20
  100 free sprint @1:30/1:45/2:00 (~:15 rest)
  100 im sprint @1:30/1:45/2:00/2:20 (~:10 rest)
  100 choice easy r1:00
  75 (25 fly/25 back/25 breast) sprint @1:10/1:20/1:30 (~:10 rest)
  
}
JSON (abridged)
{
  "type": "repeat",
  "rounds": 2,
  "items": [
    {
      "type": "set",
      "reps": 4, "distance": 75, "stroke": "imo",
      "segments": [
        { "distance": 25, "activity": "kick" },
        { "distance": 25, "activity": "drill" },
        { "distance": 25, "activity": "swim" }
      ],
      "interval": { "mode": "rest", "rest": ":20" }
    },
    {
      "type": "set",
      "reps": 1, "distance": 100, "stroke": "free",
      "effort": { "level": "sprint" },
      "interval": {
        "mode": "sendoff",
        "sendoffs": { "A": "1:30", "B": "1:45", "C": "2:00" },
        "targetRest": ":15"
      }
    },
    
  ]
}
The defining feature

Speed groups: one workout, many speeds

A speed group is a lane or pace cohort, declared once per workout and ordered fastest → slowest. Sets carry a send-off map from group id to time — exactly what coaches write as @1:30/1:45/2:00. Groups can also override reps or distance per set, and items can be filtered to specific groups.

Sparse maps are legal — and resolved deterministically. A group with no entry resolves to the nearest faster (earlier-listed) specified group, then to the nearest slower one. That encodes the everyday reality of a coach listing three times in a four-lane workout. In wednesday-im-nested, the line 100 free sprint @1:30/1:45/2:00 appears in a workout with four groups:

A @1:30 listed
B @1:45 listed
C @2:00 listed
D @2:00 inherits from C, the nearest faster listed group
Reconciliation rule: distance and duration are computed per group, and a stated total “matches” when any group’s computed distance equals it — printed totals usually describe one cohort. Repeat blocks resolve roundsPerGroup sparsely the same way.
Vocabulary

Strokes, activities, effort

Stroke and activity are orthogonal axes: “fly drill” is simply stroke: fly + activity: drill — a combination most formats cannot express.

stroke

  • free freestyle
  • back backstroke
  • breast breaststroke
  • fly butterfly
  • im individual medley
  • imo IM order, rotating across reps/rounds
  • rimo reverse IM order
  • stroke the swimmer’s specialty stroke
  • choice swimmer’s choice
  • mixed varies — see segments / perRep

activity

  • swim full-stroke swimming (default)
  • kick kicking, with or without a board
  • pull pulling — pairs with buoy/paddles
  • drill technique drill; name in drillName
  • scull sculling
  • mixed varies within the set

Equipment rides alongside: fins, paddles, buoy, snorkel, board.

effort

  • level easy · smooth · moderate · strong · fast · sprint · max · race
  • shape steady · build · descend · ascend · negative-split · variable-sprint
  • percent “% effort,” optionally a range via percentMax; with descend, the target — “descend to 90%”
  • detail free-form shape detail: "1-4", "by 25", "by round"
Interval modeMeaningExtras
sendoff Leave on a clock time; sendoffs maps group id → time targetRest (“ideally :15 rest”), maxRest, openEnded (“@1:25+” — slowest listed send-off is a floor)
rest Fixed rest between reps, in rest SwimText: r:20, r1:00, r20s, r1m
open No clock

All times are strings — "1:30", ":35", or bare seconds "35" — emitted canonically as m:ss, with :ss for sub-minute values. Validators flag non-positive reps, segment sums that don’t match the rep distance, undefined group ids, totals matching no group, distances not divisible by the course length, and send-offs implausibly shorter than the estimated swim time.

Reference implementation

SwimWorkoutKit

A UI-free Swift package: the OSW model with Codable, the SwimText parser, printer, and lexer, per-group totals and duration calculators, and the validator — unit-tested against a corpus of representative swim workouts covering the format's features. An optional SwimWorkoutKitUI module adds SwimText syntax highlighting (see below).

  • swimtext to-json — parse SwimText into a .swimworkout document.
  • swimtext to-text — print a document back as SwimText.
  • swimtext check — validate and report per-group distance and estimated duration.

Parsing never rejects input: unrecognized fragments land in note, whole unparsed lines become note items — and the caller is told.

terminal
$ swimtext check friday-sprint.swimtext
group A: 3200 yd, ~40:10 (partial)
group B: 3200 yd, ~43:10 (partial)
group C: 3200 yd, ~45:10 (partial)
Interop: OSW is the source of truth; exports are intentionally lossy. Garmin FIT: one chosen group, descriptors become step notes, send-off → repetition_time. Apple WorkoutKit (watchOS 11+): one group, send-offs → poolSwimDistanceWithTime goals, stroke/activity → step display names. SwimText round-trips losslessly for the canonical subset.
Editor & display

SwimText syntax highlighting, built in

The optional SwimWorkoutKitUI module colors SwimText with the same visual vocabulary as the app and its deck sheets — strokes, activities, effort, send-offs, sections, and notes — so a typed or pasted workout reads at a glance. It's color-blind-safe and pairs color with weight, staying legible in black & white.

  • SwimTextView — a UITextView / NSTextView subclass that highlights live as the text changes. Set its text and it colors itself; type into it and it re-highlights on every keystroke.
  • SwimTextEditor — a SwiftUI wrapper around SwimTextView: a drop-in, highlighting replacement for TextEditor.
  • SwimTextLabel — a read-only SwiftUI view (renders into Text), so it works everywhere, including watchOS.
  • SwimTextHighlighter — the engine: SwimText → AttributedString or NSAttributedString. Built on the UI-free SwimTextLexer in the core library.
  • SwimTextTheme — the palette. .standard matches the app; pass your own to any view to retheme.
SwiftUI
import SwiftUI
import SwimWorkoutKitUI

struct WorkoutEntry: View {
  @State private var text = "8x50 free fast @:45 r:10"
  var body: some View {
    SwimTextEditor(text: $text)   // live highlighting
  }
}
SwimTextView · rendered
# Friday — Sprint
course: scy
== Warmup: 300
3x100 as swim/kick/pull
== Main Set: 2800
3x300 free descend to 80% @4:40/4:50/5:00
2x {
  6x25 fly sprint @:35
  100 back smooth r:20 | long axis
}
> remember your fins
One vocabulary, everywhere. The colors here are the same SwimTextTheme tokens the app draws on screen and prints to deck sheets — strokes by color, send-offs in high-contrast blue, sections by name, effort by intensity, notes dimmed.