# AGENTS.md — Hyperhuman Content API

> Last updated: 2026-04-30. 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. 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 (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`)
- `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`**.
- **Quirk (library lists):** `GET /v1/orgs/{organizationId}/workouts`, `GET /v1/orgs/{organizationId}/plans`, and `GET /v1/orgs/{organizationId}/video-assets` currently use **`limit: 10` in the service** when the query param is **omitted** (even if the schema advertises a different default). **Pass `limit` explicitly** if you need 20, 30, or the max. Always follow the operation in [`/openapi.json`](https://content.api.hyperhuman.cc/openapi.json) for the exact DTO.

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`, `nl-NL`.
- Unsupported locales fall back to English on metadata/playlist endpoints, and return `404` on pre-rendered `/export/...` endpoints.
- **`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; locale-aware when `?locale=` is set — also matches `nameTranslations.<locale>`), `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.

## 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 requests per window>
X-RateLimit-Remaining: <requests left in window>
X-RateLimit-Reset:     <unix timestamp when the window resets>
```

- AI / heavy-video endpoints (workout/plan generate, recommend, adapt, insights, chat, video generation, full video export) count as **10x** API calls.
- On `429 Too Many Requests`, back off until `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`.
- A few endpoints return `200 { "data": null }` instead of `404` to indicate "no resource yet" (e.g. `/me/plans/active/progress` when there is no active plan). Code paths must accept `null`.
- Workout `/playlist` returns `data: WorkoutSegmentByKind[]` — an **array of single-key objects** keyed by `kind`, not a flat array of segments.
- `videoAudioMode` (workout-level) governs which audio tracks the player must mix. Do not assume "always full".
- `GET .../export/video/stream_url` and `GET .../export/audio/stream_url` return **`200`** with **`Content-Type: text/plain`** — the body is a **single** presigned URL string (not JSON). The linked file is the stored full-workout export for that locale (commonly MP4 for video, M4A for narrative audio); format follows the actual object (extension and `Content-Type` of the response when fetching the URL), not a fixed HLS-only contract.

## 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 cursors are `offset` and `limit` (max 50). Reads `links.next` to advance.
- Localization via `locale` (BCP-47). Unsupported locales fall back to English.
- 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.
- Treat 429 by waiting until `X-RateLimit-Reset` before retrying.
- Use the `operationId` from /openapi.json as the canonical name for each operation.
```

## 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:

- **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[]`.
- **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`.
