Use the pydantic models in src/gymbook_connector/ — don't hand-write .workout
JSON. This is a reverse-engineered format (no official spec); the models encode
everything confirmed by real import testing against the app. Full writeup,
confirmed findings, and open questions: README.md at the repo root — read it
before doing anything nonstandard (custom exercises, new taxonomy codes, fields
not shown below).
The one hard rule
quantityUnit is required on every WorkoutExerciseSet, with no exceptions
— including bodyweight sets that have no quantityValue at all. Omitting it on
even one set fails the entire file's import with a generic, unhelpful error.
When a set truly has no natural unit, use "lb" (or whatever a real export
would plausibly use) rather than leaving it unset. See
src/gymbook_connector/models/workout.py for why this was concluded (found via
extensive real-import bisection testing, not guessed).
Building a workout
from gymbook_connector.io import save_workout
from gymbook_connector.models.exercise import Exercise
from gymbook_connector.models.workout import (
Workout, WorkoutExercise, WorkoutExerciseGroup, WorkoutExerciseSet, WorkoutGroup,
)
workout = Workout(
name="Push Day",
days=["010.monday"], # optional; see codes.py
notes="optional workout-level note", # optional
workoutGroup=WorkoutGroup(name="My Program"), # optional; program grouping
workoutExercises=[
WorkoutExercise(
exercise=my_exercise, # see "Picking exercises" below
notes="optional exercise-level note",
timerDuration=60, # optional, seconds, unconfirmed exact semantics
workoutExerciseGroup=WorkoutExerciseGroup(name="Superset A"), # optional
workoutExerciseSets=[
WorkoutExerciseSet(
type="warmUp", # "default" | "warmUp" | "coolDown" (others unconfirmed)
frequencyUnit="x", # "x"=reps, "s"=seconds, "m"=minutes; free text
frequencyValueMin=5, frequencyValueMax=8, # or just frequencyValueMax for a fixed target
quantityUnit="lb", # REQUIRED -- see above
quantityValue=135, # optional; omit for bodyweight (keep quantityUnit!)
),
],
),
],
)
save_workout(workout, "/path/to/output.workout")
Load an existing file the same way: load_workout(path) from
gymbook_connector.io. Both are pydantic models, so mutate and re-save freely.
Picking exercises
Every workoutExercise.exercise is a full embedded Exercise object (region,
muscles, equipment, an optional identifier), not a reference — GymBook's
taxonomy codes ("070.legs", "060.glutes", etc.) are internal and only
observable from real exports.
- Prefer a known exercise.
data/exercise_catalog.json(rebuild/extend withuv run scripts/ingest.py) maps exercise names to full, confirmed-correctExerciseobjects pulled from real.workoutexports. Load it and constructExercise(**catalog[name])(mind the camelCase aliases) to reuse one exactly. - Need an exercise not in the catalog? Look it up by name in
data/samples/csv/exercises.csv(gymbook_connector.catalog.csv_catalog) for its correct region/muscle names, then build anExerciseyourself: omitidentifier(confirmed OK — that's how a custom exercise is expressed), but still setimageIdentifierto an existing icon id borrowed from a similar known exercise (an original export did exactly this: a custom "Snatch Grip Barbell Shrug" reused theimageIdentifierof the built-in Barbell Shrugs). Pick codes frommodels/codes.py'sKNOWN_*registries by matching muscle/ region names — flag this to the user as unverified if you must invent a code not already in that registry; only ~20 of 485 exercises currently have fully confirmed codes, so this is common and expected, not a bug.
After generating a file
Run uv run pytest if you touched the models. There's no way to verify a
generated file actually imports without a human trying it in the app — say so
plainly rather than asserting it will work, and ask the user to test-import it
if this is new/unusual content (e.g. a first-time custom exercise or a taxonomy
code not yet in KNOWN_*).