Docs · Guide
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.
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.
npm install @anthropic-ai/sdk zod
export EB_KEY=eb_live_…
export ANTHROPIC_API_KEY=sk-ant-…
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 } : {}) }),
});
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) })));
},
});
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."; },
});
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));
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();
}
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.
GET /v1/exercises/:id/substitutes?equipment=…&excludeJoints=… returns ranked replacements instantly.nameFr, descriptionFr and cuesFr where available. Ask the model to write its notes in French and show those fields.X-End-User-Role: coach: coaches may open far more exercises a day.GET /v1/catalog once a day (it answers 304 until a new version ships) and point the search tool at your database.