The flow
- Your AI calls
push_planwith the plan below (or writes the file itself, or you runhealthaccess plan push session.json). The server validates it, fillsid,created_atandschema, and writesplans/<id>.json. The id is the UTC time plus the slug of the name:2026-05-13T19:02:11Z-s12. - The folder syncs to the phone. The app scans
plans/on launch, when it comes to the foreground and after every export, so the plan shows up within about a minute as "Your AI wrote S27" with Put on Watch and See parts. If the app was not in front, that is also the one notification it sends. - You tap "Put on Watch". Two ways, chosen in Settings:
- Schedule (the default): WorkoutKit schedules the workout for a date, by default the next day at 10:00, changeable. The Watch shows it in the Workout app at that time, and the app can verify it is scheduled, so the status is a fact. default time to confirm
- Apple's "Add to Watch" sheet: installs it as a plain custom workout you start whenever you like. Apple's sheet does not report back, so the app asks you whether you added it and records
method: preview.
- The app writes
plans/<id>.status.json:scheduled,failed(with the error text) orcancelled. Your AI reads it withget_plan_status; until the file exists the state ispending. - You do the session. The Watch shows each step's label. HealthKit drops the labels when it saves the workout, but it keeps the WorkoutKit id, so the exported file gets an exact
plan_refand every step gets itslabelback from the plan.
The format
{
"name": "S27", # shown in the app and on the Watch, 40 characters at most
"sport": "pool_swim", # pool_swim | indoor_row
"pool_length_m": 50, # a reminder for you; the Watch keeps its own setting
"warmup": {"goal": {"type": "time_s", "value": 120}, "zone": 1},
"blocks": [
{"repeat": 4, "steps": [
{"kind": "work", "goal": {"type": "distance_m", "value": 50}, "zone": 1, "label": "FR easy, long strokes"},
{"kind": "recovery", "goal": {"type": "time_s", "value": 30}}
]}
],
"cooldown": {"goal": {"type": "distance_m", "value": 100}, "zone": 1},
"notes": "what this session is for, shown in the app",
"scheduled_for": "2026-10-27T07:30:00Z" # optional; also used to match the finished workout
}
Rules the server checks
sportispool_swimorindoor_row. Other sports are not supported yet.nameis not empty and at most 40 characters.- At least one block.
repeatfrom 1 to 99, each block with at least one step. goal.typeistime_sordistance_m, with a positive value.zone, when given, is 1 to 5. These are the Watch's own heart-rate zones; the Watch alerts when you leave the zone.labelat most 40 characters. Write it as an instruction you can read on the wrist mid-length: "KICK on back, no board, hips up". The app truncates longer labels and logs it.kindisworkorrecovery. Rests are real rests.- Every violation is reported in one message, so the AI can fix them all at once.
A full example
The plan behind the example swim on the files page, plans/2026-05-13T19:02:11Z-s12.json in the repository's synthetic example folder: 1,200 m in six parts, about 36 minutes, as push_plan wrote it.
{
"schema": "healthaccess.plan.v1",
"id": "2026-05-13T19:02:11Z-s12",
"name": "S12",
"sport": "pool_swim",
"pool_length_m": 50,
"warmup": {"goal": {"type": "distance_m", "value": 200}, "zone": 1, "label": "FR easy, find your breathing"},
"blocks": [
{"repeat": 3, "steps": [
{"kind": "work", "goal": {"type": "distance_m", "value": 50}, "zone": 1, "label": "DRILL 6-3-6: 6 kicks side, 3 strokes"},
{"kind": "work", "goal": {"type": "distance_m", "value": 50}, "zone": 1, "label": "FR easy, long strokes"},
{"kind": "recovery", "goal": {"type": "time_s", "value": 30}}
]},
{"repeat": 4, "steps": [
{"kind": "work", "goal": {"type": "distance_m", "value": 100}, "zone": 2, "label": "FR 100 steady, breathe every 3"},
{"kind": "recovery", "goal": {"type": "time_s", "value": 40}}
]},
{"repeat": 2, "steps": [
{"kind": "work", "goal": {"type": "time_s", "value": 120}, "zone": 1, "label": "KICK on back, no board, hips up"},
{"kind": "recovery", "goal": {"type": "time_s", "value": 45}}
]},
{"repeat": 1, "steps": [
{"kind": "work", "goal": {"type": "distance_m", "value": 100}, "zone": 2, "label": "FR 100, carry the drill into it"}
]}
],
"cooldown": {"goal": {"type": "distance_m", "value": 100}, "zone": 1, "label": "Easy, any stroke, long exhale"},
"notes": "Technique week 2. Short drill reps, then four steady 100s to carry the feel over. Kick on back is for the hips, not for speed. Target effort 5 to 6.",
"created_by": "claude-code",
"created_at": "2026-05-13T19:02:11Z",
"scheduled_for": "2026-05-14T05:00:00Z"
}
The swim idea usually puts a two-minute time step first: the athlete starts the workout in the shower, so the Watch is already in swim mode at the pool edge and no length is lost to fumbling with a wet screen. This plan warms up over 200 m instead; both work.
What came back
The app's answer, plans/2026-05-13T19:02:11Z-s12.status.json, after WorkoutKit scheduled it for the next morning (method: schedule; a plan installed through Apple's sheet says preview and has no scheduled_for):
{
"plan_id": "2026-05-13T19:02:11Z-s12",
"state": "scheduled",
"at": "2026-05-13T19:04:40Z",
"method": "schedule",
"workoutkit_id": "D4E7A2C1-6F3B-4E8A-9B5C-1A2F3E4D5C6B",
"scheduled_for": "2026-05-14T05:00:00Z",
"error": null
}
workoutkit_id is what links the finished swim back to this plan: the exported session on the files page carries plan_ref S12 and every step has its label back. get_plan_status returns the same fields; list_plans returns them for every plan in the folder, with planned distance and time.
Limits, all Apple's
- Pool length cannot be set from a plan. WorkoutKit has no parameter for it; the Watch uses its own setting. The app shows
pool_length_mas a reminder. - Step names on the Watch need iOS 18. On iOS 17 the workout installs without labels, and the app logs it.
- Labels longer than 40 characters are cut.
- Zone alerts an activity does not support are dropped and logged.
- Sports: pool swim and indoor rowing today. Running and cycling plans are a question of adding goal types and testing, not of the format.