Docs · Guide

Build an AI workout generator on real exercises

Claude plans the session. The catalogue answers every factual question: which exercises exist, what gear they need, which joints they load, what can replace them. The result is a workout where every item has a video. About 150 lines of TypeScript.

Why not just ask the model?

A language model asked for "a 35-minute dumbbell workout, easy on the knee" writes something plausible. Then the trouble starts in your app: the exercise names do not match anything you can show, "easy on the knee" was a guess, and when the user says they have no bench there is nothing to swap with. The model is good at planning and bad at being a database.

So split the work. The model plans; a catalogue with hand-checked fields answers. Here the catalogue is the ExerciseBank API: 1,492 filmed exercises with muscles, equipment, movement pattern, difficulty 1–5, loaded joints and impact on every record, and a substitute map that says which exercise can replace which, with a score and a reason.

What you need

npm install @anthropic-ai/sdk zod
export EB_KEY=eb_live_…
export ANTHROPIC_API_KEY=sk-ant-…

1. A small client for the catalogue

Four calls. Note two things the API asks of you: the key stays on your server, and media is only issued for a named person (X-End-User, any stable opaque id; hash your own user id).

async function call<T>(path: string, init: RequestInit = {}): Promise<T> {
  const res = await fetch(`${BASE}${path}`, { ...init, headers: { authorization: `Bearer ${KEY}`, ...init.headers } });
  const body = await res.json();
  if (!res.ok) throw new Error(`ExerciseBank ${res.status} ${body?.error?.code}: ${body?.error?.message}`);
  return body as T;
}
const query = (params: Record<string, string | number | boolean | string[] | undefined>) =>
  new URLSearchParams(Object.entries(params).filter(([, v]) => v !== undefined && !(Array.isArray(v) && !v.length)).map(([k, v]) => [k, Array.isArray(v) ? v.join(",") : String(v)])).toString();

/** Search and filter. `equipment` is the gear the person HAS; `excludeJoints` skips anything that loads them. */
export const search = (params: { q?: string; muscle?: string[]; equipment?: string[]; pattern?: string[]; maxDifficulty?: number; minDifficulty?: number; excludeJoints?: string[]; impact?: string[]; ids?: string[]; limit?: number }) =>
  call<{ data: Exercise[]; total: number }>(`/exercises?${query(params)}`);

/** Ranked replacements for one exercise, each with a score and a reason. */
export const substitutes = (id: string, params: { equipment?: string[]; excludeJoints?: string[]; limit?: number } = {}) =>
  call<{ data: Substitute[] }>(`/exercises/${encodeURIComponent(id)}/substitutes?${query(params)}`);

/** The catalogue's vocabulary (muscles, equipment, movement patterns, joints) and the sandbox sample ids. */
export const meta = () => call<{ enums: Record<string, (string | number)[]>; sample: string[]; version: string }>("/meta");

/**
 * Signed, short-lived video and still URLs for the exercises a screen shows, for one named person.
 * `endUser` is any stable opaque id (hash your own user id). Store exercise ids, never these URLs.
 */
export const mediaUrls = (endUser: string, ids: string[], opts: { coach?: boolean; kinds?: ("mp4" | "still")[] } = {}) =>
  call<{ data: Record<string, Media> }>("/media/urls", {
    method: "POST",
    headers: { "content-type": "application/json", "x-end-user": endUser, ...(opts.coach ? { "x-end-user-role": "coach" } : {}) },
    body: JSON.stringify({ ids, ...(opts.kinds ? { kinds: opts.kinds } : {}) }),
  });

2. Give Claude the catalogue as tools

Two tools let the model look things up. The descriptions carry the two rules that matter: equipment is the gear the person has, and excludeJoints removes anything that loads a joint. Results are trimmed to the fields the model reasons over, which keeps the conversation small.

const searchExercises = betaZodTool({
  name: "search_exercises",
  description: "Search the exercise catalogue. Every filter is optional. `equipment` is the gear the person HAS (bodyweight is always allowed); `excludeJoints` removes anything that loads those joints. Returns up to `limit` exercises with their classification.",
  inputSchema: z.object({
    q: z.string().optional().describe("Free text against exercise names and aliases"),
    muscle: z.array(z.string()).optional().describe("Primary or secondary muscles"),
    equipment: z.array(z.string()).optional(),
    pattern: z.array(z.string()).optional().describe("Movement patterns, e.g. squat, hinge, push, pull"),
    maxDifficulty: z.number().int().min(1).max(5).optional(),
    excludeJoints: z.array(z.string()).optional(),
    impact: z.array(z.enum(["low", "moderate", "high"])).optional(),
    limit: z.number().int().min(1).max(25).optional(),
  }),
  run: async (input) => {
    const { data, total } = await eb.search({ ...input, limit: input.limit ?? 12 });
    return JSON.stringify({ total, exercises: data.map(slim) });
  },
});

const getSubstitutes = betaZodTool({
  name: "get_substitutes",
  description: "Ranked replacements for one exercise, each with a score (0-1) and the reason it fits. Use it when an exercise is right for the plan but wrong for this person's gear or joints.",
  inputSchema: z.object({ id: z.string(), equipment: z.array(z.string()).optional(), excludeJoints: z.array(z.string()).optional() }),
  run: async ({ id, ...filters }) => {
    const { data } = await eb.substitutes(id, { ...filters, limit: 6 });
    return JSON.stringify(data.map((s) => ({ score: s.score, reason: s.reason, exercise: slim(s.exercise) })));
  },
});

3. Take the plan back as data, not prose

The third tool is how the workout comes back: a schema, validated by the SDK before your code sees it. Every item is an exercise id the tools returned, so nothing on the screen can be invented.

const Plan = z.object({
  title: z.string(),
  summary: z.string().describe("Two sentences for the person: what this session does and why it suits them"),
  blocks: z.array(z.object({
    name: z.string().describe("Warm-up, Main, Finisher, Cool-down…"),
    items: z.array(z.object({
      exerciseId: z.string().describe("An id returned by search_exercises or get_substitutes. Never invent one."),
      sets: z.number().int().min(1),
      reps: z.string().describe('"10", "8 per side", "40 s"'),
      restSeconds: z.number().int().min(0),
      note: z.string().optional().describe("Why this exercise, or what to watch, in one line"),
    })),
  })),
});
let plan: z.infer<typeof Plan> | undefined;

const submitPlan = betaZodTool({
  name: "submit_plan",
  description: "Hand in the finished workout. Call it exactly once, last.",
  inputSchema: Plan,
  run: async (input) => { plan = input; return "Plan received."; },
});

4. Run the loop

The SDK's tool runner calls the model, runs the tools it asks for, feeds the results back, and stops when the model is done. The system prompt includes the catalogue's own vocabulary (from GET /v1/meta), so the model filters with values that exist.

const client = new Anthropic(); // ANTHROPIC_API_KEY, or an `ant auth login` profile

const final = await client.beta.messages.toolRunner({
  model: "claude-opus-5",
  max_tokens: 16000,
  betas: ["server-side-fallback-2026-07-01"],
  fallbacks: "default", // if the model declines a request, the API retries it on a fallback model in the same call
  system: [
    "You are a strength and conditioning coach building one workout from a catalogue of filmed exercises.",
    "Only exercises the tools return exist for you; the person will watch a video of each, so an invented exercise is a broken screen.",
    "Respect the person's equipment and anything they say hurts: pass their gear as `equipment` and the joints to protect as `excludeJoints`, and use get_substitutes when a good exercise does not fit them.",
    "Balance the session across movement patterns, order it sensibly, and match difficulty to their level. When the plan is complete, call submit_plan.",
    `Catalogue vocabulary. Muscles: ${enums.muscles?.join(", ")}. Equipment: ${enums.equipment?.join(", ")}. Movement patterns: ${enums.movementPatterns?.join(", ")}. Joints: ${enums.joints?.join(", ")}.`,
  ].join("\n"),
  tools: [searchExercises, getSubstitutes, submitPlan],
  messages: [{ role: "user", content: request }],
});

if (final.stop_reason === "refusal") throw new Error("The model declined this request.");
if (!plan) throw new Error("No plan was submitted. Last message: " + JSON.stringify(final.content));

5. Attach the videos

One call for the whole screen, for the person who will watch. The URLs are ordinary MP4 links, signed and short-lived: play them, do not store them. Store the exercise ids and ask again when the screen opens.

// The videos, for the person who will watch. One call for the whole screen.
const ids = [...new Set(plan.blocks.flatMap((b) => b.items.map((i) => i.exerciseId)))];
const [{ data: media }, { data: details }] = await Promise.all([eb.mediaUrls(endUser, ids), eb.search({ ids, limit: 100 })]);
const nameOf = new Map(details.map((e) => [e.id, e.name]));

console.log(`\n${plan.title}\n${plan.summary}\n`);
for (const block of plan.blocks) {
  console.log(block.name.toUpperCase());
  for (const item of block.items) {
    const m = media[item.exerciseId];
    console.log(`  ${nameOf.get(item.exerciseId) ?? item.exerciseId}: ${item.sets} × ${item.reps}, rest ${item.restSeconds}s${item.note ? `  (${item.note})` : ""}`);
    console.log(`    ${m?.mp4 ?? (m?.sandbox ? "video: outside the sandbox sample; a paid key returns it" : "no clip")}`);
  }
  console.log();
}

What comes out

The plan prints block by block, each exercise with its sets and its clip. The shape of the output (the exercises chosen vary with the request):

$ node generate.ts "35 minutes, dumbbells and a bench at home, sore left knee, intermediate, glutes and back"

Glutes and back, knee-friendly
A hinge-and-pull session that keeps load off the knee: …

WARM-UP
  Glute bridge: 2 × 12, rest 30s
    https://…/m/9f2c…e1.mp4?token=…
MAIN
  …

On a sandbox key, exercises outside the sample print without a link; a paid key returns all of them with no change to the code.

Where to take it

Get a free sandbox key API reference