Skip to content
AFAIFitnessAPI
AI Features

AI Workout Plan Generation: How to Build One That Ships

Updated July 27, 2026

You can generate a workout plan with an LLM, but the implementations that hold up do not let the model invent the plan. Filter your own exercise catalogue down to a candidate set with ordinary database queries, let the model select and sequence from that set, return it as schema-constrained JSON, and validate every row server-side against your own rules before a user sees it. Keep the arithmetic — sets, reps, load progression, weekly volume caps, equipment substitution — in deterministic code, because that is the part users notice when it is wrong. And screen the intake for red flags with a cheap deterministic gate first: for some answers the correct output is not a plan at all.

Ask a language model for a four-day push/pull/legs programme and you will get one. It will read well. It will also name exercises that are not rows in your database, invent a progression scheme, and quietly treat "no barbell, and a cranky left shoulder" as a stylistic suggestion. That is the distance between a demo and a feature.

The generators that survive contact with real users all have the same shape: your catalogue proposes, the model selects, your server decides. The model is doing something it is genuinely good at — reading a messy human goal and picking a sensible, well-ordered subset from a menu — and nothing else. Set and rep math, load progression, volume accounting, and equipment substitution stay in deterministic code. Red-flag screening runs before the model is invoked at all. This page is the build, in order, with the parts that break called out by name.

Why the naive version fails

The naive version is a single prompt: user profile in, plan out. Four things go wrong, and only one of them is obvious.

Hallucinated exercise names. The model returns "Cable Anti-Rotation Split-Stance Pallof Press" and your catalogue has no such row. This is not a cosmetic error — it is a dangling pointer. You cannot render a demo video, you cannot show a muscle map, you cannot log a set against it, and next week's progression has nothing to progress from. Fabrication also gets worse on obscure topics, and niche exercise variations are exactly the low-visibility tail.

Made-up progression. Asked for week 3 of a programme, a model will produce numbers that look like progression — a few more kilos, an extra set — with the same fluent confidence whether or not it has any basis in what the user actually lifted. It has no access to your set logs unless you give it access, and even then arithmetic is not what it is for.

Silently dropped constraints. Equipment lists, injury exclusions and time budgets stated in prose are soft constraints. The model honours them most of the time, which is worse than never, because your QA passes and your incident report arrives from a user. This gets worse in conversation: an injury mentioned on turn 1 has to survive fifteen turns of context to still be excluded on turn 16.

Over-confident numbers. Numeric specificity reads as personalisation to users, and models emit specific numbers whether or not they are grounded. Published expert evaluations of AI-generated resistance-training programmes exist and are broadly characterised as producing "safe but generic" output with weak progression and individualisation — we could not read the primary papers from this environment, so treat that as a direction rather than a measurement. Separately, we found no source at all evaluating LLM accuracy on percentage-of-1RM prescriptions or heart-rate zones, which is a reason to be more careful about those, not less.

ApproachHow it worksWhere it breaks
Prose prompt"Write a 4-day plan for a beginner with dumbbells" and render the markdownNothing is a database row. Names drift between sessions, so progressive-overload math silently breaks. Nothing to log a set against.
Prose plus a parserSame call, then regex or a second model call to extract structureYou now have clean structure wrapped around invented content. A confidently parsed hallucination is harder to spot than an obvious one.
Unconstrained JSON outputSchema-constrained response, model fills every field freelySchema-valid and physiologically arbitrary. exercise_id is a plausible-looking string that matches nothing, and the schema layer cannot even express "sets must be under 10".
Retrieved candidates, model selects, server validatesFilter the catalogue in SQL, enumerate survivors in the prompt, constrain the response schema, validate every row server-side, compute all numbers in codeSlower to build, and it needs a real catalogue and a real rules layer. In exchange it fails loudly instead of silently.

The shape that ships

Six stages. Only stage 3 involves a model.

1. Deterministic intake gate. Before anything else, screen the intake answers for red flags with code the user cannot argue with. Detail below, but the ordering is the point: deterministic before probabilistic.

2. Filter the catalogue in SQL. The constraints in a workout request are almost entirely categorical — equipment in the set the user owns, movement pattern, primary muscle, difficulty band, exclusions from the user's contraindication list. Those are WHERE clauses. A vector index answers them badly, because "no barbell" is a hard filter rather than a similarity gradient. A few thousand rows filter down to something like 30 to 150 candidates, and that is the entire universe the model is allowed to work in. Grounding an LLM in your exercise database goes deeper on the retrieval layer than this page does.

3. One model call: select and sequence. The prompt carries the candidate list, the user's goal in their own words, the session budget, and your programming rules. The model returns which exercises, in what order, in which block, with a rep-scheme intent and a one-line rationale. It never returns a weight.

4. Structured output, not prose. Constrain the response to a JSON schema so the response is guaranteed to parse and match your shape. Check the stop reason before you trust the parse: a refusal or a max_tokens truncation can still hand you output that does not match the schema.

5. Server-side validation. Every ID against the catalogue, every exercise against the original filter, every number against bounds. Reject rather than repair on a catalogue miss.

6. Deterministic arithmetic and persistence. Reps, rest, load, weekly volume, session duration estimate — all computed in code from the validated selection plus the user's history. Persist catalogue IDs, and version-stamp the prompt, schema, catalogue snapshot and model together, because a catalogue edit can regress plan quality with no code change.

A schema sketch

Note what is not in it. There is no weight_kg and no raw reps integer, because the model does not get to decide those. rep_scheme is an enum of intents that your code maps to actual numbers.

{
  "type": "object",
  "additionalProperties": false,
  "required": ["session_name", "focus", "blocks"],
  "properties": {
    "session_name": { "type": "string" },
    "focus": {
      "type": "string",
      "enum": ["upper_push", "upper_pull", "lower", "full_body", "conditioning"]
    },
    "blocks": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["block_type", "items"],
        "properties": {
          "block_type": {
            "type": "string",
            "enum": ["warmup", "main", "accessory", "cooldown"]
          },
          "items": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": ["exercise_id", "order", "sets", "rep_scheme", "rationale"],
              "properties": {
                "exercise_id": { "type": "string" },
                "order": { "type": "integer" },
                "sets": { "type": "integer" },
                "rep_scheme": {
                  "type": "string",
                  "enum": ["strength", "hypertrophy", "endurance", "timed"]
                },
                "rationale": { "type": "string" }
              }
            }
          }
        }
      }
    }
  }
}

Two implementation notes that catch teams out.

Schema enum is supported, so your first instinct is to put the filtered exercise_id set directly in the schema and let constrained decoding make hallucination structurally impossible. Resist it. Compiled grammars are cached, and the cache is invalidated by a change in schema structure — so a per-user filtered enum means every distinct user filter is a distinct schema paying cold compilation. Keep exercise_id a plain string in a stable schema, put the candidate list in the prompt, and validate the ID on your server.

Numeric constraints are not supported at the decoding layer. You cannot enforce minimum or maximum on sets, or a maximum array length on items. SDK helpers strip those from the wire schema and re-check them client-side after generation — which is fine, as long as you understand that schema validity and physiological sanity are unrelated properties.

The validator you still have to write

function validatePlan(plan: GeneratedPlan, ctx: PlanContext): string[] {
  const errors: string[] = [];
  const seen = new Set<string>();
  let estMinutes = 0;

  for (const block of plan.blocks) {
    for (const item of block.items) {
      const ex = ctx.candidates.get(item.exercise_id);
      if (!ex) { errors.push(`unknown exercise_id: ${item.exercise_id}`); continue; }
      if (!ex.equipment.every((e) => ctx.user.equipment.has(e)))
        errors.push(`${ex.id} needs equipment the user does not have`);
      if (ex.contraindications.some((c) => ctx.user.contraindications.has(c)))
        errors.push(`${ex.id} is contraindicated for this user`);
      if (item.sets < 1 || item.sets > ctx.limits.maxSetsPerExercise)
        errors.push(`${ex.id}: sets out of range`);
      if (seen.has(item.exercise_id)) errors.push(`duplicate: ${ex.id}`);
      seen.add(item.exercise_id);
      estMinutes += ctx.estimateMinutes(ex, item);
      ctx.volume.add(ex.primaryMuscle, item.sets);
    }
  }

  if (estMinutes > ctx.user.timeBudgetMinutes * 1.15)
    errors.push("session exceeds the user's time budget");
  errors.push(...ctx.volume.exceedingWeeklyCeiling());
  return errors;
}

On failure, prefer deterministic repair or a filtered retry — drop the offending item and backfill from the candidate set, or re-run once with the bad IDs removed — over re-prompting the model to "fix it". Open-ended repair loops are how a bad generation turns into a bill.

Job by job: model or rules

JobModel?Deterministic code?Why
Interpret a free-text goal ("I want to look like a swimmer")YesNoGenuinely hard for rules, genuinely easy for a model
Red-flag screen on intake answersNoYesMust not be negotiable; runs before the model exists
Filter the catalogue by equipment, injury, difficultyNoYesCategorical constraints are WHERE clauses
Choose which exercises, and in what orderYesNoSelection and sequencing from an enumerated set is the model's job
Rep-scheme intent (strength vs hypertrophy vs endurance)ProposesBounds itModel picks from an enum; code decides what the enum means
Sets, reps, rest secondsNoYesArithmetic, and the part users notice when it is wrong
Load and week-to-week progressionNoYesMust derive from logged history, not from a plausible-sounding guess
Weekly volume caps per muscle groupNoYesA cross-session invariant; the model only sees one session
Equipment substitutionRanksDecidesSubstitution graph lives in the catalogue; the model can order the options
Session duration estimateNoYesSum of per-exercise estimates, not a vibe
Coaching notes and the "why this session" explanationYesNoProse about numbers that already exist is the safest use of a model
Deciding a user needs a clinicianNoYesEscalation is a gate, not a judgement call

The general rule underneath the table: let the model do selection and sequencing, let code do arithmetic. Models are good at the first and unreliable at the second, and the second is what breaks visibly.

What must stay deterministic, in detail

Set and rep math. Map rep_scheme to concrete numbers in code, per exercise class and per user experience level. Then bound everything: sets per exercise, sets per session, rest ranges. A model that returns 14 sets of an accessory movement should produce a validation failure, not a workout.

Progression. Progression is a function of what the user actually did — completed reps, RPE if you collect it, missed sessions — and it belongs in a function you can unit-test and explain to a user. If you let the model do it, you cannot answer "why did it add 5kg this week?", and you will get asked.

Volume caps. Weekly sets per muscle group is a cross-session invariant. The model generating today's session has no view of the week, so this check can only live in your server. It is also the check that catches the most plausible-looking bad plans: five sensible sessions that add up to an unsustainable week.

Equipment substitution. When a candidate is unavailable — travel week, busy squat rack — swap it via a substitution graph in your catalogue, where each edge is a curated equivalence someone signed off on. A model asked to improvise a substitution will produce something reasonable-sounding that may load a joint the original did not. Let the model rank the curated options if you want personalisation there; do not let it invent the edge.

The safety floor

An LLM's output about exercise is not medical advice, and when your app displays it, you own it — not the model vendor. Three consequences are specific to a plan generator; the rest of the guardrail architecture is LLM safety for fitness advice's subject, not this page's.

The gate runs on intake answers, before stage 2. Screen deterministically for cardiac, eating-disorder, self-harm and acute-injury signals with code the user cannot argue with — and accept that for some intakes the correct output is not a plan at all, but emergency guidance, a clinician referral, or a conservative vetted template instead of a free generation. That last tier is the one plan generators skip, and skipping it turns every risk signal into a refusal. Our trigger list, with the constrained-mode tier, is on the safety page, and it is engineering judgement rather than a standard, so have a clinician review yours.

Store constraints, do not converse them. An injury is a profile field that feeds the SQL filter, not a sentence in a chat log hoping to survive the context window. This single decision removes most contraindication-leakage bugs, and it is what lets the gate re-run cheaply on every regeneration instead of re-reading a transcript. It also contains the injection surface: custom exercise names, workout notes and goal descriptions are user-controlled text that lands in the model's context, and they belong in fields you validate rather than instructions you concatenate.

Log the generation, and treat the log as a privacy decision. Prompt version, model, retrieved candidate set, which gate fired and what it returned, the final plan, and what the user did with it. Verbose prompt logging to a third-party observability vendor ships health data somewhere new — as does the model call itself, which is what Apple's Guideline 5.1.2(i) disclose-and-get-explicit-permission requirement covers. See App Store health data rules; do not take a paragraph on this page as a compliance review.

Cost and latency, briefly

The cheapest token is the one you do not send. Pre-filtering the catalogue is a cost lever as much as a quality one: 60 candidates in context instead of 3,000 rows changes the bill by an order of magnitude, and it is the lever most specific to this pipeline. Put the stable material — persona, programming rules, schema, any shared catalogue slice — in a cached prefix and the user's profile after it. One caveat if you tier models by stage: caches are scoped per model, so a mid-pipeline switch throws the cached prefix away.

Tier by job. A small fast model is the right tool for the intake classification and the deterministic-gate assist; a frontier model is worth it for the selection call. What AI fitness features cost has the rates, the caching mechanics and the worked arithmetic; choosing an LLM for fitness apps covers the tiering decision itself.

Two practical notes. Overnight regeneration of next week's plans for the whole user base is non-interactive, high-volume work — the batch path exists for exactly that and is cheaper than the synchronous one. And do not try to stream a schema-constrained plan into the UI token by token: structured and tool input arrives as partial JSON with pauses while the model assembles a key/value pair, so it feels worse than prose. Stream a one-line preamble for perceived responsiveness, then render the plan when the block completes.

The short version

Retrieve, select, validate, compute. The model reads the human and picks from a menu you control; your database owns what exists; your code owns every number; a deterministic gate decides whether a plan should be generated at all. Everything that goes wrong with AI workout generators — dangling exercise IDs, invented progression, plans that ignore the equipment and injuries the user just told you about — is a symptom of letting the model do a job on that list.

Next: build an AI fitness coaching app for the product around this pipeline.

Frequently asked questions

Can I just ask an LLM to write the workout plan?
You can, and the demo will look convincing. The problem is what comes back: exercise names that may not exist in your catalogue, invented progression, and constraints stated in prose that the model treats as suggestions rather than hard limits. For a shipped feature, retrieve a candidate set from your own database first, have the model select and sequence from it, and validate the result server-side. The model is doing selection, not authorship.
What should the model return instead of a finished plan?
Identifiers and intent, not content. Have it return exercise IDs drawn from a candidate set you supplied, a rep-scheme category picked from a fixed enum, and an ordering — then let your own code expand that into sets, reps, load and rest. The model is doing selection and sequencing, which it is good at; your code is doing arithmetic and progression, which it is not. The practical test is whether a wrong answer from the model can survive your validator. If it can only hand back an ID from a list you gave it, an invented exercise cannot reach the user.
Should the LLM decide sets, reps and weights?
Have it propose intent, not numbers. Let it pick a rep-scheme category (strength, hypertrophy, endurance, timed) from a fixed enum, then let deterministic code turn that into actual sets, reps, rest and load using the user's logged history. Models are unreliable at arithmetic and progression, and load math is exactly what a user notices when it drifts. Published expert evaluations of AI-generated resistance-training programmes broadly characterise the output as safe but generic with weak progression and individualisation, though we could not read the primary papers from this environment, so treat that as a direction rather than a measurement.
Where in the pipeline does the safety gate run?
Before generation, not after. Screen the intake and any free-text the user typed with a deterministic check first, decide which path the user is on, and only then call the model — with the path as an input it cannot override. Screening after generation means you have already spent the tokens and still have to throw the plan away, and it tempts you into patching an unsafe plan rather than refusing to build one. Note that some conditions call for a constrained, conservative path rather than a refusal; declining to give a pregnant user a walking programme is its own kind of harm.
How do I regenerate next week's plan without contradicting last week's?
Pass the relevant history as structured facts, not as prose, and make continuity a rule rather than a hope. Your code should decide what progresses and by how much, based on what the user actually logged, and hand the model the resulting targets. If you instead ask the model to remember what it prescribed and build on it, you get drift: the load moves in the wrong direction, an exercise silently disappears, or a movement the user cannot do reappears. Continuity across weeks is state, and state belongs in your database.

Keep reading

Engineering guidance, last reviewed July 27, 2026. This is not medical, nutritional, or legal advice. Language models produce fluent, confident output that can still be wrong — and when the output is exercise or nutrition guidance, being wrong has consequences. Whatever your app tells a user is your responsibility, not the model vendor’s. Model behaviour, pricing, and platform AI policies change often; verify against current official documentation, and design for a qualified human in the loop wherever the advice could cause harm.

← All ai features · by AIFitnessAPI