> ## Documentation Index
> Fetch the complete documentation index at: https://docs.collectivex.health/llms.txt
> Use this file to discover all available pages before exploring further.

# Have a conversation — streaming or sync

> Dual-mode endpoint. By default (`stream` omitted or `true`) returns a streaming
UIMessage SSE response compatible with the Vercel AI SDK `useChat` hook. Send
`stream: false` to receive a synchronous JSON response (the `SyncResponse` shape)
suited for Claude Skills, MCP tool calls, and agent pipelines.

**Server-to-server (the only supported pattern for web), or
native-mobile-direct via a short-lived session token** minted by
your backend through `POST /v1/session-tokens` (native mobile only,
not browser/JS — see "API keys" above). Never embed your long-lived key in any client,
browser or mobile.

Rate limits are communicated via `X-RateLimit-*` headers on every
response and are shared with `/v1/query` per API key
(see "Rate limits" above). When a 429 is returned, honour
`Retry-After` before retrying.




## OpenAPI

````yaml /openapi.yaml post /v1/chat
openapi: 3.1.0
info:
  title: CollectiveX Health Partner API
  version: '1.10'
  description: >
    The CollectiveX Health Partner API gives your product access to a grounded,

    peer-reviewed women's-health research corpus. Every answer includes inline

    citations from real papers; the API refuses to answer when the corpus lacks

    strong evidence.


    There are two ways to use it: ask a single question and get one

    grounded answer (`/v1/query`), or have a back-and-forth conversation

    with streaming or sync responses (`/v1/chat`). A third, asynchronous

    way — submit the same question as a job with `POST /v1/jobs` and poll

    `GET /v1/jobs/{id}` for the answer — is for callers that can't hold

    the connection open (see "Async jobs" below). Pick whichever matches

    your integration.


    ---


    ## API keys: server-side, or native-mobile-direct via short-lived session
    tokens


    > **Warning:** Do NOT call `/v1/query` or `/v1/chat` directly from a

    > browser or a mobile app using your long-lived partner key. Build a

    > server-side proxy (a Next.js Route Handler, Express middleware, or

    > Lambda function) that forwards requests and authenticates with the

    > partner key from a secret manager. Browser-direct usage will leak the

    > key to your users. **This rule is absolute and does not change** —

    > everything below adds a second sanctioned pattern for native mobile

    > apps, it does not relax this one, and it does not extend to browsers.


    **For web apps, server-to-server is the supported and recommended

    integration**, full stop: keep your long-lived partner key on your own

    backend and proxy every browser request through it. This is what

    HumanGo does today — the browser never sees any CollectiveX Health

    credential, long-lived or short-lived.


    **Short-lived session tokens exist for exactly one case: a native

    mobile app that streams from us directly**, without a round trip

    through your backend for every turn. They are **not** a sanctioned way

    to call this API from a browser or any other JavaScript context — a

    token handed to client-side JS is sitting in developer tools same as

    the long-lived key would be, just on a shorter clock. To be plain about

    how this is enforced: it's a partner-trust rule, stated here and in the

    partner terms, not a technical one — we do not block browser-origin

    requests by user-agent or otherwise, and a session token will work if

    you send it from one. We reserve the right to revoke a partner's keys

    for minting or using session tokens outside native mobile apps.


    There are two sanctioned integration patterns:


    **1. Server-to-server proxy** (recommended default, and the only

    supported pattern for web):


    ```

    Browser  →  useChat({ api: '/api/proxy/chat' })
                  ↓
    Partner's server-side proxy (your Route Handler / Lambda)
                  ↓  Authorization: Bearer $CXH_API_KEY (from env)
    POST https://api.collectivex.health/v1/chat  (or /v1/query)

    ```


    **2. Native-mobile-direct, via a short-lived session token** (see

    "Mobile-direct access" below): for a native mobile app you don't want
    proxying every chat

    turn through your own backend, your backend presents the long-lived

    key to `POST /v1/session-tokens` and hands the returned short-lived key

    straight to that app, which calls `/v1/query` / `/v1/chat` with it

    directly:


    ```

    Your backend  →  POST /v1/session-tokens  (Authorization: Bearer
    $CXH_API_KEY)
                        ↓  { key: "...", expires_in_seconds: 900, scope: ["query","chat"] }
    Mobile app    →  POST /v1/query (or /v1/chat)  (Authorization: Bearer
    <session key>)

    ```


    The session key can call **only** `/v1/query` and `/v1/chat`, expires in

    at most 60 minutes, and can never mint another session token. See

    "Mobile-direct access" below for the full contract and a runnable

    example. **The long-lived key itself must never leave your server** —

    embedding it in a mobile *or* web client is exactly the leak this

    section exists to prevent; the short-lived session key it mints is

    safe to hand to a native mobile client, and only a native mobile

    client.


    Your app owns end-user identity. We never receive end-user PII. The partner

    key is shared across all of your end-users; you take the contract that no

    PHI is forwarded to us in prompts.


    ---


    ## Authentication


    All requests require an `Authorization` header:


    ```

    Authorization: Bearer <CXH_API_KEY>

    ```


    To request a key:
    [partners@collectivex.health](mailto:partners@collectivex.health)


    ---


    ## Ask a question — `POST /v1/query`


    Most partner integrations don't need a conversation at all — just a

    question and a grounded answer. If that's you, `/v1/query` is the

    simplest way in: send one question, get back one JSON response, done.

    The endpoint is stateless — no session, no setup, no history to manage.


    **When to pick this:**


    - **Snapshot insights** — daily, weekly, or on-demand recommendations

    - **Embedded ask-widgets** — drop a research-search box on a content site

    - **Claude Skills, MCP tools, agent tool-calls** — one call, one cited
    answer

    - **Slack / Discord slash commands** — `/research <question>` → answer +
    sources

    - **Background batch generation** — content calendars, email digests, alerts

    - **Voice assistant integrations** — single utterance → single grounded
    answer


    Because each call is independent, you can also cache by query hash (same

    question, same answer, skip the call), retry safely (a repeated call

    returns a fresh grounded answer), and fan out many different questions in

    parallel within your per-key rate budget.


    ```bash

    curl -sS -X POST https://api.collectivex.health/v1/query \
      -H "Authorization: Bearer $CXH_API_KEY" \
      -H "content-type: application/json" \
      -d '{
        "question": "What does the research say about magnesium for perimenopause sleep?"
      }'
    ```


    **What comes back** (`SyncResponse`, abbreviated):


    ```typescript

    interface SyncResponse {
      request_id: string;          // UUID — use for support correlation
      answer: string;              // empty string on deferral or crisis
      citations: Citation[];       // empty array on deferral or crisis
      deferral?: { originalQuery: string };  // corpus lacked strong evidence
      crisis?: { category, locale, originalQuery, resources };  // safety classifier fired
      error?: { message: string }; // generic sanitized failure message (v1.1)
    }

    ```


    ```json

    {
      "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "answer": "Magnesium supplementation has been associated with modest improvements in sleep quality during perimenopause...",
      "citations": [
        {
          "id": "CXH-00835",
          "score": 0.92,
          "text": "Transdermal estradiol was associated with significant reductions...",
          "metadata": { "doi": "10.1093/humrep/dead156", "year": "2023" }
        }
      ]
    }

    ```


    Already building request bodies for `/v1/chat`? `/v1/query` also accepts

    a single-element `messages` array in place of `question` — see the

    endpoint reference below for the exact shape.


    **Structured responses (BETA).** Add `"response_format": "structured"` to

    get a `structured_answer` field alongside the normal `answer`/`citations`

    fields — the same answer, reshaped into sections and citation-bound

    claims for callers that want to render or route the answer programmatically

    instead of parsing markdown. This is additive only: omit the field, or set

    it to `"text"` (the default), and the response is unchanged. See

    `QueryRequest.response_format` and `StructuredAnswer` in the schema

    reference below. This field and its shape may change in a future minor

    version while in beta.


    **You MUST branch on `structured_answer.status` first.** It is a required

    field with one of five values (`answered`, `ungrounded`, `deferral`,

    `crisis`, `error`) — never infer the outcome from `sections`/`citations`

    being empty. A failed or truncated generation is surfaced as

    `status: "error"` with `structured_answer.error` populated and

    `sections`/`citations` always empty — it is never reshaped into a section

    that looks like a normal cited answer. `deferral` and `crisis` each carry

    one non-empty section with pre-approved copy (see `StructuredSection.kind`)

    so that even a client that only renders `sections[].body` never shows a

    blank card for a self-harm query.


    **Common errors:** `400 invalid_request` (bad or unknown field in the

    body), `401 unauthorized` (missing or malformed key), `429 rate_limited`

    (back off and retry after the `Retry-After` header). See "Rate limits"

    below and the endpoint reference for the full list.


    ---


    ## Have a conversation — `POST /v1/chat`


    When your integration needs back-and-forth — a chat UI, follow-up

    questions, multi-turn context — use `/v1/chat` instead. It streams the

    answer by default so users see text appear as it's generated; send

    `stream: false` if you'd rather wait and get one JSON blob back.


    **When to pick this:** a chat widget, an assistant thread, or any flow

    with follow-ups where the next question references the prior one ("is it

    different?", "what about HRT?").


    ### Streaming (default)


    ```bash

    curl -sS -D /tmp/headers.txt \
      -X POST https://api.collectivex.health/v1/chat \
      -H "Authorization: Bearer $CXH_API_KEY" \
      -H "content-type: application/json" \
      -d '{
        "messages": [
          {
            "id": "msg_smoke_1",
            "role": "user",
            "parts": [{ "type": "text", "text": "How does luteal-phase progesterone affect sleep?" }]
          }
        ]
      }'
    ```


    **What comes back:** HTTP `200`, `content-type: text/event-stream`, an

    `x-vercel-ai-ui-message-stream: v1` header (AI SDK v6 protocol marker),

    and an SSE body — abbreviated:


    ```

    data: {"type":"start"}

    data: {"type":"text-delta","delta":"Progesterone during the luteal phase "}

    data: {"type":"data-sources","data":[{"id":"CXH-00835","score":0.92,...}]}

    data: {"type":"finish","finishReason":"stop"}

    ```


    ### Synchronous mode (`stream: false`)


    Send `"stream": false` to get a flat JSON response instead of SSE — the

    same `SyncResponse` shape shown under "Ask a question" above. This stays

    fully supported indefinitely; it's for callers that structurally can't

    read a stream (Claude Skills, MCP tool calls, batch pipelines). **New

    one-shot integrations should use `/v1/query` instead** — same contract,

    simpler request body.


    ```bash

    curl -sS -X POST https://api.collectivex.health/v1/chat \
      -H "Authorization: Bearer $CXH_API_KEY" \
      -H "content-type: application/json" \
      -d '{
        "stream": false,
        "messages": [
          {
            "id": "msg_smoke_1",
            "role": "user",
            "parts": [{ "type": "text", "text": "How does luteal-phase progesterone affect sleep?" }]
          }
        ]
      }' | jq '.'
    ```


    ### Multi-turn follow-ups


    Include the prior `user` + `assistant` messages in the array — the same

    shape as any AI SDK / OpenAI-style `messages` history. Only the last 10

    messages are used.


    > **Heads-up:** retrieval keys on the *latest* user message only. A bare

    > follow-up like "yes please" or "tell me more" will not retrieve relevant

    > papers on its own — keep follow-ups self-contained ("yes — show me the

    > HRV studies") or rewrite the query on your side before sending.


    ### Latency: prefer streaming wherever you can render (or buffer) it


    Answers are retrieval-grounded and fully generated per request, so a

    complete answer takes roughly **14–23 seconds** end to end (measured

    against production on the golden question set, 2026-08-14). The two modes

    spend that time very differently:


    | mode | first byte | first answer text | complete answer |

    |---|---|---|---|

    | `stream` (default) | ~7s (p50) | **~9s (p50)** | ~18s (p50) |

    | `stream: false` | ~18s (p50) — the full wait | — | ~18s (p50) |


    The second half of the wall clock is the model writing the answer — time

    streaming consumers spend reading, and sync consumers spend staring at a

    spinner. Sync mode cannot be made materially faster server-side; switching

    to streaming halves *perceived* latency with no server change. Anything

    user-facing should consume the SSE stream — first words on screen at ~9s

    instead of an ~18s blank wait. Streaming doesn't require a chat UI or the

    AI SDK either: read the `text-delta` chunks and concatenate them (the

    proxy example below pipes bytes through unchanged).


    ### Building a chat UI (`useChat` via your server-side proxy)


    Point `useChat` at YOUR own proxy, never at `api.collectivex.health`

    directly. If this `useChat` instance runs in a native mobile app that

    holds a short-lived session token (native mobile only, not browser/JS

    — see "API keys" above), point it

    at `/v1/chat` directly with that token instead of a proxy. A browser

    `useChat` instance always goes through your proxy — never the

    long-lived key, and never a session token either:


    ```tsx

    'use client';

    import { useChat } from '@ai-sdk/react';

    import { DefaultChatTransport } from 'ai';


    export function ResearchChat() {
      const { messages, sendMessage } = useChat({
        transport: new DefaultChatTransport({
          api: '/api/proxy/chat',   // YOUR proxy — NOT api.collectivex.health
        }),
      });
      // ... render
    }

    ```


    Your server-side proxy holds the `CXH_API_KEY` and forwards to `/v1/chat`:


    ```typescript

    // app/api/proxy/chat/route.ts

    import 'server-only';

    export const runtime = 'nodejs';

    export const maxDuration = 60;


    export async function POST(req: Request) {
      const CXH_API_KEY = process.env.CXH_API_KEY;
      if (!CXH_API_KEY) return new Response('CXH_API_KEY not configured', { status: 500 });

      const upstream = await fetch('https://api.collectivex.health/v1/chat', {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          authorization: `Bearer ${CXH_API_KEY}`,
        },
        body: await req.text(),
      });
      return new Response(upstream.body, {
        status: upstream.status,
        headers: upstream.headers,
      });
    }

    ```


    **Common errors:** same as "Ask a question" above, plus `502` in sync

    mode when the generation pipeline fails with nothing usable (the body is

    still a `SyncResponse` with `error.message` set — retry with backoff).


    ---


    ## Rate limits


    Default limit for every key: **60 requests per minute**, shared across

    every `/v1/*` endpoint that requires a key — today that's `/v1/query`,

    `/v1/chat`, `POST /v1/jobs`, `/v1/jobs/{id}`, and

    `POST /v1/session-tokens`. (A future endpoint on this same key naturally

    joins the same budget unless we say otherwise.) Submitting an async job

    is one metered request and each poll is one more; the job's background

    execution is never metered.


    Two session-token consequences worth sizing for: **minting is itself a

    metered request** against your budget (one mint = one request), and a

    **session key does not get its own budget** — it draws from its parent

    long-lived key's pool, so the limit is effectively per *partner*, not

    per key, once session tokens are in play.

    Limits are **per-key configurable** — some partners are provisioned with

    a different requests-per-minute ceiling, and/or a requests-per-day

    ceiling, on request. There is no implicit daily cap unless your key has

    been configured with one; ask partners@collectivex.health if you want a

    daily ceiling added or changed.


    Read `X-RateLimit-Remaining` on every successful response to self-throttle

    before reaching 429. When a key has a configured daily ceiling, successful

    responses also carry `X-RateLimit-Limit-Daily` /

    `X-RateLimit-Remaining-Daily` / `X-RateLimit-Reset-Daily` — check these

    too, since the per-minute headers alone can show plenty of headroom while

    you're one request from the daily cap. When you receive a 429, wait the

    number of seconds in the `Retry-After` response header before retrying.

    `X-RateLimit-*` on a 429 always reflects whichever budget (per-minute or,

    if configured, per-day) was actually exceeded.


    On the rare occasion the rate-limit service itself is degraded, you'll

    get a `503` with `error.code: "internal_error"` and a `Retry-After`

    header — treat it like any other transient 503 and retry with backoff.

    A genuine 429 always carries a `Retry-After` header; in a rare

    clock-skew case its value may be `0`, which clients should treat as

    "retry after a short backoff", not "retry immediately".


    ---


    ## Mobile-direct access — short-lived session tokens


    Session tokens are for native mobile apps only — see "API keys" above

    for why this doesn't extend to browsers. If your product has a native

    mobile app you'd rather not proxy every chat turn through your own

    backend, your backend can mint a short-lived, scoped key instead of

    embedding the long-lived one in the app:


    ```bash

    # 1. Your backend mints a session token (long-lived key, server-side only)

    curl -sS -X POST https://api.collectivex.health/v1/session-tokens \
      -H "Authorization: Bearer $CXH_API_KEY" \
      -H "content-type: application/json" \
      -d '{"ttl_seconds": 900}'
    # -> { "key": "<short-lived key>", "expires_in_seconds": 900,

    #      "scope": ["query", "chat"], ... }


    # 2. Hand `key` to the mobile client, which calls /v1/query or /v1/chat

    #    with it directly:

    curl -sS -X POST https://api.collectivex.health/v1/query \
      -H "Authorization: Bearer <short-lived key>" \
      -H "content-type: application/json" \
      -d '{"question": "What does the research say about magnesium for perimenopause sleep?"}'
    ```


    What a session key can and can't do:


    - Scoped to `/v1/query` and `/v1/chat` only — every other endpoint,
      including `POST /v1/session-tokens` itself, returns `403 forbidden`
      (no chaining a session key into another session key).
    - Expires automatically 60 seconds to 60 minutes after minting
      (`ttl_seconds`, default 900 = 15 minutes); Unkey deletes it on its
      own, nothing to clean up. An expired key returns `401 unauthorized`
      with an "API key expired" message — treat that as "fetch a fresh
      token from your backend", not as an error to alert on.
    - Shares its parent long-lived key's rate-limit budget and telemetry —
      minting session keys does not multiply your rate limit. A budget
      change to the long-lived key reaches outstanding session keys only
      after they expire, since each session key's limits are captured at
      mint time.
    - Always capped at `max_requests` total requests (1–10,000, default
      200), independent of the rate-limit window. An exhausted key
      returns `403 forbidden` permanently — mint a new one, don't retry.

    See the `POST /v1/session-tokens` operation below for the full request/

    response schema and error contract.


    ---


    ## OpenAPI spec


    The machine-readable contract is at

    [/openapi/v1-chat.yaml](/openapi/v1-chat.yaml) (the source of this

    documentation). Drop it into Postman, Insomnia, or Stainless to generate a

    client SDK.


    ---


    **Versioning policy:** Additive minor bumps for new fields/codes; breaking

    shape changes are major.
  contact:
    email: partners@collectivex.health
servers:
  - url: https://api.collectivex.health
    description: Production partner API
security:
  - bearerAuth: []
tags:
  - name: Ask a question
    description: One-shot, single-turn — send a question, get one grounded JSON answer.
  - name: Have a conversation
    description: Multi-turn chat — streaming by default, or synchronous with stream:false.
  - name: Async jobs
    description: >
      Ask a question without holding the connection open. `POST /v1/jobs`

      accepts exactly the `/v1/query` request body, answers `202` with a job id
      in milliseconds, and runs

      the same grounded-answer pipeline in the background; you poll

      `GET /v1/jobs/{id}` until the job reaches a terminal state. Built

      for agent pipelines and serverless callers whose own request timeout

      is shorter than a grounded answer can take.


      **Lifecycle.** `queued` → `running` → exactly one of `succeeded`

      (read `result`), `failed` (read `error`), or `expired`. A job can

      also go straight from `queued` to `expired` if it was never started

      in time. Terminal states never change again.


      **What you get back.** For a `query` job, `result` is byte-for-byte

      the `200` body `POST /v1/query` would have returned for the same

      request (`QueryResponse`) — same `answer`, `citations`, optional

      `deferral` / `crisis` / `error`, and `structured_answer` when you

      asked for `response_format: "structured"`. A request that

      `/v1/query` would have answered with `502` (generation failed with

      nothing usable) becomes a `failed` job whose `error` carries the

      same generic message.


      **Retention.** Every job carries an `expires_at` (one hour after

      submission today). A job that is still `queued` or `running` when

      that deadline passes is marked `expired` the next time it is read;

      a job seen `running` for more than 2 minutes is likewise treated as

      expired on the next read (the runner cannot live that long, so the

      answer is never coming). A job that already finished keeps its

      `result` readable **until `expires_at`** — the first read after

      that returns `404 not_found`, its stored request and result are

      cleared at that moment, and the row itself is deleted within

      about 24 hours. Fetch your result before `expires_at`.


      **Polling.** Start ~1 s after the `202`, then back off (e.g. ×1.5

      up to 10 s). Every poll is a metered request against the same

      per-key budget as `/v1/query`: honour `X-RateLimit-Remaining` and,

      on a `429`, `Retry-After`. Branch on the HTTP status of each poll:

      a `2xx` means read `status`; a `429` or a `503` means wait

      `max(Retry-After, 1)` seconds and poll again — a `503` with

      `Retry-After` while polling is transient (our auth/rate-limit

      dependency was briefly unavailable) and the job itself is

      unaffected; any other status means stop and inspect. A grounded

      answer typically takes 10–40 s.


      **Failures.** A `failed` (or `expired`) job carries a

      machine-readable `error_code`; branch on it, not on the `error`

      text: `corpus_unavailable` and `internal` are transient — submit

      a new job with backoff; `generation_failed`, `empty_query`,

      `invalid_payload` and `expired` mean the same request will fail

      the same way.


      **Who can call it.** Server-to-server only, with your long-lived

      partner key — both operations refuse session tokens with

      `403 forbidden`. Any of your long-lived keys can poll any of your

      jobs (ownership is per partner, not per key).
  - name: Session tokens
    description: |
      Mint a short-lived, scoped key for a mobile client from your
      long-lived partner key — see "Mobile-direct access" in the
      introduction.
paths:
  /v1/chat:
    post:
      tags:
        - Have a conversation
      summary: Have a conversation — streaming or sync
      description: >
        Dual-mode endpoint. By default (`stream` omitted or `true`) returns a
        streaming

        UIMessage SSE response compatible with the Vercel AI SDK `useChat` hook.
        Send

        `stream: false` to receive a synchronous JSON response (the
        `SyncResponse` shape)

        suited for Claude Skills, MCP tool calls, and agent pipelines.


        **Server-to-server (the only supported pattern for web), or

        native-mobile-direct via a short-lived session token** minted by

        your backend through `POST /v1/session-tokens` (native mobile only,

        not browser/JS — see "API keys" above). Never embed your long-lived key
        in any client,

        browser or mobile.


        Rate limits are communicated via `X-RateLimit-*` headers on every

        response and are shared with `/v1/query` per API key

        (see "Rate limits" above). When a 429 is returned, honour

        `Retry-After` before retrying.
      operationId: postChat
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChatRequest'
            examples:
              stream_request:
                summary: Streaming request (default)
                value:
                  messages:
                    - id: msg_smoke_1
                      role: user
                      parts:
                        - type: text
                          text: How does luteal-phase progesterone affect sleep?
              sync_request:
                summary: Synchronous request (for Skills/agents)
                value:
                  stream: false
                  messages:
                    - id: msg_smoke_1
                      role: user
                      parts:
                        - type: text
                          text: How does luteal-phase progesterone affect sleep?
      responses:
        '200':
          description: >
            OK. Content type depends on the `stream` field in the request body:

            - `stream: true` (default): `text/event-stream` — AI SDK v6
            UIMessage SSE protocol.

            - `stream: false`: `application/json` — SyncResponse flat envelope.
          headers:
            X-Request-Id:
              description: >-
                UUID correlation key for this request (propagated to PostHog +
                Langfuse).
              required: true
              schema:
                type: string
                format: uuid
                example: 550e8400-e29b-41d4-a716-446655440000
            X-RateLimit-Limit:
              description: >-
                Maximum requests per minute for this key (shared budget with
                /v1/query, /v1/jobs/{id}, and /v1/session-tokens; per-key
                configurable, default 60).
              required: true
              schema:
                type: integer
                example: 60
            X-RateLimit-Remaining:
              description: Requests remaining in the current per-minute rate-limit window.
              required: true
              schema:
                type: integer
                example: 59
            X-RateLimit-Reset:
              description: >-
                Unix timestamp (seconds) when the per-minute rate-limit window
                resets.
              required: true
              schema:
                type: integer
                example: 1717000060
            X-RateLimit-Limit-Daily:
              description: >-
                Present only when this key has a configured daily (rpd) budget.
                Maximum requests per day.
              required: false
              schema:
                type: integer
                example: 5000
            X-RateLimit-Remaining-Daily:
              description: >-
                Present only when this key has a configured daily (rpd) budget.
                Requests remaining in the current day's window.
              required: false
              schema:
                type: integer
                example: 4321
            X-RateLimit-Reset-Daily:
              description: >-
                Present only when this key has a configured daily (rpd) budget.
                Unix timestamp (seconds) when the daily window resets.
              required: false
              schema:
                type: integer
                example: 1717003600
          content:
            text/event-stream:
              schema:
                type: string
                description: >
                  AI SDK v6 UIMessage stream protocol. The response header

                  `x-vercel-ai-ui-message-stream: v1` is always present on
                  streaming responses.


                  Each line is a server-sent event of the form `data:
                  <json>\n\n`.


                  Partner-visible chunk types (inclusion list — all others are
                  filtered):

                  - `start` / `finish` / `start-step` / `finish-step`: control
                  flow brackets

                  - `text-start` / `text-delta` / `text-end`: streamed answer
                  text

                  - `data-sources`: citation array (one chunk per response)

                  - `data-deferral`: emitted when corpus lacks strong evidence
                  (no answer)

                  - `data-crisis`: emitted when a safety classifier fires before
                  retrieval

                  - `error` / `abort`: error propagation


                  See https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol for full
                  chunk format.
              examples:
                grounded_stream:
                  summary: Grounded answer stream (excerpt)
                  value: >
                    data: {"type":"start"}


                    data: {"type":"text-start","id":"msg_abc"}


                    data: {"type":"text-delta","delta":"Progesterone during the
                    luteal phase "}


                    data:
                    {"type":"data-sources","data":[{"id":"CXH-00835","score":0.92,"text":"...","metadata":{"doi":"10.1093/humrep/dead156","year":2023}}]}


                    data: {"type":"finish","finishReason":"stop"}
                deferral_stream:
                  summary: Deferral stream (corpus lacks evidence)
                  value: >
                    data: {"type":"start"}


                    data: {"type":"data-deferral","data":{"originalQuery":"How
                    do I lose weight fast?"}}


                    data: {"type":"finish","finishReason":"stop"}
            application/json:
              schema:
                $ref: '#/components/schemas/SyncResponse'
              examples:
                grounded_sync:
                  summary: Grounded synchronous response
                  value:
                    request_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
                    answer: >-
                      Progesterone during the luteal phase has a thermogenic
                      effect...
                    citations:
                      - id: CXH-00835
                        score: 0.92
                        text: >-
                          Transdermal estradiol was associated with significant
                          reductions...
                        metadata:
                          cxh_id: CXH-00835
                          doi: 10.1093/humrep/dead156
                          year: 2023
                deferral_sync:
                  summary: Deferral (corpus lacks strong evidence)
                  value:
                    request_id: b2c3d4e5-f6a7-8901-bcde-f12345678901
                    answer: ''
                    citations: []
                    deferral:
                      originalQuery: How do I lose weight fast?
                crisis_sync:
                  summary: Crisis response (safety classifier fired)
                  value:
                    request_id: c3d4e5f6-a7b8-9012-cdef-123456789012
                    answer: ''
                    citations: []
                    crisis:
                      category: self-harm
                      locale: US
                      originalQuery: I want to hurt myself
                      resources:
                        - region: US
                          name: 988 Suicide & Crisis Lifeline
                          description: >-
                            24/7 free, confidential support for people in
                            distress.
                          phone: '988'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          description: >
            Sync mode (`stream: false`) only. The generation pipeline failed and
            no usable

            content was produced (empty answer, no deferral, no crisis). The
            body is still

            the SyncResponse envelope: use `request_id` for support correlation
            and `error.message`

            for display. Retry with backoff. When a PARTIAL answer (or a
            deferral/crisis

            short-circuit) exists alongside a pipeline error, the endpoint
            returns 200 with

            the optional `error` field populated instead.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SyncResponse'
              examples:
                generation_failed_sync:
                  summary: Generation failed with nothing usable
                  value:
                    request_id: d4e5f6a7-b8c9-0123-def1-234567890123
                    answer: ''
                    citations: []
                    error:
                      message: Generation failed. Please retry.
        '503':
          $ref: '#/components/responses/CorpusUnavailable'
      security:
        - bearerAuth: []
components:
  schemas:
    ChatRequest:
      type: object
      additionalProperties: false
      description: >
        Request body for POST /v1/chat. Uses additionalProperties: false
        (mirrors Zod .strict()

        on the server). Any unknown field returns 400 invalid_request naming the
        offending field.

        Retrieval tuning knobs (topK, rerank, rerankModel, rerankLimit,
        minScore, filter, mode,

        includeRelationships) are intentionally stripped — we curate retrieval;
        partners don't tune.
      required:
        - messages
      properties:
        messages:
          type: array
          description: >
            Conversation history as AI SDK v6 UIMessage objects. Only the last
            10 messages

            are used (matched to the internal pipeline's slice cap).
          minItems: 1
          items:
            $ref: '#/components/schemas/UIMessage'
        stream:
          type: boolean
          default: true
          description: >
            When true (default), returns text/event-stream SSE. When false,
            returns

            application/json SyncResponse. Cannot be combined with other
            streaming

            negotiation headers.


            Latency note: sync mode waits for the complete generation before the

            first response byte (~18s p50 measured in production); streaming

            delivers the first answer tokens at roughly half that (~9s p50).

            Prefer streaming for anything user-facing — see "Latency: prefer

            streaming" in the introduction.
    SyncResponse:
      type: object
      description: >
        Flat JSON envelope returned when stream=false. Always contains
        request_id, answer,

        and citations. The deferral and crisis fields are mutually exclusive
        optional fields

        present when the pipeline short-circuits before generating an answer.
      required:
        - request_id
        - answer
        - citations
      properties:
        request_id:
          type: string
          format: uuid
          description: >-
            UUID minted per request. Use for support correlation across PostHog
            / Langfuse / logs.
        answer:
          type: string
          description: >
            The model's answer string. Empty string ("") on deferral or crisis
            short-circuit

            (the pipeline refuses to generate an answer without grounded
            evidence).
        citations:
          type: array
          description: >
            Retrieved citations from the peer-reviewed corpus. Empty array on
            deferral or crisis.

            Never fabricate or paraphrase a citation that is not in this array.
          items:
            $ref: '#/components/schemas/Citation'
        deferral:
          description: >
            Present when the corpus lacks strong evidence for the query. Present
            the originalQuery

            back to the user and do not synthesize an answer from training data.
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/DeferralPart'
        crisis:
          description: >
            Present when a safety classifier fires before retrieval (self-harm,
            acute symptoms,

            IPV). Show the resources verbatim and route the user away from
            research framing.
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/CrisisPart'
        error:
          description: >
            Present when the generation pipeline emitted an error (v1.1,
            optional). The message

            is always a generic sanitized string — internal error details are
            never exposed.

            With no usable content the response status is 502; alongside a
            partial answer or a

            deferral/crisis short-circuit the status stays 200 and this field
            flags the failure.
          type: object
          required:
            - message
          properties:
            message:
              type: string
              description: >-
                Generic human-readable failure message. Safe to display; do not
                parse.
              example: Generation failed. Please retry.
    UIMessage:
      type: object
      description: >
        AI SDK v6 UIMessage shape. id + role + parts are the load-bearing
        fields.

        `file` parts are rejected with `400 invalid_request` on every endpoint —

        this API's contract is text questions in, grounded text answers out, and

        file URLs are never forwarded upstream. Text parts (and, on replayed

        assistant history, the `data-*` parts our own stream emits) are
        accepted.
      required:
        - id
        - role
        - parts
      properties:
        id:
          type: string
          description: >
            Unique message identifier. AI SDK clients (e.g. useChat)
            auto-generate

            this; raw HTTP callers MUST provide it (any opaque string — e.g.
            UUID,

            partner-side conversation row id). validateUIMessages rejects
            messages

            without an id.
        role:
          type: string
          enum:
            - user
            - assistant
            - system
          description: Message sender role.
        parts:
          type: array
          description: >
            Message content as an array of parts. Each part has a `type` field.

            For user messages: use `{ "type": "text", "text": "..." }`.

            For assistant messages: the API returns text-delta parts assembled
            into parts.
          items:
            $ref: '#/components/schemas/UIMessagePart'
    Citation:
      type: object
      description: A single research citation from the CollectiveX Health corpus.
      required:
        - id
        - score
        - text
      properties:
        id:
          type: string
          description: >-
            Corpus-internal citation ID (e.g. "CXH-00835"). Use as an inline
            citation reference.
        score:
          type: number
          format: float
          minimum: 0
          maximum: 1
          description: Retrieval relevance score. Higher is more relevant.
        text:
          type: string
          description: Excerpt from the source paper used to ground the answer.
        metadata:
          type: object
          description: >
            Bibliographic metadata. Strict allowlist — fields not listed here
            are

            stripped at the `/v1/*` boundary so partners never see
            Agentset-internal

            field names. Source of truth: `lib/api/v1/citation-sanitizer.ts`

            (`PARTNER_METADATA_ALLOWLIST`).
          additionalProperties: false
          properties:
            cxh_id:
              type: string
              description: Canonical paper identifier in the CollectiveX Health corpus.
              example: CXH-00835
            doi:
              type: string
              description: Peer-reviewed DOI of the source paper.
              example: 10.1093/humrep/dead156
            year:
              type: string
              description: >
                Publication year as it appears in the corpus catalog. Emitted as
                a

                string at runtime (#191) — partners that want a number should

                `parseInt(year, 10)` on the partner side. The corpus also
                includes

                non-numeric values like `"in press"` for unpublished work.
              example: '2023'
            journal:
              type: string
              description: Peer-review journal name.
              example: Human Reproduction
            authors:
              description: |
                Author list — either a single joined string or an array of
                author strings; both forms occur in corpus metadata.
              oneOf:
                - type: string
                - type: array
                  items:
                    type: string
              example: Shultz et al.
            title:
              type: string
              description: Paper title.
              example: Sex hormone profiles in normal menstruating females
            page_number:
              type: integer
              description: Page within the source paper this excerpt came from.
              example: 4
    DeferralPart:
      type: object
      description: >
        Deferral payload. Indicates the corpus lacks strong evidence for this
        query.

        Do not synthesize an answer from training data when this is present.
      required:
        - originalQuery
      properties:
        originalQuery:
          type: string
          description: The query that triggered the deferral.
    CrisisPart:
      type: object
      description: >
        Crisis payload. Indicates a safety classifier fired before retrieval.

        Show the resources verbatim and route the user away from research
        framing.
      required:
        - category
        - locale
        - originalQuery
        - resources
      properties:
        category:
          type: string
          enum:
            - self-harm
            - acute-symptom
            - ipv
          description: Safety category that triggered the classifier.
        locale:
          type: string
          description: >-
            Detected locale (e.g. "US", "GB"). Used to select region-appropriate
            resources.
          example: US
        originalQuery:
          type: string
          description: The query that triggered the crisis classifier.
        resources:
          type: array
          description: Crisis resources to show verbatim. Never omit or reorder.
          items:
            $ref: '#/components/schemas/CrisisResource'
    ErrorEnvelope:
      type: object
      description: >
        Standard partner API error envelope (D-09). Mirrors Anthropic/OpenAI
        error shape.

        Parse `error.code` for machine-readable error classification.
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
            - request_id
          properties:
            code:
              type: string
              description: >
                Machine-readable error code (D-10 locked enum). Use this for
                branching logic.

                Adding new codes is a minor version bump per the versioning
                policy.
              enum:
                - unauthorized
                - forbidden
                - rate_limited
                - invalid_request
                - corpus_unavailable
                - grounding_deferral
                - crisis_resources
                - not_found
                - internal_error
            message:
              type: string
              description: >-
                Human-readable error description. Use for logging; do not parse
                programmatically.
            request_id:
              type: string
              format: uuid
              description: UUID for this request. Include when contacting support.
    UIMessagePart:
      type: object
      description: A single content part within a UIMessage.
      required:
        - type
      properties:
        type:
          type: string
          description: 'Part type. Common values: "text", "tool-invocation", "tool-result".'
        text:
          type: string
          description: Text content (present when type is "text").
    CrisisResource:
      type: object
      description: >
        A single crisis support resource. Render verbatim — do not reorder,
        summarise, or

        omit fields. Source of truth: `lib/safety/crisis-resources.ts`.
      required:
        - region
        - name
        - description
      properties:
        region:
          type: string
          enum:
            - US
            - UK
            - global
          description: Region this resource applies to.
          example: US
        name:
          type: string
          description: Human-readable resource name.
          example: 988 Suicide & Crisis Lifeline
        description:
          type: string
          description: Short context line shown next to the resource.
          example: 24/7 free, confidential support for people in distress.
        phone:
          type: string
          description: Phone or SMS contact (optional). Format varies by region.
          example: '988'
        hours:
          type: string
          description: Operating hours, when not 24/7 (optional).
          example: Mon–Fri 9am–5pm ET
  responses:
    InvalidRequest:
      description: >
        400 Bad Request. The request body failed Zod .strict() validation. The
        error message

        names the offending field (e.g. "Unknown field: topK"). Fix the request
        body.
      headers:
        X-Request-Id:
          description: Request UUID.
          schema:
            type: string
            format: uuid
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: invalid_request
              message: >-
                Unknown field: topK. The /v1/chat endpoint does not accept
                retrieval tuning parameters.
              request_id: 550e8400-e29b-41d4-a716-446655440000
    Unauthorized:
      description: >
        401 Unauthorized. Missing, malformed, or invalid API key. Check that the
        Authorization

        header is present and has the form `Bearer <key>`. An EXPIRED session
        token also

        returns 401, with an "API key expired" message — session-token clients
        should treat

        that specific message as "request a fresh token from your backend", not
        as an alert.

        Session tokens additionally return 401 whenever the session-token
        feature is disabled

        (the feature flag is a kill switch for outstanding keys).
      headers:
        X-Request-Id:
          description: Request UUID.
          schema:
            type: string
            format: uuid
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: unauthorized
              message: >-
                Missing or malformed Authorization header. Expected: Bearer
                <key>
              request_id: 550e8400-e29b-41d4-a716-446655440000
    Forbidden:
      description: >
        403 Forbidden. Key is valid but does not have permission for this
        operation. Four

        cases, distinguishable by `error.message`: (1) the key's scope excludes
        this

        endpoint — e.g. a session token calling anything other than /v1/query or
        /v1/chat,

        including /v1/session-tokens itself; (2) the key's total request
        allowance is

        exhausted ("…allowance is exhausted. Mint a new session token." —
        permanent for

        that key, do not retry; mint a new token); (3) "This session token is
        not usable.

        Mint a new one." — a SERVER-SIDE provisioning fault, not a client
        mistake: do NOT

        re-mint in a loop (each mint is a metered request and the replacement
        will fail the

        same way) — surface the error and contact partners@collectivex.health;
        (4) an

        account-level restriction — contact partners@collectivex.health.
      headers:
        X-Request-Id:
          description: Request UUID.
          schema:
            type: string
            format: uuid
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: forbidden
              message: This key does not have permission to access /v1/chat.
              request_id: 550e8400-e29b-41d4-a716-446655440000
    RateLimited:
      description: >
        429 Too Many Requests. Rate limit exceeded. Read the Retry-After header
        (seconds)

        before retrying. Also check X-RateLimit-Remaining on successful
        responses to

        self-throttle before hitting 429. Limits are per-key configurable (a
        partner may

        have a non-default requests-per-minute ceiling and/or a requests-per-day
        ceiling);

        X-RateLimit-* on this response always reflects whichever budget was
        exceeded.
      headers:
        Retry-After:
          description: >-
            Seconds until the rate-limit window resets. Wait this long before
            retrying.
          required: true
          schema:
            type: integer
            example: 37
        X-Request-Id:
          description: Request UUID.
          schema:
            type: string
            format: uuid
        X-RateLimit-Limit:
          description: >-
            Maximum requests for the window that was exceeded (per-minute by
            default, or per-day if this key has a daily budget and that's what
            was hit).
          schema:
            type: integer
            example: 60
        X-RateLimit-Remaining:
          description: Requests remaining (0 when rate-limited).
          schema:
            type: integer
            example: 0
        X-RateLimit-Reset:
          description: Unix timestamp (seconds) when the window resets.
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: rate_limited
              message: Rate limit exceeded. Retry after 37s.
              request_id: 550e8400-e29b-41d4-a716-446655440000
    InternalError:
      description: >
        500 Internal Server Error. Unexpected server-side failure. Retry with
        exponential

        backoff. If persistent, contact partners@collectivex.health with the
        request_id.
      headers:
        X-Request-Id:
          description: Request UUID.
          schema:
            type: string
            format: uuid
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: internal_error
              message: An unexpected error occurred. Please retry.
              request_id: 550e8400-e29b-41d4-a716-446655440000
    CorpusUnavailable:
      description: |
        503 Service Unavailable. A transient condition — retry with exponential
        backoff. Two distinct causes share this status code, distinguishable by
        `error.code`:
          - `corpus_unavailable`: the research corpus (Agentset) is temporarily
            unavailable.
          - `internal_error`: an upstream dependency required to authenticate or
            rate-limit the request (Unkey) is temporarily unavailable — includes
            the "rate-limit service degraded" case described under "Rate limits"
            above. This variant always carries a `Retry-After` header.
      headers:
        X-Request-Id:
          description: Request UUID.
          schema:
            type: string
            format: uuid
        Retry-After:
          description: >-
            Seconds to wait before retrying. Present on the `internal_error`
            (Unkey-outage) variant; absent on `corpus_unavailable`.
          required: false
          schema:
            type: integer
            example: 5
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            corpus_unavailable:
              summary: Research corpus (Agentset) unavailable
              value:
                error:
                  code: corpus_unavailable
                  message: Research corpus temporarily unavailable. Please retry.
                  request_id: 550e8400-e29b-41d4-a716-446655440000
            auth_or_ratelimit_service_unavailable:
              summary: Unkey (auth/rate-limit) service degraded
              value:
                error:
                  code: internal_error
                  message: Rate limit service temporarily unavailable. Please retry.
                  request_id: 660e8400-e29b-41d4-a716-446655440001
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: cxh_pk_live_*
      description: |
        Bearer token used by every `/v1/*` endpoint. Two kinds of key work here:
          - **Long-lived partner key** (`cxh_pk_live_*`) — issued by
            CollectiveX Health (contact partners@collectivex.health). Keep
            it server-side only — never expose it in client bundles or
            mobile app code. This rule is absolute and does not change for
            any endpoint.
          - **Short-lived session key** — minted server-side via
            `POST /v1/session-tokens` from a long-lived key. Safe to hand
            to a native mobile client only — not a browser or other
            JavaScript context, see "API keys" in the description above.
            It's scoped to `/v1/query` and `/v1/chat` only, expires in at
            most 60 minutes, and can't mint further session keys.
            `POST /v1/session-tokens` itself only ever accepts the
            long-lived key.

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.