---
title: "Structured Output for Workout Plans: Constraining What the Model Returns"
canonical: "https://aifitnessapi.com/ai/structured-output-for-workout-plans"
cluster: "AI Features"
primary_query: "llm structured output json schema workout plan"
last_reviewed: "2026-08-12"
description: "Free-text plans do not survive production parsing. Constrain the JSON shape, validate server-side anyway, version the schema, and retry with the errors."
publisher: "AIFitnessAPI — independent, not sponsored"
cite_as: "\"Structured Output for Workout Plans: Constraining What the Model Returns\", AIFitnessAPI, https://aifitnessapi.com/ai/structured-output-for-workout-plans"
---

# Structured Output for Workout Plans: Constraining What the Model Returns

> Treat the response schema as the contract between a probabilistic model and a deterministic engine: declare exactly the fields your engine consumes, use closed enums for every vocabulary that has one, and leave out anything your code recomputes. Constrained decoding guarantees the output parses and matches the shape; it guarantees nothing about whether the identifiers exist, the loads sit inside your caps, or the week adds up, so server-side validation stays non-negotiable. Keep exercise IDs as plain strings in one stable schema with the candidate set in the prompt rather than enumerating them per user, because a per-request schema pays cold grammar compilation on every call. Version the schema like any persisted format, retry once with the specific validation errors as data, and do not constrain coaching prose at all.

- Canonical: https://aifitnessapi.com/ai/structured-output-for-workout-plans
- Last reviewed: 2026-08-12
- Publisher: AIFitnessAPI (https://aifitnessapi.com) — independent, not sponsored
- Cite as: "Structured Output for Workout Plans: Constraining What the Model Returns", AIFitnessAPI, https://aifitnessapi.com/ai/structured-output-for-workout-plans

---

Prose is a document. A plan is a data structure. The demo never notices the difference because a human reads the output; production notices immediately, because the readers are a renderer, a set-logger, a progression job and next week's generator — and none of them can read.

This page is the layer between the model and those readers: how you constrain the *shape* of what comes back, and what that does not buy you. The pipeline around it is [AI workout plan generation](/ai/ai-workout-plan-generation)'s subject; filtering your catalogue down to a candidate set belongs to [grounding an LLM in your exercise database](/ai/ground-llm-in-exercise-database). Everything below is deliberately vendor-neutral: schema-constrained generation ships under a different name, with different limits, from every major provider, so only the mechanics are durable.

## What breaks when the plan is free text

**The format drifts between calls.** One response says `3 x 8-10 @ RPE 7`. The next says "3 sets of 8 to 10 reps at RPE 7 (or 2 sets if you are short on time)". A third writes load in pounds because the goal text mentioned a 225 bench. Your parser handles the first, gets patched for the second, and mis-reads the third. Parse failures also cluster on the users whose requests are unusual — the population you least want to fail.

**A successful parse is not a correct parse.** The dangerous outcome is not an exception, it is a regex that matches the wrong span and returns a number. "Rest 90-120s between sets, 3 min before your heaviest single" yields 90, 120 or 180 depending on which capture group you wrote first, and all three look fine in the row.

**Second-pass extraction inherits the problem and adds cost.** Generating prose and then extracting structure from it doubles the token bill and creates a second surface where content can be invented — clean structure wrapped around whatever the first call made up, which is harder to spot than obvious mush.

## The schema is a contract, not a formatter

A schema is not there to tidy the output. It fixes, in advance, what the deterministic side is allowed to receive. Your engine needs which exercises, in what order, in which block, with what intent; it does not need the model's opinion on load. So declare exactly the fields the engine consumes, mark them required, and forbid unknown properties — a stray key means prompt and schema have drifted apart, and you want that as an error rather than a silently dropped field.

Two rules follow. **Prefer a closed vocabulary to an open string wherever one exists** — block type, training intent, laterality, tempo class, session focus are all enums, and every one removes a class of downstream normalisation. And **leave out anything your engine recomputes anyway**: if sets, reps and load are derived in code from training intent plus logged history, there should be no load field at all. A field the model can fill is a field someone will eventually trust.

### Exercise IDs: the enum you should not build

The tempting move is a per-request schema with the exercise ID field enumerated from that user's equipment-filtered candidate set, making a hallucinated exercise impossible by construction.

Our corpus argues against it for an operational reason: constrained decoding compiles a grammar from the schema, and that compilation is cached against the schema's structure. A per-user enum is a distinct structure per user, so every request pays cold compilation — trading a validation problem you had to solve anyway for a latency problem you did not. Keep the ID a plain string in one stable schema, enumerate candidates in the prompt, and enforce membership server-side. Same guarantee; only the enforcement point moves.

So: **stable vocabularies in the schema, per-user vocabularies in the prompt.** That is engineering judgement rather than a provider recommendation, and it inverts if your candidate sets ever collapse to a handful of fixed variants.

## Constrained decoding does not make validation optional

Schema validity and plan validity are unrelated. A response can satisfy every constraint a decoder can enforce and still be unsafe or unbuildable. Three checks the schema layer structurally cannot do:

- **Referential integrity.** Every returned identifier must exist in the catalogue *and* still be in the candidate set supplied for this request. Not "looks like an ID" — is in the set. On a miss, reject rather than repair; a fabricated ID means something upstream is wrong, and patching it hides the signal.
- **Numeric bounds.** Constraints such as minimum, maximum and array length are widely unsupported at the decoding layer, and client libraries that accept them commonly strip them from the wire schema and re-check afterwards. Assume you own the bounds: sets per exercise, sets per session, rest ranges, loads against caps derived from history.
- **Cross-row invariants.** Weekly sets per muscle group, duplicates inside a session, estimated duration against the time budget, and whether the selection still satisfies the original filter. No per-field constraint sees the whole document, and one block with no items is schema-valid, required and useless.

The corpus doctrine holds: the model proposes from a set you control, your server decides, your code owns every number.

## Failure modes worth a branch

| Failure | What you observe | Handling |
|---|---|---|
| Truncation mid-document | Output stops inside a string or an unclosed array; the parse throws, or a lenient parser returns a plan missing its last block | Check the termination reason before parsing. Treat a budget-exhausted stop as failure even if the fragment parses, and shrink the request rather than raising the budget |
| Fabricated enum value | Schema-invalid under strict decoding; under best-effort modes, a near-miss intent not in your list | Reject, do not coerce. A near-miss mapped onto your closest value is a silent mis-prescription |
| Identifier outside the candidate set | Schema-valid, but the lookup returns nothing — or a real exercise the user has no equipment for | Reject the item and backfill deterministically. A rising rate means your prompt is losing the candidates |
| Unit ambiguity | A load or duration arrives with no unit, or one inferred from locale | Never infer. Make the unit a required sibling enum, or drop the numeric field entirely — the better answer for load |

## Retry with the error, not with the same request

When validation fails, the useful retry carries the original request plus the specific violations as data: these three identifiers were not in the candidate set, this session exceeded the time budget by nine minutes. Models correct itemised errors far more reliably than they respond to being asked to try harder.

Bound it, because a repair loop is an unbounded cost loop wearing a helpful expression. Our default is one feedback retry, then a deterministic fallback: drop the offending items and backfill from the candidate set, or serve a vetted template. Two rules keep it honest: never widen the schema to make a bad response pass, and never re-feed the failed output as a draft to edit, because it preserves the parts you rejected. One retry is a judgement, not a constant; settle it with your own logs, and treat retry and fallback rates as quality metrics, which is [evaluating AI fitness features](/ai/evaluating-ai-fitness-features)' territory.

## Version the schema like an API, because it is one

Stored plans outlive the schema that produced them. Add a required intent value, rename a block type, and every plan in your database is written in a dialect your current reader does not speak. Carry a schema version on every generated plan and treat migrations as you would for any persisted format: readers accept the current version and its predecessors, writers emit one. Additive changes are cheap; removals and renames need a backfill or a shim.

Version the schema alongside the prompt, the catalogue snapshot and the model, and log all four per generation. A schema change and a catalogue edit can each move output quality with no code change, and without those stamps a regression is unattributable.

## When not to constrain

Constraining prose is the common overreach. Coaching copy, the rationale for a session, the explanation of why last week's load moved — that is language, and a rigid schema makes it shorter, blander and oddly shaped.

Our split is two calls with different contracts. The structured call returns identifiers and intent and is validated hard. The prose call receives the *already validated* plan and writes about it, with no ability to change a number. The ordering matters: prose generated before validation describes a plan that may not survive, and users notice when the copy and the sets disagree.

Two more cases. Open-ended intake, where you do not yet know what fields the answer has — classify first, structure second. And any escalation path, where a schema requiring a plan object makes "this user needs a clinician, not a programme" unrepresentable. Give the response a discriminated top level so refusal is a first-class variant, and see [LLM safety for fitness advice](/ai/llm-safety-fitness-advice) for what belongs on that branch.

## FAQ

### Why not have the model write the plan as text and parse it afterwards?

Because the format drifts and a wrong parse looks like a right one. Rep and rest notation changes between calls, parenthetical caveats appear, units switch, and your regex accumulates special cases that fail hardest on the users whose requests are unusual. The outcome you should fear is not an exception but a capture group that matched the wrong span and returned a plausible number nobody questions. Extracting structure with a second model call does not fix it either: you pay twice, and you get clean structure wrapped around whatever the first call invented.

[Permalink](https://aifitnessapi.com/ai/structured-output-for-workout-plans#faq-1)

### Constrained decoding already guarantees my schema. What is left to check on the server?

Everything that is not shape. Three categories in particular. Referential integrity: each returned identifier must exist in your catalogue and still be inside the candidate set you supplied for that request, since a schema-valid string can point at nothing. Numeric bounds: minimum, maximum and array-length constraints are widely unsupported at the decoding layer, and client libraries that accept them typically strip them from the wire schema and re-check after generation, so assume the bounds are yours to enforce. And cross-row invariants such as weekly sets per muscle group, duplicate movements inside one session, or estimated duration against a time budget, which no per-field constraint can see.

[Permalink](https://aifitnessapi.com/ai/structured-output-for-workout-plans#faq-2)

### Should the exercise identifier be a schema enum built from the candidate set?

In our judgement no, even though it would make an invented exercise impossible to emit. Constrained decoding compiles a grammar from your schema and caches that compilation against the schema's structure, so a per-user enum is a distinct structure for every distinct filter and every request pays cold compilation. Keep the identifier a plain string in one stable schema, list the candidates in the prompt, and enforce membership yourself: the guarantee is identical and only the enforcement point moves. Reserve enums for vocabularies that are the same in every request, such as block type or training intent. This inverts if your candidate sets ever collapse to a small number of fixed variants.

[Permalink](https://aifitnessapi.com/ai/structured-output-for-workout-plans#faq-3)

### What should happen when the response is cut off partway through the JSON?

Treat it as a failure even when the fragment happens to parse. Check the response's termination reason before you parse anything, because a budget-exhausted stop and a clean finish are indistinguishable once you are looking only at the text, and a lenient parser will hand you a plan that is silently missing its last block. The durable fix is to make the response smaller rather than to keep raising the output budget: generate one session per call instead of a week, drop the free-text rationale from the structured call, and shorten enum member names. Repeated truncation on the same shape is a sign the schema is doing work the prompt should be doing.

[Permalink](https://aifitnessapi.com/ai/structured-output-for-workout-plans#faq-4)

### How do I change the plan schema without breaking plans I have already stored?

Version it and migrate it like any other persisted format, because stored plans outlive the schema that produced them. Stamp a schema version on every generated plan, have readers accept the current version and its predecessors, and have writers emit only the current one. Additive changes such as a new optional field or a new enum member are cheap; removals and renames need a backfill or a compatibility shim. Stamp the prompt version, the catalogue snapshot and the model alongside it, since a schema edit and a catalogue edit can each move output quality with no application code change and a regression is unattributable without all four.

[Permalink](https://aifitnessapi.com/ai/structured-output-for-workout-plans#faq-5)
