Training Plans API
Goal-driven training plans for the Practice element, mounted at /api/practice/plans. A plan is an authored curriculum ("learn the windmill") made of ordered days, each holding ordered blocks ("Warm-up", "Windmill drills"), each holding ordered steps ("10 hip openers"). Distinct from /api/practice/sessions, which are ad-hoc Fitbod-style runs over knowledge items.
This surface is agent-first: one POST /api/practice/plans can author an entire plan tree, so a coach agent does not need a dozen round trips.
Auth#
Every route needs a JWT or a PAT. Writes need scope practice:write (the same scope as the rest of the Practice module, with no separate plans scope). Reads of a plan whose visibility is "public" work unauthenticated.
Rate limits (PATs only; JWTs bypass): practice-plans-write 60/min, practice-plan-completions-write 120/min.
Create a whole plan in one call#
POST /api/practice/plans
{
"title": "Learn the windmill",
"goal": "Three clean consecutive windmills",
"discipline": "bboy",
"status": "active",
"daysPerWeek": 3,
"startDate": "2026-09-14",
"days": [
{
"label": "Day 1: Foundation",
"weekday": 1,
"blocks": [
{
"title": "Warm-up",
"kind": "warmup",
"mode": "time",
"targetSeconds": 600,
"steps": [
{ "text": "10 neck circles" },
{ "text": "10 hip openers", "reps": 10 },
{ "text": "10 hollow rocks", "reps": 10 }
]
},
{ "title": "Windmill drills", "kind": "drill", "mode": "reps", "targetReps": 20, "sets": 3, "restSeconds": 60 }
]
},
{ "label": "Day 2: Conditioning" }
]
}
Returns 201 {plan} with the full tree. Positions come from array order. Never send a position; days, blocks and steps are numbered 1..n as they appear. The slug is derived from the title and is unique per user, not globally.
Field reference#
Plan: title (1..160, trimmed), goal (<=2000), description (<=5000), discipline (<=60), status draft | active | archived (default draft), visibility private | public (default private), startDate YYYY-MM-DD, daysPerWeek 1..7, sessionTime "HH:MM" 24h local (null = all-day in the calendar feed), reminderMinutes 0..1440 (null = no alarm).
Day: label (1..120), weekday 0..6 where 0 = Sunday (null means "the next session" rather than a pinned weekday), notes (<=2000).
Block: title (1..160); kind one of warmup, skill, drill, strength, conditioning, mobility, cooldown, other (default other); mode time | reps (default time); targetSeconds 1..86400; targetReps 1..1000; sets 1..100 (default 1); restSeconds 0..3600 (default 0); notes (<=2000); steps up to 50.
Step: text (1..300, trimmed), reps 1..1000, durationSeconds 1..86400.
Caps: 60 days per plan, 50 blocks per day, 50 steps per block.
Routes#
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /api/practice/plans | auth | ?status=&limit=1..100; {plans}, yours only, newest-updated first |
| POST | /api/practice/plans | practice:write | nested create above; 201 {plan} (full tree) |
| GET | /api/practice/plans/:planId | public if plan is public | {plan} with days[].blocks[].steps[] |
| PATCH | /api/practice/plans/:planId | practice:write | any plan field; a nested days key is ignored, edit days via the day routes |
| DELETE | /api/practice/plans/:planId | practice:write | 204; cascades to days, blocks, steps and completions |
| POST | /api/practice/plans/:planId/days | practice:write | appends at the next position; 201 {day} |
| PATCH | /api/practice/plans/:planId/days/:dayId | practice:write | {day} |
| DELETE | /api/practice/plans/:planId/days/:dayId | practice:write | 204 |
| POST | /api/practice/plans/:planId/days/:dayId/blocks | practice:write | accepts inline steps; 201 {block} |
| PATCH | /api/practice/plans/:planId/days/:dayId/blocks/:blockId | practice:write | {block} |
| DELETE | /api/practice/plans/:planId/days/:dayId/blocks/:blockId | practice:write | 204 |
| PUT | /api/practice/plans/:planId/days/:dayId/blocks/:blockId/steps | practice:write | replaces the whole list; {steps: []} clears it |
| POST | /api/practice/plans/:planId/completions | practice:write | records the tally; 201 {completion} |
| GET | /api/practice/plans/:planId/completions | auth | ?from=&to= (YYYY-MM-DD); {completions} |
| DELETE | /api/practice/plans/:planId/completions | practice:write | ?blockId=&isoDate= (query, not a body); 204 |
| POST | /api/practice/plans/:planId/calendar-token | practice:write | mints the feed token (201) or returns the existing one (200); {"rotate": true} replaces it |
| DELETE | /api/practice/plans/:planId/calendar-token | practice:write | revokes the feed; 204 |
| GET | /api/practice/plans/:planId/calendar.ics | token in query | ?token=<uuid>; text/calendar subscribable feed |
Every nested resource is addressed under its plan, and a day or block that does not belong to the plan in the URL is a 404 (not a 403), so a private plan id is never confirmable by a stranger.
The completion tally#
POST /api/practice/plans/:planId/completions
{ "blockId": "…", "isoDate": "2026-09-14", "setsCompleted": 3, "elapsedSeconds": 620, "completed": true }
Unique on (user, block, date), so re-POSTing the same triple updates rather than duplicating, which is safe for a runner that syncs mid-set. completed: false clears completedAt while keeping the partial setsCompleted/elapsedSeconds, which is what un-checking a block means. GET /completions always returns the caller's own tally, even on a public plan.
The schedule#
weekday and daysPerWeek together decide which calendar dates a plan runs on:
- A day with a pinned
weekdayruns on that weekday, every week. - A day with
weekday: nullis floating and rotates through whatever weekly slots are left. Three floating days atdaysPerWeek: 2run D1,D2 then D3,D1 then D2,D3 — the A/B/A shape a real program has. - Slot weekdays are picked Mon, Wed, Fri, Tue, Thu, Sat, Sun in that order, skipping any weekday a pinned day already owns, and capped at
daysPerWeek(falling back to the number of days). - The rotation is anchored on
startDate, and nothing is scheduled before it. A plan with nostartDatestill has a stable rotation, just an arbitrary phase.
Floating days always get at least one slot, even when daysPerWeek is already spent on pinned days — a day that is never scheduled reads as a bug rather than as a cadence.
Calendar feed#
POST /api/practice/plans/:planId/calendar-token -> {"token": "…uuid…"}
GET /api/practice/plans/:planId/calendar.ics?token=…uuid…
The .ics route is deliberately unauthenticated: a subscribing calendar server carries no session, so the token in the query string is the whole credential. It is looked up first and the path's planId only has to agree with it, so a wrong token cannot confirm a plan id exists. The token never appears in a plan read by anyone but the owner. Minting is idempotent (an already-pasted subscribe URL keeps working); rotate and DELETE are the escape hatches for a leaked URL.
The feed carries one VEVENT per scheduled date over a rolling window (14 days back, 120 forward), with a stable per-date UID so a refresh updates events in place. With sessionTime set the events are timed and floating-local (no TZID, so the session is at 07:00 wherever you are) with a duration estimated from the day's blocks; without it they are all-day. reminderMinutes becomes a VALARM, which is how a plan reminds you at all — there is no push-notification service behind it.
Gotchas#
isoDateis a calendar date, not a timestamp.2026-02-30is a 400.- Whitespace-only
title,labelor steptextis a 400, not a blank row. PATCHwith an empty body is a 400 ("Nothing to update").- The
.icsfeed isCache-Control: no-store, but a subscriber refreshes on its own schedule (typically hours), so a plan edit is not visible in Google Calendar immediately. - A
mode: "reps"block should carrytargetRepsand amode: "time"blocktargetSeconds; neither is enforced server-side, so an agent that sets the wrong one produces a block the runner cannot time.