# AGENTS.md — Hyperhuman Content API

> Last updated: 2026-09-11. Spec version: see `info.version` in `/openapi.json`. Sections 11–12 document runtime behavior and repository conventions for maintainers.

This file orients coding agents (Claude, Cursor, Codex, Copilot, ChatGPT, etc.) to the Hyperhuman Content API — the fitness content infrastructure behind modern health, wellness, and fitness products. The API exposes five capability layers on a shared library: publish content, AI recommend, AI generate, AI adapt, and AI insights (reads **and** org-scoped health-data / nutrition writes that feed them). Follow the conventions below verbatim — they reflect how the live service actually behaves, not aspirational targets.

If you only have room for one artifact in your context window, prefer:

1. [`/openapi.json`](https://content.api.hyperhuman.cc/openapi.json) — **contract** for the **documented, productized** Content API surface (supported operations, parameters, and schemas). The same process may host additional routes; do not assume a path exists until it appears here.
2. [`/llms-full.txt`](https://content.api.hyperhuman.cc/llms-full.txt) — text-friendly digest of (1)

## 1. Base URL & versioning

- Production base URL: `https://content.api.hyperhuman.cc`
- **Member library embeds** (branded workouts/programs grids) use the **member web app** host (`https://member.hyperhuman.cc`, typically `https://member.hyperhuman.cc`), with paths `/workouts` and `/plans` and an `orgId` query param — not `https://content.api.hyperhuman.cc`. Never substitute the Content API base URL for those pages.
- All public endpoints are URI-versioned and live under `/v1/...`
- The OpenAPI document advertises one server per environment via `servers[]`
- `operationId`s in the spec follow the stable shape `Controller_method` (e.g. `WorkoutsApi_getWorkoutById`). Use them as function/tool names when generating SDKs.

## 2. Authentication

Every request requires the API key header:

```
X-Api-Key: <organization_api_key>
```

- Missing, malformed, or unknown key  →  `401 Unauthorized`
- Valid key but the key's organization does not own the requested resource  →  `403 Forbidden`
- A few public endpoints (health, docs, llms.txt, openapi.json) are unauthenticated by design

Some end-user-scoped endpoints (`/me/...`) additionally accept a JWT `Authorization: Bearer <token>` header in place of, or in addition to, the API key. The OpenAPI spec marks these explicitly with `bearer` security.

## 3. Error envelope (always JSON)

Every non-2xx response — including those for endpoints that normally return `text/plain` (stream URL endpoints) — is JSON with an `error` object.

**Request validation** (invalid or unknown query/body fields, format constraints) returns **`400 Bad Request`**. For `class-validator` failures, the payload typically uses `error.code` **`ValidationError`**, with `error.target` set to the DTO class name and `error.details` listing each field issue:

```json
{
  "error": {
    "code": "ValidationError",
    "message": "ValidationError",
    "target": "GetPlansByOrganizationIdQuery",
    "details": [
      {
        "code": "ConstraintError",
        "message": "property extraParam should not exist",
        "target": "extraParam"
      }
    ]
  }
}
```

**Other errors** (for example `401`, `403`, `404`, `500`) use a top-level `error.code` from the stable set below. `error.message` text may change between releases.

Stable codes: `BadRequest`, `Unauthorized`, `Forbidden`, `NotFound`, `MethodNotAllowed`, `RequestTimeout`, `Conflict`, `Gone`, `PayloadTooLarge`, `UnsupportedMediaType`, `UnprocessableEntity`, `InternalServerError`, `NotImplemented`, `BadGateway`, `ServiceUnavailable`, `GatewayTimeout`. Validation responses use HTTP **400** and often include `error.code` **`ValidationError`** (see above), which is not the same as HTTP 422.

## 4. Pagination

Paginated list endpoints accept:

- `offset` (integer, default `0`) — **runtime-accepted** on shared `PageQuery` list DTOs
- `limit` (integer, default `20` in many **OpenAPI** schemas, **max `50`** for most list endpoints)
- **Exception:** `GET /v1/orgs/{organizationId}/endusers` allows **`limit` up to `100`**.
- **Exception:** chat list/messages use **`page` / `limit`** (not `offset` / `links.next`): `GET /v1/chat/conversations` (`limit` default 20, max 100) and `GET /v1/chat/conversations/{conversationId}/messages` (`limit` default 50, max 100). Both also require `organizationId` plus `userEmail` **or** `userExternalUUID`.
- **Library list defaults when `limit` is omitted:** `GET /v1/orgs/{organizationId}/workouts` and `GET /v1/orgs/{organizationId}/plans` use **`limit: 20`**. `GET /v1/orgs/{organizationId}/video-assets` and `GET /v1/orgs/{organizationId}/groups` use **`limit: 10`**. **Pass `limit` explicitly** when you need a different page size. Values above the operation max return **400** `ValidationError`. Always follow the operation in [`/openapi.json`](https://content.api.hyperhuman.cc/openapi.json) for the exact DTO.

**Prefer `links.next`:** Advance pages by following `links.next` from the previous response. Do **not** invent `?page=`. Shared `PageQuery.offset` is intentionally **omitted from OpenAPI** (no `@ApiPropertyOptional`) so Try-it-out steers clients to `links.next`; the server still accepts `offset` when you construct URLs yourself (e.g. from `links.next` or the cheat sheet).

Responses use the canonical envelope:

```json
{
  "data": [ ... ],
  "links": {
    "next": "https://content.api.hyperhuman.cc/v1/orgs/<id>/workouts?offset=20&limit=20",
    "total": 142
  }
}
```

- `links.next` is omitted on the last page
- `links.total` is the canonical total count
- A handful of legacy endpoints still expose a top-level `total` field marked `deprecated: true`. Do not read from it in new code.
- `page` is **not** a valid query parameter. Generators that emit `?page=N` will silently get the first page back.

## 5. Localization

- Pass `locale` as a BCP-47 string (e.g. `en-US`, `fr-FR`, `de-DE`). When omitted, the API uses `en-US`.
- Supported locales: `en-US`, `en-GB`, `en-AU`, `fr-FR`, `de-DE`, `es-ES`, `it-IT`, `pt-PT`, `he-IL`, `ro-RO`, `cs-CZ`, `fi-FI`, `lt-LT`, `nl-NL`, `pl-PL`, `ar-XA`, `nb-NO`, `sv-SE`.
- Norwegian: stored code is `nb-NO`. Read-side chains alias `no` / `no-NO` to `nb-NO`. AI generate `validateLocale` rejects `no` (`400`). Nynorsk (`nn`) is unsupported.
- Unsupported locales fall back to English on metadata/playlist endpoints, and return `404` on pre-rendered `/export/...` endpoints.
- **AI generate endpoints** (`POST /v1/orgs/{organizationId}/workouts/generate`, `POST /v1/orgs/{organizationId}/plans/generate`, and `POST /v1/me/plans/generate`): success is **HTTP 200** (`@HttpCode(OK)`), not 201. Optional body `locale`. Omit for English-only output (`nameTranslations` / `descriptionTranslations` = `{ "en-US": "..." }`). Non-English locales add AI-authored translation maps plus additive `*Localized` fields and `difficultyLabel`. **Invalid locale → `400 BadRequest`** (`Unsupported locale: ...`) — not silent fallback. Legacy `name` / `description` / `muscleGroups` / `equipment` on the generate response stay English; use the maps and `*Localized` fields for localized UI. **Plan generate persistence:** top-level plan `id` is returned **only** when `endUserProfileDetails.endUserId` is set; without it there is no plan id (cannot `GET /v1/plans/{id}`). When an `id` is returned, translation maps are **persisted** — `GET /v1/plans/{id}?locale=...` resolves top-level `name` / `description` (unlike the generate response, where those fields stay English base). `userRequirements` language does not affect output — `locale` does. Plan generate sources field is `workoutCollectionSources` (not `workoutSources`).
- **`GET /v1/orgs/{organizationId}/video-assets`** localizes `name`, `muscleGroups[].name`, `equipment[].name`, and **`audioInstructions[]`** (instruction audios; only emitted for `kind: single-exercise`) when `locale` is supplied (English fallback). When `locale` is omitted, `audioInstructions[]` returns all available locales. Each `audioInstructions[]` entry carries `id`, `createdAt`, `assetUri` (presigned MP4A), `type`, `locale`, and **`scriptText`** — the verbatim narrator transcript (optional; omitted when not stored on the underlying `Audio` document, e.g. legacy or stock recordings). Use `scriptText` for captions / accessibility / search; do not assume it is present. Full per-asset audio (cues + instructions) is exposed on the per-asset detail route when listed in `/openapi.json`. Filter surface (all DB-level, AND-combined, multi-value `$in`): `q` (case-insensitive **substring** on `name`; locale-aware when `?locale=` is set — also matches `nameTranslations.<locale>`; **not** the same fuzzy engine as org workout/plan lists), `equipmentIds` / `muscleGroupIds` (CSV of 24-hex ids), `kinds` / `skillLevels` / `executionSides` (CSV of enum values), `coach` (single value), `collectionNames` (CSV of exact strings). Sort: `+`/`-` prefix on `date` (alias of `createdAt`) / `name` / `kind`; default `-date`. `updated` (`updatedAt`) is intentionally NOT in the sort allowlist — `Exercise` schema does not track that field today; reintroduce only after a schema migration. Filters always run pre-pagination so `links.total` stays exact. Bodyweight UX-expansion behavior from internal-api (auto-include Mat-only / equipment-empty rows when `Bodyweight` is requested) is intentionally NOT ported to this public route — consumers requesting a specific equipment id should get exactly that. **`GET /v1/orgs/{organizationId}/workouts`** and **`.../plans`** use **fuzzy** `q` instead (relevance order; `sort` ignored while `q` is set; `links.total` post-search) — see [`docs/LIBRARY_SEARCH.md`](../../docs/LIBRARY_SEARCH.md) in the backend repo.

## 6. ID conventions

- Public IDs are exposed as `id`, `organizationId`, `ownerId`. Prefer these.
- Some legacy DTOs still expose `_id`, `_teamId`, `_ownerId` marked `deprecated: true` — they hold the same value as the public field for now and will be removed in the next major release. Do not read from them in new code.
- All resource ids are 24-character hexadecimal strings (DB ids). Never inspect, parse, or mutate them — round-trip them as opaque strings.
- **Org end-user path segment** `endUserId` in routes like `GET /v1/orgs/{organizationId}/endusers/{endUserId}/...` (plans, insights) is resolved by the server: it may be a **24-character hex user id**, an **external id** string stored for the org, or a **user email** (case-insensitive match). Public API responses still use opaque ids. Prefer the hex id in production integrations; putting an email in the path can surface in access logs and proxies.

## 7. Rate limits

Every response carries:

```
X-RateLimit-Limit:     <max weighted units per window for this key>
X-RateLimit-Remaining: <units left in window>
X-RateLimit-Reset:     <unix timestamp when the window resets>
```

- Caps are **plan-based**. While the team Stripe subscription is **`trialing`**, hourly and daily caps are **1/100** of the paid entitlement (minimum 1). Read `X-RateLimit-Limit` — do not assume a sample like `1000`.
- AI / heavy-video endpoints (workout/plan generate, recommend, adapt, insights, chat **writes**, video generation, full video export) **and** `GET /v1/orgs/{organizationId}/stock-exercises` count as **10x** API calls.
- Weight is consumed when the request is admitted at the auth middleware, including most **400** and **403** responses. **Exception:** a plan-gated `GET .../stock-exercises` **403** (Platform/trial/Content/Ultra monthly, no catalog flag) does **not** consume units.
- Chat **list** (`GET /v1/chat/conversations`) is **1x**.
- The **health-data batch push** (`POST .../endusers/{endUserId}/insights/health-data`) and the direct **nutrition log** write endpoints count as **1x** per request regardless of payload size — the batch is capped at **50 entries** per request instead.
- On `429 Too Many Requests`, honor **`Retry-After`** when present, then `X-RateLimit-Reset`. Do not implement immediate retry loops.

## 8. Generating tool/function definitions

Drive code generation from `/openapi.json`. Recommended:

- Use `operationId` (`Controller_method`) as the tool name.
- Use `summary` as the tool description, falling back to `description` if missing.
- Map `parameters[]` (path/query/header) to typed inputs.
- Map `requestBody` JSON schema 1:1 to the input schema.
- Map the `2xx` response schema to the output schema.
- Honor `deprecated: true` on operations and parameters — do not surface them in code completions.

## 9. Common pitfalls (do not invent these)

- Authentication is **header-only** (`X-Api-Key`). There is no `?api_key=` query fallback. Do not generate URLs that include the key.
- Pagination uses `offset`/`limit`, not `page`/`per_page`, except chat list/messages (`GET /v1/chat/conversations` and `GET .../conversations/{conversationId}/messages`), which use `page`/`limit`. Chat also requires `organizationId` plus `userEmail` **or** `userExternalUUID` (bare GET → 400). `GET .../groups` `limit` max is **50** (not 100).
- **Trial + stock catalog:** Stripe `trialing` caps are 1/100 of paid. `GET .../stock-exercises` is not on Platform/trial/Ultra monthly; the plan-gated **403 does not consume** quota. Content-only keys are **401** on all routes. Do not invent a 1000/hour global table. On 429 honor `Retry-After`.
- **Generate/recommend/adapt status:** success is **200**, not 201. 201 is for creates (sessions, invite, chat start, autopilot / social-publish). Health-data **push** is also 200 (per-entry results); nutrition **log** is 201.
- A few endpoints return `200 { "data": null }` instead of `404` to indicate "no resource yet" (e.g. `GET .../endusers/{endUserId}/plans/active/progress` when there is no active plan). Code paths must accept `null`.
- Workout `/playlist` returns `{ data: WorkoutSegmentByKind[], presentationStyle?, links? }` — an **array of single-key objects** keyed by segment type (`intro`, `exercise`, `break`, `outro`, `class`, …), not a flat `instances` array and not the generate/JSON-receiver shape.
- **Clip file ≠ playlist `duration` for `kind: exercise`.** `duration` is the planned work window (time-based instance duration, or `reps × estimatedRepDuration`). The MP4 is a short looping demo (often ~3–40s, e.g. 27s file in a 60s slot). When `main.video.loop` is `-1` (`playsInLoop` true, the default), keep looping until that clock elapses. `class` / `multiExercise` / `promo` / `educational` `duration` equals the file length. Do not treat a shorter file as a bug or as the segment timer.
- **AI workout generate** returns ephemeral JSON (`name`, `instances[]` with wire `kind` `single-exercise`|`rest`, `totalDurationSeconds`, …) with **no top-level persisted workout `id`**. `durationSeconds` are **seconds**. Play via the JSON-receiver **iframe** on `team.hyperhuman.cc` (`/embed/workout-json-receiver` + `HYPERHUMAN_EMBED_INIT` with the generate payload) or a custom player; do **not** call `/playlist`, `/music`, or sessions/feedback for that response. The JSON-receiver waits for a **non-empty** `apiKey` (INIT or legacy query) before resolving media via `GET /v1/video-asset/{exerciseId}` (instruction clips capped at 3; some assets have none; each GET is **1x** on that key). Do not start media fetches with an empty key. Always load the receiver **in an iframe** (not as a top-level page). **Pre-built** `/embed/workout/{id}` supports anonymous/attributed sessions and in-player feedback; its `GET /workouts/{id}` + `/playlist` (+ `/music`) and sessions use the URL/INIT key as `X-Api-Key` (quota). **Recommend** returns library workouts with real ids.
- **AI plan generate** returns a top-level plan `id` **only** with `endUserProfileDetails.endUserId`. Optional `categoryIds` hard-filters the workout pool. `workoutCollectionSources` defaults to **team + template** on this surface; fewer than **3 eligible workouts** across the selected sources → **`400`** naming the resolved sources (fix: widen `workoutCollectionSources` or add workouts — not a retry-able error). **AI plan adapt** requires `endUserProfileId` (omit → `400`); body sources field is `workoutSources` (not `workoutCollectionSources`).
- **AI workout generate sources:** `exerciseSources` is the final catalog. Team-only / premium-only / team+premium with **0** matching clips → **`400`** (`Not enough team exercises…` / `Not enough exercises in the selected sources…`) — not 200 with free-stock clips, not a template swap. Omit or empty `exerciseSources` still defaults to **free + premium**. Collection include/exclude floor ≥ **10** still **400**s first. Same copy as workout adapt. Do not retry; add a source or publish team exercises.
- **Generate vs adapt free-text:** workout/plan **generate** body field is `userRequirements`. Workout/plan **adapt** is `userGuidance`. Sending the other name → **`400`** `property … should not exist`. Do not invent aliases.
- **Programming DNA / Generation Inspector:** `GET /v1/orgs/{organizationId}/methodologies` lists **team** profiles only (id, name, applicability, `isTeamDefault`, `ruleSummary` — not full guidance text). A workspace can publish **up to 10** team profiles. Empty `{ items: [] }` is a valid **200** (no published team profiles — coaches use **Set as team guideline** then **Make default** in Teams). **This API:** generate applies a profile **only** when the body includes `methodologyProfileId` (24-character hex from that list). Omit = no team methodology (`isTeamDefault` is informational). Unknown, personal (non-team), or inactive ids return **400**. Omit still means none. Recommend, adapt, and chat do **not** accept this field. **Hyperhuman Club and the Hyperhuman member web app:** when a team default exists, member Generate and Adapt follow that house Programming DNA; a personal Style does not reach members; members do not send `methodologyProfileId`. `userRequirements` wins over methodology **text** for that request; hard structured rules and platform bounds (exercise/workout pool, safety, duration window, structure) do not. Workout generate also has `POST .../workouts/generate/preflight` (same body, no workout created, **1x** — feasibility, not the Inspector snapshot). Plan generate has no preflight. Generate responses may include a **Generation Inspector** snapshot: `methodology` (`{ id, name, version, reason }` or `null`) and `inspection` (`methodology`, `hardRulesApplied`, `violations`, `complianceStatus` of `compliant` | `partial` | `not_applicable`, `generationId`). Store that snapshot yourself — there is **no** `GET /v1/generations/{id}`, and later `GET /v1/plans/{id}` does not replay it. Hard-rule results in `inspection` are deterministic; text guidelines are guidance, not a guarantee. Do not invent a public `defaultMethodologyProfileId` field or Teams-internal Inspector aliases as Content API keys.
- **Generate/adapt HTTP timeout:** keep the client open at least **120 seconds**. A client abort can still finish as `200` server-side — do not treat abort as a 500 and do not immediately retry the same request.
- **Workspace catalog vs stock templates:** `GET /v1/orgs/{organizationId}/workouts` and `.../plans` list **this org’s published workspace items only**. Empty catalog → **200** `{ data: [], links: { total: 0 } }` (not 404). There is **no** unscoped `GET /v1/workouts` list — that path is **404**. Pre-made stock workouts/programs are added in the Teams app (Workouts / Programs → Add from stock); there is **no** public Content API to list or clone that global template catalog. Do not invent `GET .../stock-workouts`, `GET /v1/orgs/{organizationId}/library`, `GET /v1/workouts/categories`, or `GET /v1/categories`. Categories and difficulties are `GET /v1/workouts/metadata`. A non-hex `GET /v1/workouts/{workoutId}` segment (for example `categories`) returns **400** `ValidationError`, not metadata. `GET .../stock-exercises` is stock **clips** (Ultra yearly or custom), not workouts. Workout generate does **not** insert a library row — do not play generate JSON via `/playlist`, and do not mint local hex ids to poll playlist. Recommend on an empty workspace returns **200** with an empty `workouts` / `plans` array.
- **Library rotation:** recommend pool is **published + public**; plan-generate pool is **public** via `buildWorkoutSourceQuery` (team: not deleted/locked; template: public) — not the same `status: published` pin as recommend. Optional `categoryIds` = `$in` on `_workoutCategoryIds` (hex ids only; not list `custom:` tokens). Empty generate pool or `categoryIds` yielding &lt; 3 workouts → **`400`** (not `500`; no template fallback). Set workout `visibility` to `private` to exclude from new pools; `GET /v1/workouts/{id}` still returns the workout. Workout **tags** are library filter/search only — they do **not** filter recommend/generate pools. Do **not** invent Unlisted as a third visibility value.
- **Org metadata `ai*Enabled` / `insightsEnabled`** are **member-app visibility** flags. They do **not** alone gate Content API recommend/generate/adapt/chat/insights — those routes use separate API capability asserts.
- **`GET .../music`** requires a persisted workout id. The public JSON-receiver embed does not fetch music; `mixBgMusic` / `videoAudioMode` on a posted JSON payload are ignored.
- Class exercises emit `{ class: { kind: 'class', ... } }` (not `multiExercise`). Optional `class.chapters[]` provides timestamped exercise blocks within `main.video` when chapter analysis has been run; when omitted, treat the class as one continuous segment. Chapter names and nested catalog labels are stored at analysis time and are **not** re-localized by `?locale=` (segment-level `name`, `equipment`, and `muscleGroups` still follow locale).
- `videoAudioMode` (workout-level) governs which audio tracks the player must mix. Do not assume "always full".
- **Embedded player iframe:** always frame on `https://team.hyperhuman.cc`. Prefer `allow="autoplay; fullscreen; accelerometer; gyroscope"` + `allowfullscreen`. Mobile/portrait hosts should use a **full-viewport** container (`100vh`/`100dvh`), not a short 16:9 card. Optional host messages: `HYPERHUMAN_HOST_IMMERSIVE` (host → player), `HYPERHUMAN_ORIENTATION_TOGGLE` (player → host for `screen.orientation.lock`). PulseMix music-off **pauses** BGM (`pause` + `muted`); never document volume-only mute — iOS ignores `HTMLMediaElement.volume`. **Usage:** the URL or `HYPERHUMAN_EMBED_INIT` `apiKey` is the Content API key sent as `X-Api-Key` — those GETs (and pre-built sessions) count against that key. There is no separate embed-play unit. Details: overview **Embedded Player**, `custom-player-guide.md`.
- `GET .../export/video/stream_url` and `GET .../export/audio/stream_url` return **`200`** with **`Content-Type: text/plain`** — the body is a **single** AWS S3 **presigned** URL string (not JSON). The linked file is the stored full-workout export for that locale (**progressive MP4** for video, **M4A** for narrative audio typically); format follows the actual object (extension and `Content-Type` of the response when fetching the URL). Not HLS/DASH/ABR. URLs expire on the order of **~7 days** — re-fetch; do not treat as durable. **Locale rules differ:** video omit → latest any locale, provide → exact or **404**; audio omit → trainer preferred locale with same-language family fallback, provide → exact then family (never cross-language) or **404**.
- **Playlist / video-asset media URLs** (`GET .../playlist`, `GET /v1/video-asset/{id}`, preview/`videoUri` fields): **video and audio `url` / `assetUri`** are time-limited S3 **presigned progressive MP4** (and M4A for instruction audio), typically ~7 days. **Do not assume HLS**, `master.m3u8`, `stream.hyperhuman.cc`, or ABR ladders — those are not the runtime delivery model. Play with HTML5 `<video>` / AVPlayer / ExoPlayer (no `hls.js` required).
- **Posters are not the video URL.** On playlist `exercise` / `class` / `multiExercise` / `promo` / `educational` segments, `intro.video.poster` and `main.video.poster` (and matching `thumbnail`) are **public unsigned S3 JPEGs** (`Content-Type: image/jpeg`, typically `.../e/{id}/poster_a_720p.jpg` or `poster_a_1080p.jpg`). Fetch them as images — no API key, no `X-Amz-*` query. Do **not** use `video.url` as the poster. **Intro and outro** `video` is `{ url }` only (no `poster`). Breaks use `image.url`. Do not bind a missing/empty poster to `<img src="">` (browsers then request the current HTML page). List/detail `preview.poster` is a separate field (cover / still / clip) and is also usually a public JPEG — not the playlist intro video. OpenAPI/`example.com`/`images.hyperhuman.cc` placeholders are **not** live image hosts (`example.com` returns HTML).
- **Org plan library list** (`GET /v1/orgs/{organizationId}/plans`) returns published `public`/`private` only. Plans with visibility `end-user-ai-generated` are never listed (even with `visibility=all`); use `GET /v1/plans/{id}` with the stored id from generate/adapt.
- **Assign vs start vs end vs unassign:** `POST .../endusers/{endUserId}/workouts/assign` and `POST .../plans/assign` add items to the member's **library**. They do **not** set `isActive`. Enroll with `POST .../plans/start` — start does **not** require a prior assign. `GET .../endusers/{endUserId}/plans` is the org-scoped library, not `GET .../plans/active`. Max 10 workout ids / 5 plan ids. **Assign** catalog gate: published `public`/`private` org workouts; trainer-owned `public`/`private` plans (never `end-user-ai-generated`). Any ineligible id → `400` and no writes. Already-assigned ids are skipped (no re-notify); retries are safe. Empty `200`. Notifications on **new** rows only (`sendAssignmentEmail` / `sendAssignmentPush`, default true). **Unassign** is `POST .../unassign` (no notification): org-owned ids only (not the assign published/visibility gate — retired org content can still be removed). Missing or other-org ids are ignored (not `400`). `POST .../plans/{planId}/end` clears `isActive` and keeps the library row; unassigning the active plan **deletes** the access row. Team-bound API key must match the organization + active membership (suspended/unknown → `404`). Do not assume a `visibility` query on `GET .../endusers/{endUserId}/workouts` filters the list (shared DTO; unused here).
- **Workout sessions are anonymous unless you attribute them.** A key-only `POST .../sessions/start` (and the rest of the lifecycle) tracks an **anonymous** session (`traineeId: null`). To attribute a session, feedback, and analytics to a specific end user **without** a member token, send `endUserId` (internal user id or email) **or** `externalUserId`, plus `organizationId` for standard/club keys that span organizations (team-scoped keys default to their own org). These optional fields live on `EndUserAttributionFields` and are accepted on **every** session/feedback call — `sessions/start`, `PATCH sessions/{id}`, `sessions/end`, `sessions/complete`, and `workouts/{id}/feedback` — in the **body** for `POST`/`PATCH` and as **query** params for `GET` (`sessions/{id}`, `sessions/recent`). Re-send the identifier on every call; each endpoint resolves it statelessly and is scoped to that trainee. A member `Authorization: Bearer` token, when present, always wins and these fields are ignored. **HyperCast `workout/start` is the exception:** Bearer does not attribute a cast session (see the HyperCast pitfall below).
- **`sessions/end` vs `sessions/complete`.** `end` closes the session keeping the last `PATCH`ed `progressSeconds` (abandon); `complete` forces 100% (natural finish / "mark as done"). They are distinct POSTs.
- **`GET .../sessions/recent` needs an identity.** It returns the most recent **in-progress** (not completed) session for resume-first UX. Resolve identity via a Bearer token **or** the `endUserId` / `externalUserId` query fields above; a fully anonymous key-only call has nothing to resolve and returns `404`.
- **Feedback uses option ids, not raw numbers.** `POST .../feedback` body is `{ workoutSessionId, ratingId?, difficultyId?, comment? }` (plus optional attribution fields). Fetch the valid `ratingId` / `difficultyId` values from `GET /v1/workouts/feedback/options` — wire shape `{ data: { rating: [{ id, label, value }], difficulty: [...] } }`. Option ids are **24-character hex** strings (e.g. `60af7f9d8ded6000114c92c4`); invented labels like `rating-5-stars` return **400** `ValidationError`.
- **Health-data batch push always returns `200` — check per-entry results.** `POST .../endusers/{endUserId}/insights/health-data` processes up to 50 entries independently (best-effort batch) and reports `{ data: { results: [{ index, dataType, externalId, status: created|updated|rejected, reason? }], summary } }`. A `200` with `summary.failed > 0` is a **partial success**; never treat the HTTP status alone as "all applied". Whole-request `400` only fires for malformed bodies (unknown `dataType`, wrong field types, > 50 entries).
- **Health-data entries are idempotent on `(dataType, externalId)`.** Re-POSTing the same key **updates** (never duplicates) — retries and corrections are safe. `externalId` is **required** for `activity` entries; the other types default to a per-day key `day-YYYY-MM-DD` derived from `recordedAt`. Corrections: re-POST the same key, or `DELETE .../insights/health-data/{dataType}/{externalId}` (404 when unknown). Backfill window: max **90 days** past, never future-dated (rejected per entry, not per request).
- **Pushed values overwrite wearable-synced day values** ("explicit write wins"); deleting the entry releases the claim and the next wearable sync restores device values. Wearable-synced activities cannot be deleted through the API (`400`) — only entries the API created.
- **Direct nutrition log is additive unless you pass `externalId`.** `POST .../endusers/{endUserId}/nutrition/log` without `externalId` adds to the day totals on every call (same as `/v1/me/nutrition/log`); with `externalId`, a re-POST **replaces** the previous contribution and enables `DELETE .../nutrition/log/{externalId}`. At least one of `caloriesKcal` / `proteinG` / `carbsG` / `fatG` is required.
- **HyperCast (`/v1/cast/pairing/...`, OpenAPI tag `workouts-cast`) — controllers join, displays create.** The TV shows the hosted member-web `https://member.hyperhuman.cc/cast?orgId=...` page, which owns session creation; partner controller apps only call `POST .../join-by-code` (6-digit code) or `POST .../join` (sessionId + code parsed from the QR URL). The display QR encodes `https://member.hyperhuman.cc/cast?sessionId=...&code=...`. Do **not** call `POST .../session` or `DELETE .../session/{id}` from a controller (those are display-side; unpair with `POST .../{sessionId}/device/disconnect`). One controller per session — a new join disconnects the previous device. Sessions expire after **80 minutes**. A **team-scoped key** may only create or join sessions bound to its own organization (mismatch → `403`). REST control commands (`.../workout`, `.../workout/start`, `.../workout/control`) broadcast to the display server-side — Socket.IO (namespace `/cast/pairing`, handshake `auth: { apiKey }`; use `socketIO.fullUrl` from the join response) is optional and only needed for live status events. Cast routes count as **1x**. `workoutData` on the cast body is deprecated — omit it; the display loads by `workoutId`. The workout must be created/published (same as `GET /v1/workouts/{id}`; unknown id → `404`). `workout/start` accepts the same `endUserId` / `externalUserId` / `organizationId` field names as workout sessions, but a member Bearer token does **not** attribute a cast session (API key + those fields only). The end user must be an active member of that organization; unknown / non-member → `404` and **playback does not start** (omit the fields for anonymous — a failed resolve does not fall back). `done` completes the session created at start; do not open a second `sessions/start` + `sessions/complete`. Control `action` strings are literal: `play`, `pause`, `next`, `prev`, `restart`, `done_exercise`, `done`. Show `done_exercise` only when status reports `currentExerciseIsRepBased: true`. Poll `GET .../workout/status` as the no-socket fallback and after a socket reconnect. Disconnect never stops TV playback. `pairing-success` is `{ sessionId, displayName }` — device info arrives on `device-paired`.

## 10. Suggested system prompt for agents

```
You are integrating against the Hyperhuman Content API.
- Base URL: https://content.api.hyperhuman.cc
- Branded library iframes (member `/workouts` and `/plans` grids) use the member web app host, not https://content.api.hyperhuman.cc.
- Capabilities: publish workouts and plans, recommend/generate/adapt personalized variants, and produce daily insights for end users.
- Auth header: `X-Api-Key: <key>` on every request.
- Every error response is `{ "error": { "code": "<StableCode>", "message": "..." } }`. Switch on `error.code`.
- Pagination: prefer `links.next` to advance. Runtime cursors are `offset`/`limit` (max 50); OpenAPI may hide `offset` on shared list DTOs. Defaults: org workouts/plans omit→20; video-assets/groups omit→10. Chat list/messages use `page`/`limit` and require `organizationId` plus `userEmail` or `userExternalUUID`. Values above the operation max return 400.
- Localization via `locale` (BCP-47). Unsupported locales fall back to English on read endpoints; AI generate endpoints return `400` for unsupported locale values.
- Workout generate/adapt = ephemeral JSON (no workout id; JSON-receiver iframe + wait for non-empty apiKey + GET /video-asset/{id} 1x each; no /playlist, /music, or sessions). Pre-built embed sessions/feedback need a persisted workout id; URL/INIT key is X-Api-Key (quota). Plan generate returns top-level id ONLY with endUserProfileDetails.endUserId. Plan adapt REQUIRES endUserProfileId (omit→400). Sources: plan generate=workoutCollectionSources, plan adapt=workoutSources. Generate free-text is `userRequirements`; adapt is `userGuidance` (the other name → 400). Optional generate `methodologyProfileId` is opt-in team Programming DNA from GET .../methodologies (up to 10 team profiles; omit = none on this API; unknown/personal/inactive → 400; not used on recommend/adapt/chat). Hyperhuman Club / member web apply the published team default without that field. Generate may return a Generation Inspector snapshot (`methodology` / `inspection`) — store it; there is no GET-by-generation-id. HTTP client timeout for generate/adapt ≥ 120s; a client abort can still finish 200 — do not immediately retry. Workout generate `exerciseSources` constrained empty (no free, 0 clips) → 400 — do not retry; omit/empty still defaults to free+premium.
- Recommend body whitelist: workouts = endUserProfileDetails + optional categoryIds + optional locale; plans also allow root goalIds. Plan generate accepts optional categoryIds (hard-filter workout pool). No invented scores/weights. Do not invent `GET /v1/workouts`, `GET .../library`, or `GET /v1/workouts/categories` — catalog is `GET /v1/orgs/{organizationId}/workouts`; categories are `GET /v1/workouts/metadata`.
- **Plan = Program** (product/Teams name). Visibility is **public | private** only (UI labels match API). Workouts have **categories** (global type catalog; AI pools / quality) and optional **tags** (org freeform labels; library filter/search only). Categories filter pools; **Programs own ordered sequence** (`GET /v1/plans/{id}/workouts`). Tags do not filter recommend/generate. Retire from recommend/generate by setting visibility **private**; `GET /v1/workouts/{id}` still plays. Do not invent Unlisted as a third state, or week tags on workouts. Org workout/plan lists are the **workspace catalog** (empty 200 until items are added in Teams); do not invent a public stock-workout/program list.
- Org metadata ai*Enabled / insightsEnabled = member-app visibility, NOT Content API capability gates.
- AI recommend/generate/adapt return HTTP 200 (not 201).
- For org-scoped end-user routes, `{endUserId}` is often a 24-character hex id; the same path segment can also be an email or external id where the server resolves it (see section 6). Prefer hex ids in new code.
- Assign (`POST .../workouts/assign`, `POST .../plans/assign`) adds library membership and does not enroll. Start (`POST .../plans/start`) sets the active plan and does not require a prior assign. `GET .../endusers/{endUserId}/plans` is the library, not `GET .../plans/active`. Unassign ignores missing/other-org ids; `plans/end` deactivates, unassign deletes (including the active row).
- Treat 429 by honoring `Retry-After` (seconds) when present, then `X-RateLimit-Reset`. Trialing Stripe subs use 1/100 of paid caps. Do not retry stock-exercises on a Platform/trial 403 (plan-gated 403 does not consume).
- Use the `operationId` from /openapi.json as the canonical name for each operation.
- Media URLs: video/audio `url` (playlist, video-asset, export stream_url, music) are time-limited S3-presigned progressive MP4/M4A (~7 days). Not HLS/DASH/ABR. Re-fetch before expiry. Playlist exercise `poster`/`thumbnail` are public unsigned S3 JPEGs (not signed, not HTML, not the MP4). Intro/outro omit poster. Never use OpenAPI example hosts as live image URLs.
- Playlist `exercise.duration` is the planned work window, not the MP4 length. Loop the clip (`main.video.loop: -1`) until that clock elapses when `playsInLoop` is true.
- Health-data push (POST .../insights/health-data, max 50 entries, 1x) always returns 200 with per-entry results - check summary.failed. Idempotent on (dataType, externalId); activity requires externalId. Nutrition log: externalId = idempotent replace, omit = additive.
- HyperCast (OpenAPI tag workouts-cast): controllers join (join-by-code or join); do not create/terminate sessions. Display/QR host is the member web origin (https://member.hyperhuman.cc). Team-scoped keys are pinned to their own organization (403 otherwise). REST broadcasts to the TV (1x). Socket.IO /cast/pairing is optional for live status. Omit workoutData. Control actions are play|pause|next|prev|restart|done_exercise|done. Cast start attribution is endUserId/externalUserId/organizationId + API key only (Bearer does not win, unlike sessions/start). Non-member identifier → 404 and the TV does not play. done completes the start session. Poll GET .../workout/status after a socket drop.
```

## 11. Strict request shape and public OpenAPI scope

- **Unknown fields:** The API validates request **query and body** objects with a **whitelist** of DTO property names. Sending a parameter or field that is **not** part of the documented operation (for example a typo, or a deprecated name not listed in the spec) typically returns **`400 Bad Request`** with `error.code` **`ValidationError`**, often with a detail like `property <name> should not exist`. Send **only** keys that appear in `/openapi.json` for that path and method. This applies to optional filters such as `locale`: if it appears in Swagger, it is allowed; if it is missing from the spec, do not send it.
- **Public spec vs. process:** The document at [`/openapi.json`](https://content.api.hyperhuman.cc/openapi.json) is built from a **curated** set of feature modules (the productized, customer-facing surface). The same service process may also register other routes; **for integrations and tools, treat `/openapi.json` as the contract** for which operations and schemas are part of the supported Content API. A separate **private** API explorer (when enabled) may list the full in-process graph for internal use; do not assume a route exists in the public spec until you see it there.
- **Comma-separated id lists:** Some list filters (for example `goalIds`, `categoryIds`) use **comma-separated** values. Standard entries are **24 hex characters**; `categoryIds` may also use **`custom:`** tokens for in-memory filters where documented. Malformed id values in those lists return **`400 Bad Request`** (clear error message, not a driver-specific stack trace in the response body).

## 12. This repository: implementation and documentation notes

Conventions for engineers and agents **changing** the Content API or its docs:

- **Doc sync rule:** When fixing a contract claim, update in one pass: controller `@ApiOperation` / DTO descriptions, `assets/docs/overview.md`, `assets/docs/AGENTS.md`, `assets/docs/llms-index.md`, and `assets/docs/content-API-cheat-sheet.md` (changelog bump). Do not invent behaviors — verify against the live service or the service code path. **Do not document non-public hosts** (internal environments) in client-facing copy. **Do not document routes absent from `/openapi.json`** in partner copy (cheat sheet, overview, llms-index, AGENTS §1–11). Org assign / start / end / unassign are the public library and enrollment contract — contrast those operations with each other only. Member JWT library remove is `@ApiExcludeController` (`/v1/me/plans`, `/v1/me/workouts`); document it in `docs/library-membership-and-enrollment.md`, not partner copy. **Embedded player claims** must match Teams `embed/workout/[id]` + `embed/workout-json-receiver` (not aspirational): JSON path = `GET /video-asset/{id}` only; sessions/feedback = pre-built only; usage/quota = ordinary Content API calls on the URL/INIT key (1x playback, host generate is 10x; no embed-play unit). Public player host is `https://team.hyperhuman.cc` only.
- **Programming DNA public wording:** Partner copy uses **Programming DNA**, **methodology profile**, **Generation Inspector** (`methodology` / `inspection` on generate). Document `GET /v1/orgs/{organizationId}/methodologies` and opt-in `methodologyProfileId` on generate only. A workspace may publish **up to 10** team profiles (Teams **Set as team guideline** then **Make default**). Public Inspector fields are `methodology` and `inspection` (`hardRulesApplied`, `violations`, `complianceStatus`, `generationId`) — do **not** document Teams-internal aliases (`programmingGuidelineApplied`, top-level `ruleResults`, `complianceAudit`) or treat them as Content API keys. DTO / Swagger copy for `methodologyProfileId` must stay **fail-closed** (unknown / personal / inactive → **400**; omit still means none on **partner** keys) — do not revive “ids are ignored; check `methodology`”. Do **not** document internal guideline routes, model/vendor names, prompt internals, repair loops, collection names, or `GET /v1/generations/{id}` (not shipped). Do **not** invent env kill switches, a per-team App Features toggle, or a public `defaultMethodologyProfileId` field — in Teams the Programming tab is always on; on organization API keys generate stays opt-in. Empty `{ items: [] }` means no published team profiles, not that the feature is off. Partner copy may say Hyperhuman Club / the member web app follow the **team default** when published (product language). Do **not** claim organization API keys auto-apply a default. Do **not** name key classes, `applyTeamDna`, TeamClient vs JWT, or Club / members-web file paths. Recommend / adapt / chat on this API still reject `methodologyProfileId`.
- **Docs tokens:** write `https://content.api.hyperhuman.cc` (Content API origin) and `https://member.hyperhuman.cc` (member web origin — same value as HyperCast `qrCodeData`) in Swagger strings and served markdown. Substituted at bootstrap / serve time by `src/lib/swagger/docs-tokens.ts` (markdown replace + in-place walk of the OpenAPI document after `createDocument`). NestJS `@ApiOperation` / `@ApiProperty` copy is static — do **not** leave prose placeholders such as `{member web host}`. Do not stringify-replace the OpenAPI JSON (a host containing `"` or `\` would corrupt the spec).
- **Media delivery wording:** Client-facing docs and OpenAPI examples must describe **S3-presigned progressive MP4/M4A** (~7-day expiry, SigV4 `X-Amz-*` query style in examples) for **video/audio `url`**. **Poster / thumbnail / cover stills** are **public unsigned S3 JPEGs** (`hhcontent.s3.{region}.amazonaws.com/.../poster_a_720p.jpg` style, `Content-Type: image/jpeg`, no query string). **Never** use `example.com`, `images.hyperhuman.cc`, or `assets.hyperhuman.cc` as poster examples — those hosts are not the live image CDN (`example.com` returns HTML; `images.hyperhuman.cc` does not resolve). Playlist **intro/outro** examples must omit `poster`/`thumbnail` to match runtime. **Never** reintroduce HLS, `master.m3u8`, `stream.hyperhuman.cc`, `hls.js`, MPEG-DASH, or ABR ladders unless the runtime delivery path actually ships them.
- **Swagger `@ApiOperation` style (gold):** Short summary + what / try-it / shape / rate-limit / related. No emoji brochure tables. Keep `@ApiBody` / response examples accurate. Canonical length/tone: `organization-workouts` list + recommend controllers. Nav tags are kebab-case resource or `{resource}-{capability}` (`workouts`, `workouts-cast`, `endusers-content`). Keep the HyperCast group **name-only** — do not add a tag-level `addTag(..., description)` blurb.
- **HyperCast public wording:** OpenAPI nav tag is **`workouts-cast`**. Document the partner contract only — controllers join (do not create or terminate sessions); team-scoped keys are pinned to their own organization (`403` on mismatch); attributing `workout/start` requires an **active org member** (`404` otherwise, and start does not play); a member Bearer token does **not** attribute cast start (fields + API key only — unlike `sessions/start`); REST broadcasts to the display; Socket.IO is optional for live status; use `socketIO` from the join response; poll `GET .../workout/status` after a socket drop. Display / QR host is `https://member.hyperhuman.cc` (`/cast?orgId=...` on the TV; QR is `/cast?sessionId=...&code=...`). Do **not** document internal key-class exceptions (standard-app cross-org create/join, standard-app roster fallback), env-var enforcement flags, internal collection names, first-party Club/members-web file paths, or non-public hosts in Swagger, overview, AGENTS §9, llms-index, or the cheat sheet.
- **`PageQuery.offset` in OpenAPI:** Keep `offset` **hidden** on the shared DTO (do not uncomment `@ApiPropertyOptional`). Document “follow `links.next`” in operation prose and AGENTS §4; runtime validation still accepts `offset`.
- **Plan AI persistence (verified):** `POST .../plans/generate` top-level `id` only with `endUserProfileDetails.endUserId`. `POST .../plans/{planId}/adapt` requires `endUserProfileId` for Content API success. Never document “always store plan id” or “omit endUserProfileId → full schedule succeeds” without re-checking persist.
- **Wording (client-facing):** In Swagger text, DTO `description` fields, and user-visible error strings, refer to ids as **24-character hex** strings, **DB ids**, or **resource ids**. Do **not** name a specific database product, `ObjectId`, or ORM in copy intended for integrators. Implementation may still use the stack’s id validation helpers; keep transport-level language storage-agnostic.
- **Global validation** is configured in `src/apps/content-api/v1/content-api-v1.app.module.ts` (`ValidationPipe` with `whitelist`, `forbidNonWhitelisted`, and **`BadRequestException`** + `validationErrorFactory` for `class-validator` errors). DTOs must list every query key that appears in `@ApiQuery` or shared decorators, or clients will get 400 for “unknown” keys.
- **Shared query bases:** `locale` for list endpoints is declared on the query DTO via `LocalePageQuery` / `LocaleSearchableAndSortableQuery` in `src/modules/api/content-api/v1/shared/locale-query.dto.ts` so the OpenAPI spec, runtime validation, and `forbidNonWhitelisted` stay in sync.
- **Parsing CSV id lists** in services: `parseCsvIds` and `parseCommaSeparatedWorkoutCategoryIds` in `src/modules/api/content-api/v1/shared/parse-csv-ids.ts` (hex validation, optional `custom:` handling for categories, deduplication) — use these for new comma-separated id filters instead of ad hoc splits.
- **Swagger schema for CSV query params (do not break Try-it-out):** When a list filter is parsed CSV-style (DTO field is a `string`, service splits with `parseCsvIds` / `parseCsvStrings` / `parseCsvEnum`), declare it as a **plain string** in `@ApiPropertyOptional` — `type: String`, CSV `example`, allowed values listed in the `description` text. **Do not** combine `enum: ...` + `isArray: true` with `@IsString()`: that emits `type: array, items: { enum: [...] }` in `/openapi.json`, and **Swagger UI v5** then tries to `JSON.parse` the CSV `example` (e.g. `"single-exercise,educational"`), throws `Could not parse parameter value string as JSON Object or JSON Array`, and silently aborts the Try-it-out request **before any fetch** — the spinner spins forever, the network panel stays empty, and the only signal is the parse error in the browser console. The route still works from `curl` because validation lives in the runtime DTO. Canonical patterns: `organization-exercises/request.dto.ts` (`kinds`, `skillLevels`, `executionSides`) and `organization-plans/request.dto.ts` (`difficulties`). Use `enum` + `isArray: true` only when the wire format is genuinely repeated (`?k=a&k=b`) and the runtime field is `string[]`.
- **Try-it-out defaults:** Optional list filters should omit `@ApiPropertyOptional({ example })` unless the value is safe for a first call (returns data for a typical org). Put advanced filter samples in the operation description, cheat sheet, or labeled `x-codeSamples` (see `injectOrganizationWorkoutsListCodeSamples`).
- **Public OpenAPI `include` list:** The same `content-api-v1.app.module.ts` file passes an `include: [ ... ]` array into `SwaggerModule.createDocument` for the **public** explorer. Adding a module to the Nest `imports` array does not automatically add it to the public spec; update `include` deliberately when a feature should appear in [`/openapi.json`](https://content.api.hyperhuman.cc/openapi.json).
- **Path patterns:** Controllers mix two styles — `@Version('1')` with a short `@Controller('orgs')` path, and paths that **embed** `v1/...` in the `@Controller` string. Both can yield `/v1/...` URLs; when documenting or testing, follow the path shown in the generated spec rather than assuming a single style.
- **Organization library wording:** `GET /v1/orgs/{organizationId}/video-assets` docs should stay **short and outcome-led**. The endpoint is **workspace-only** (filter pins `availability: private` + `visibility: public`) — stock and pay-as-you-go assets are intentionally hidden because they ship as fuel for full-length AI workouts under separate licensing. There is **no `visibility` query parameter** on this route. **`trainer`** is the creator reference (legacy key). **`single-exercise`** highlights **instruction/cue audio per locale** vs **`multi-exercise`** (often original recording); no separate `original` enum. Stay aligned with Swagger for `/openapi.json`. **Org workouts/plans lists** are the same workspace-catalog story for full sessions and programs: document empty **200**, add-from-stock in Teams, and that there is no public stock-workout/program list — do not document internal JWT template/clone routes in partner copy.
- **Library fuzzy search:** Org **workouts** and **plans** list endpoints use shared Fuse ranking on `q` (fetch-all → rank → paginate; ignore `sort` while searching). **`video-assets`** keeps DB substring `q`. Internal API team library routes use `searchText`. When adding a searchable list, reuse `src/lib/search/fuse-search.ts`; do not copy-paste Fuse configs.
- **Stock exercise catalog:** `GET /v1/orgs/{organizationId}/stock-exercises` is the **partner video catalog** — canonical **published, public, `single-exercise`** stock clips (free + premium), distinct from workspace-only `video-assets`. Access requires `features.contentApi` **and** either sold `features.stockVideoCatalogApi` on a **non-Ultra** custom/Enterprise entitlement **or** a proven **active Stripe Ultra yearly** subscription (team-scoped `OrganizationStockCatalogAccessService`). The Ultra seed flag is **ignored** (monthly and yearly share one product). Caps/lock in `CheckAuthAllowedMiddleware`. Each call counts as **10x** for rate limits when admitted. A plan-gated Platform/trial/Ultra monthly **403** does **not** consume. Content with no API access is **401** before the catalog handler. `q` is DB **substring** (sort still applies), filters mirror `video-assets` plus `availability` (`free-stock`/`premium-stock`/`all-stock`); response omits `trainer`, `bundle`, and `organization` entirely (stock is platform content, not workspace uploads). This is a browse/playback surface — **not** AI-generation fuel (generation/adaptation keep sourcing stock internally). Partner clip browse is this route only. Ultra yearly unlocks **this browse**, not new clips. `collectionNames` are looks (filming set), not equipment; machine vs free weight is `equipmentIds` or `GET /v1/workouts/equipment/metadata`. Platform keys browse stock in Teams.
