/api/v1
Generate training plans, then read them, translate them and swap exercises in them. Each endpoint below lists what it accepts and what it returns.
Overview and a complete example requestAuthentication
Send your API key as a bearer token with every request. Limits apply per account, shared by all your keys (see Limits and bulk generation).
Authorization: Bearer tlab_live_...
Limits and bulk generation
Every limit below applies to your account as a whole. All your API keys share them, so creating more keys doesn't raise any limit.
Generations in progress
At most 3 generations per account run at the same time. A generate request sent while 3 are running gets 429 with code concurrency_limit and Retry-After: 10. It's rejected before any work starts and costs nothing.
Requests per hour
600 requests per account in any rolling hour, across every endpoint. Every request counts, including ones that end in an error, except those this limit rejects. Past it: 429 with code rate_limit, and Retry-After gives the seconds until the oldest request in the hour drops out.
Per plan
Each plan allows 20 swap-option requests and 5 translation attempts in any rolling hour. Past them: 429 with code plan_rate_limit and Retry-After.
Token balance
A paid request starts only if your balance, minus tokens held by your requests still in progress, covers its cost. Otherwise it gets 402 with code insufficient_tokens before any work starts. Tokens are taken only when the result passes validation; a request that fails releases its hold.
Our model capacity
When the plan engine is at capacity you get 503 with code capacity and Retry-After, and nothing is charged. A 503 with code unavailable and no Retry-After means generation is paused on our side, and retrying soon won't help.
Generating many plans
A plan takes 20 to 60 seconds. The fastest way through a list is a pool of 3 workers, each sending its next request as soon as its last one returns. That makes about 350 plans an hour. Sending the whole list at once is slower: most requests come back 429, and each one still counts toward the hourly limit.
- On 429 (concurrency_limit, rate_limit, plan_rate_limit) and 503 capacity, wait the Retry-After seconds, then send the same request again.
- Don't resend a 400, 402, 403, 404, 409 or 422 unchanged. The same request fails the same way.
- On 502 model_failed, retry once. A second failure usually points at the input, most often notes or injuries.
- Check that your balance covers the whole list before you start: 5 tokens per plan.
const API = "https://training-lab.app/api/v1/generate";
const KEY = process.env.TRAINING_LAB_KEY;
const WORKERS = 3;
const RETRY_CODES = new Set(["concurrency_limit", "rate_limit", "plan_rate_limit", "capacity"]);
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
// One plan. Waits out Retry-After on the codes worth retrying;
// anything else fails this input and moves on.
async function generate(input) {
for (;;) {
const res = await fetch(API, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (res.ok) return res.json();
const { code, error } = await res.json();
if (!RETRY_CODES.has(code)) throw new Error(`${code}: ${JSON.stringify(error)}`);
await sleep(Number(res.headers.get("Retry-After") ?? 10));
}
}
// WORKERS requests in flight at a time; each worker takes the next input
// as soon as its last request returns.
async function generateAll(inputs) {
const results = new Array(inputs.length);
let next = 0;
async function worker() {
while (next < inputs.length) {
const i = next++;
results[i] = await generate(inputs[i]).catch((err) => ({ failed: err.message }));
}
}
await Promise.all(Array.from({ length: WORKERS }, worker));
return results;
}Generate a plan
POST/api/v1/generate
Generates a complete one-week training plan. It costs 5 tokens, taken only after the response passes validation, so a failed generation costs nothing.
Request body
primary_goal / secondary_goal
loose_weightbuild_muscleimprove_enduranceimprove_strengthincrease_mobilityactivities
Up to 2. Any goal works with any activity. The one goal-driven limit: build_muscle or improve_strength caps running and hiit at 3 days a week.
gymrunninghiitswimmingtraining_at_homeuser_profile.experience
novicesome_experiencefew_yearsamateur_veteranprofessionaluser_profile.muscle_mass_level
lowregularfitmuscledextremely_muscularschedule.days_selected
2 to 7 days.
montuewedthufrisatsunpreferences.training_length
One value for every day, or an object with one value per selected activity, e.g. {"gym": "up_to_60_minutes", "running": "up_to_120_minutes"}.
up_to_40_minutesup_to_60_minutesup_to_90_minutesup_to_120_minutespreferences.gear_type
Optional. Only valid when "gym" is selected.
bodyweightfree_weightsmachinepreferences.gym_split
Optional, defaults to "auto". Only valid when "gym" is selected, and the split must fit the gym days in schedule.user_adjusted_split. It runs on its core cycle (full_body 1-4 days, upper_lower 2 or 4, push_pull_legs and arnold 3 or 6, upper_lower_ppl and bro_split 5), and up to 2 remaining gym days become mobility, conditioning or core sessions based on the goals.
autofull_bodyupper_lowerpush_pull_legsarnoldupper_lower_pplbro_splitpreferences.home_equipment
Optional. Only valid when "training_at_home" is selected, or "hiit" without "gym".
dumbbellkettlebellse-z curl barbandsmedicine ballexercise ballfoam rollwall barspull-up barotherpreferences.type_of_sets
supersetssingle_exercisescontrast_setspreferences.exercise_complexity
beginnerintermediateadvancedpreferences.rest_between_sets
Optional. Only valid when "gym" or "training_at_home" is selected. "as_needed" means rest by feel, with no fixed times.
as_needed30_seconds1_minute2_minutespreferences.rest_between_exercises
Optional. Only valid when "gym" or "training_at_home" is selected. "as_needed" means rest by feel, with no fixed times.
as_needed1_minute2_minutes3_minutesOther fields
user_profile
Also takes date_of_birth, sex, height_cm and weight_kg. date_of_birth must be a real YYYY-MM-DD date, and the person must be at least 18.
schedule.user_adjusted_split
How many of the selected days go to each activity. Keys must match activities exactly, and the values can add up to at most the number of selected days. Selected days left over are rest days and don't appear in the returned schedule.
injuries
An array of { area, occurred, current_impact }. It covers non-acute issues too, like posture-related pain.
notes
Optional, up to 500 characters. Free-form steering, for example "focus on more leg volume".
locale
Optional, defaults to "en". Any standard language code ("fr", "de", "ja") sets the language of the plan's name, summary, focus, notes, load guidance and warnings. Exercise names stay in English.
Response
The generated plan as a plan object (described below). Keep its id, which the plan endpoints below take.
Each exercise has an exercise_reference object (images, equipment, muscles, instructions) that the server adds after validation. The model doesn't write it, and it's null when the exercise isn't in our catalog. images are absolute URLs you can fetch directly.
{
"id": "…",
"name": "Push, pull, legs with easy runs",
"summary": "…",
"warnings": ["…"],
"weekly_schedule": [
{
"day": "mon",
"activity_type": "gym",
"focus": "Push: chest, shoulders, triceps",
"exercises": [{
"set_number": 1,
"exercises": [{
"name": "Barbell Bench Press - Medium Grip",
"sets": 4,
"target": { "type": "reps", "reps": "4 - 6" },
"load_guidance": "…",
"exercise_reference": {
"images": ["https://…/0.jpg", "…"],
"equipment": "barbell",
"primary_muscles": ["chest"],
"secondary_muscles": ["shoulders", "triceps"]
}
}]
}]
}
]
}Exercise fields
sets
How many sets, or rounds when exercises share a set_number group (they all carry the same value). Up to 5 with a reps target and up to 10 with a time or distance target, so intervals come back as their repeats, like 8 × 400 m.
target
What one set aims for. A reps target is { type, reps }, a time target is { type, min_seconds, max_seconds } and a distance target is { type, min_meters, max_meters }. Equal min and max mean a fixed value. Units are always seconds and meters, so converting them for display is up to you.
target.type
repstimedistancetarget.reps
1 - 34 - 67 - 910+The plan object
Every endpoint that returns a plan returns it in this shape. Its text is in one language at a time.
id
Pass it to the plan endpoints.
locale
The language of the text in this response.
original_locale
The language the plan was generated in.
available_locales
original_locale plus every stored translation. Pass one as locale to read the plan in it.
weekly_schedule
Training days only, each with its focus and set_number groups of exercises (fields above).
text_locale
On an exercise, only when its load_guidance and notes are in a different language than the rest of the response. That happens when a translation was added while a swap was in progress, and the swapped exercise then keeps its original text.
swap
batches_used and batch_allowance. Every plan starts with 3 free swap batches.
Get a plan
GET/api/v1/plans/{id}
Returns one of your plans. It's free and never calls the model.
Query parameters
locale
Optional. A language tag. When the plan has a translation into that language, the text comes back translated; otherwise it comes back in original_locale. locale in the response tells you which one you got.
The plan object.
Translate a plan
POST/api/v1/plans/{id}/translate
Translates the plan's text into another language and stores the translation with the plan. It costs 1 token, taken only after the translation passes validation. Asking for a language the plan already has returns that translation at no cost. A plan can hold up to 5 translations. Exercise names stay in English, and catalog instructions come in that language where we have our own translation, otherwise in English.
Request body
locale
A language tag other than the plan's own, such as "de" or "pt-BR".
{ charged, plan }: charged is true when the token was taken, and plan is the plan object in the new language.
Get swap options
POST/api/v1/plans/{id}/swap
Proposes alternatives for 1 to 5 exercises of a plan in one request. Every option has already passed the plan's equipment and injury checks. It's free and nothing changes until you apply. Each plan allows 20 of these requests per hour.
Request body
locations
1 to 5 positions in weekly_schedule, each { day_index, set_group_index, exercise_index }, counted from 0.
results (one per location, in order, each with its location and options) and free_batches_remaining.
Option fields
options
Up to 5 per location. An empty list means none passed the checks.
name, sets, target, load_guidance, notes, exercise_reference
The same as in a plan.
translations
The option's load_guidance and notes in each of the plan's stored translations, keyed by language tag.
injury_warning
true when the exercise works a body area near one of the plan's stated injuries. Applying it needs acknowledged: true.
Apply swaps
POST/api/v1/plans/{id}/swap/apply
Replaces each exercise with the option you chose, as one batch: either every choice applies or none does. A plan's first batches are free (see swap.batch_allowance). After that, each batch costs 1 token and needs refill_confirmed: true. Without it the request returns 402 with batch_count, so you can confirm with your user first. Every choice is checked again against the plan's equipment and injuries before anything changes.
Request body
choices
1 to 5 items of { location, option, acknowledged }. option is one of the swap options, sent back unchanged with its translations, so the plan's stored translations get the new text too.
choices[].acknowledged
Must be true for an option with injury_warning.
refill_confirmed
Optional, defaults to false. Allows the charge when the plan has no free batch left.
{ charged, plan }: charged is true when a token was taken, and plan is the plan object in its original language.
Error codes
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | invalid_requestThe request body, plan id or locale failed validation, or the translation target is the plan's own language. |
| 401 | unauthorized | unauthorizedThe API key is missing, malformed or revoked. |
| 402 | insufficient_tokens | insufficient_tokensNot enough tokens, counting tokens held by your requests still in progress. |
| 402 | refill_required | refill_requiredSwap apply: no free batch is left. The body has batch_count; resend with refill_confirmed: true. |
| 403 | email_not_verified | email_not_verifiedThe account email isn't verified (generate). |
| 404 | not_found | not_foundThe plan doesn't exist or belongs to another account, or a location points outside the plan. |
| 409 | translation_limit | translation_limitTranslate: the plan already has 5 translations. |
| 409 | acknowledgment_required | acknowledgment_requiredSwap apply: an option with injury_warning lacks acknowledged: true. |
| 409 | plan_changed | plan_changedSwap apply: the plan changed while the swap was applied. Fetch it and try again. |
| 422 | model_refused | model_refusedThe model declined the request. |
| 422 | swap_invalid | swap_invalidSwap apply: an option doesn't fit the plan's equipment, injuries or set count. |
| 429 | concurrency_limit | concurrency_limit3 generations are already running for this account. Retry after Retry-After seconds. |
| 429 | rate_limit | rate_limit600 requests in the last hour for this account. Retry after Retry-After seconds. |
| 429 | plan_rate_limit | plan_rate_limitThe per-plan limit for swap options or translation attempts. Retry after Retry-After seconds. |
| 502 | model_failed | model_failedGeneration, translation or swap options failed after a retry. Nothing was charged. |
| 503 | capacity | capacityThe plan engine is at capacity. Retry after Retry-After seconds; nothing was charged. |
| 503 | unavailable | unavailableGeneration is paused on our side. No Retry-After; retrying soon won't help. |