An open, plain-JSON format for strength and bodyweight workout plans, with a tiny dependency-free JavaScript library to go with it:
schema/workout-plan.schema.json: a JSON Schema (draft 2020-12) for plans. It covers sessions, exercises, sets, reps, rep ranges, timed holds, per-side work, load, rest, supersets, RPE, tempo, progression rules and a multi-week schedule.validatePlan(plan): checks a plan and returns every problem with a JSON path. No Ajv needed.sessionStats(session)/planStats(plan): sets, reps, volume (reps × load, also in kg) and an estimated duration including rest.expandPlan(plan): turns the template plus its progression rules into the concrete sessions for every week ("week 3, day 2: squat 5×5 @ 52.5 kg").
It runs in Node 18+ and modern browsers, ships TypeScript types, and has four example plans in plans/.
Why a schema? Workout plans usually live in notes, spreadsheets and screenshots, and every app has its own format for them. A small shared format makes plans easy to write by hand, generate, diff, review and move between tools.
Not published to npm. Clone it, or copy src/ and schema/ into your project:
git clone https://github.com/jackships/workout-plan-schema.git
cd workout-plan-schema
npm test
node examples/summary.js plans/five-by-five.json{
"$schema": "https://raw.githubusercontent.com/jackships/workout-plan-schema/main/schema/workout-plan.schema.json",
"schemaVersion": "1.0",
"name": "Barbell 5x5",
"unit": "kg",
"defaultRestSeconds": 180,
"sessions": [
{ "id": "A", "name": "Workout A", "exercises": [
{ "name": "Back squat", "sets": 5, "reps": 5, "load": 40, "progression": { "type": "linear", "increment": 2.5 } },
{ "name": "Bench press", "sets": 5, "reps": 5, "load": 30, "progression": { "type": "linear", "increment": 2.5 } }
] },
{ "id": "B", "name": "Workout B", "exercises": [
{ "name": "Back squat", "sets": 5, "reps": 5, "load": 40, "progression": { "type": "linear", "increment": 2.5 } },
{ "name": "Deadlift", "sets": 1, "reps": 5, "load": 50, "progression": { "type": "linear", "increment": 5 } }
] }
],
"schedule": { "weeks": 8, "daysPerWeek": 3, "rotation": ["A", "B"], "deloadWeeks": [6] }
}Most editors (VS Code, JetBrains) pick up $schema and give you autocomplete and inline errors.
import { readFileSync } from "node:fs";
import { validatePlan, sessionStats, expandPlan, planStats } from "./src/index.js";
const plan = JSON.parse(readFileSync("plans/beginner-full-body.json", "utf8"));
validatePlan(plan);
// { valid: true, errors: [] }
validatePlan({ schemaVersion: "1.0", name: "x", sessions: [{ id: "A", name: "A", exercises: [{ name: "Row", sets: 3, reps: 10, progression: { type: "linear", increment: 2.5 } }] }] });
// { valid: false, errors: [{ path: "$.sessions[0].exercises[0].progression", message: "linear progression needs a load" }] }
sessionStats(plan.sessions[0], { defaultRestSeconds: plan.defaultRestSeconds });
// { exercises: 4, sets: 11, reps: 90, timedSeconds: 40, volume: 0, volumeKg: 0, unit: "kg",
// workSeconds: 310, restSeconds: 585, durationSeconds: 895, durationMinutes: 15 }
expandPlan(plan)[3].exercises[0];
// { name: "Reverse lunge", sets: 3, reps: 9, perSide: true, step: 1 } (week 2, day 1)
const { perTemplate, sessions, program, weekly } = planStats(plan);node examples/summary.js plans/push-pull-legs.json 1 prints the templates, the expanded sessions and the weekly totals:
Push / pull / legs (dumbbells + cable)
Push: 5 exercises, 16 sets, ~26 min, volume 6860 lb
Pull: 5 exercises, 16 sets, ~27 min, volume 11550 lb
Legs: 5 exercises, 15 sets, ~30 min, volume 8640 lb
Week 1 day 1 - Push: Dumbbell bench press 4x6 @ 50lb, Seated dumbbell shoulder press 3x8 @ 35lb, ...
Week 1 day 4 - Push: Dumbbell bench press 4x8 @ 50lb, Seated dumbbell shoulder press 3x10 @ 35lb, ...
| Level | Field | Meaning |
|---|---|---|
| plan | schemaVersion |
Always "1.0" for now. |
| plan | name, description, tags |
Free text. |
| plan | unit |
"kg" (default) or "lb". Applies to every load and load increment. |
| plan | defaultRestSeconds |
Rest after each set when an exercise doesn't say (default 60). |
| plan | sessions[] |
Template sessions: { id, name, note?, exercises[] }. Ids are unique. |
| plan | schedule |
Optional: { weeks, daysPerWeek, rotation[], deloadWeeks?, deloadFactor? }. |
| exercise | name, sets |
Required. |
| exercise | reps / repRange / seconds |
Exactly one, e.g. 5, [8, 12] or 30. |
| exercise | amrap |
As many as possible. Then the target above is optional and acts as a minimum. |
| exercise | perSide |
The target is for each side. Reps and time are doubled in the stats. |
| exercise | load |
In the plan unit. Leave it out for bodyweight. |
| exercise | restSeconds, group, rpe, tempo, note |
Rest after each set, superset label, effort 1–10, "3-1-1" style tempo, free text. |
| exercise | progression |
See below. |
Schedule. Sessions are numbered across the whole program and the rotation keeps cycling, so rotation: ["A", "B"] at 3 days a week gives A B A, then B A B. Without a schedule, a plan is one pass through its sessions in order.
Progression rules ({ type, increment, every?, repStep?, max? }):
| type | What moves | Needs |
|---|---|---|
linear |
load goes up by increment |
load |
reps |
reps go up by increment |
reps |
time |
seconds go up by increment |
seconds |
double |
reps climb from the bottom to the top of repRange in repStep steps; then load goes up by increment and reps go back to the bottom |
repRange and load |
every: "session" (default) steps on every exposure of the exercise. Exposures are counted by exercise name across the whole program, so a squat that appears in both workout A and B steps up every time you squat. every: "week" steps once per week. max caps the moving value. In deloadWeeks, loads are multiplied by deloadFactor (default 0.9) and progression pauses, so the week after a deload picks up where the week before it left off.
- Volume = reps × load, summed over sets (per-side reps count twice). Bodyweight and timed sets add nothing.
volumeKgconverts pounds with 1 lb = 0.45359237 kg. - Duration = work + rest. A reps set takes
reps × secondsPerRep(default 3 s), with a 15 s minimum for setup. A timed set takes its seconds. An AMRAP set without a target takes 45 s. Rest is counted after every set except the last one of the session. All of these are options you can change, as isrange("low","mid"or"high", default"high") for which end of a rep range to count. - Supersets are not special-cased. Write the rest you actually take:
restSeconds: 0on the first exercise of the pair, rest on the second.
expandPlan describes the planned path, assuming every target is met. Real training needs feedback: repeat a weight when you miss reps, back off after a bad week. That logic belongs in the app that logs your sets. The example plans are reasonable general templates, not individual coaching or medical advice.
| File | What it is |
|---|---|
beginner-full-body.json |
Bodyweight, 2 alternating days, 3×/week for 4 weeks, weekly rep and hold steps |
five-by-five.json |
Classic novice barbell 5×5, A/B, +2.5 kg per session (+5 kg deadlift), deload in week 6 |
push-pull-legs.json |
6 days/week PPL in pounds, double progression, arm supersets |
kettlebell-conditioning.json |
One kettlebell, 2×/week, carries and an AMRAP finisher |
npm test # node:test, no dependenciesThe tests check that every example plan validates, that the validator and the JSON Schema allow exactly the same properties, and the stats and expansion maths.
MIT © 2026 Jack Ships
Made by Jack, who also builds Movo, an iOS app that turns a pasted workout plan into a set-by-set session.