openapi: 3.0.3
info:
  title: naturali.ai — Channels API
  version: 1.0.0
  description: >
    Channels, scoped to a project (API.md §6 — Channels; design in
    api/docs/CHANNELS-WHATSAPP.md). A channel is a customer's connected messaging
    surface — a WhatsApp number, or a Discord app whose bot converses in DMs
    and in server threads. Unlike agents and tools, a
    channel is naturali-native (the runtime has no channel concept), so the
    naturali record carries its state. The access token is stored write-only as
    a secret on the runtime and never returned — reads report only
    `has_credential`. Discord's bot token is instead sealed in naturali's
    encrypted store, because the Gateway worker must present the raw token to
    authenticate its WebSocket.

    A customer authorizes naturali to send on their number without handing over a
    token via Meta Embedded Signup (`credential_source: embedded_signup`, once
    Meta App Review lands); until then, the same secret is filled by a
    customer-pasted token (`byot`). Both paths share the entire downstream
    pipeline.

    Every channel carries a **default action** — what happens when nothing else
    matches (agent / fixed message / silence), required on connect and
    patchable after. A project's own `routes` (`routes.yaml`) can answer
    differently per surface and condition, and an `address` (`addresses.yaml`)
    can override both for one identifier — three places, one shape (design:
    CHANNELS-ROUTING.md §3.3).
  contact:
    name: naturali.ai
    url: https://naturali.ai
servers:
  - url: '{baseUrl}'
    description: Host of your naturali.ai deployment; every path carries the /v1 prefix.
    variables:
      baseUrl:
        description: Base host URL.
        default: https://api.naturali.ai
tags:
  - name: Channels
    description: Connect and manage a project's messaging surfaces (WhatsApp, Discord).
security:
  - bearerAuth: []
paths:
  /v1/projects/{project_id}/conversations:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    post:
      tags: [Channels]
      summary: Open a conversation
      description: >
        The outbound-first path (CHANNELS-ROUTING.md §3.11): open a
        conversation for `{ channel_id, identifier }` ahead of any inbound
        message, which falls out of making the identifier the unit rather
        than the message. Resolves the same three-layer action an inbound
        would (§3.6); a `409` when that does not land on an agent — there is
        nothing to open for a `message`/`silence` outcome.
      operationId: createConversation
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [channel_id, identifier]
              properties:
                channel_id:
                  type: string
                  example: chan_V1StGXR8Z5jdHi6B
                identifier:
                  type: string
                  description: The bare, channel-native identifier (a phone number, a Discord user id).
                  example: '425678901234567890'
      responses:
        '201':
          description: Conversation opened (or already existing).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Conversation'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '502':
          $ref: '#/components/responses/UpstreamUnavailable'
  /v1/projects/{project_id}/channels:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Channels]
      summary: List channels
      description: Lists the channels connected in the project.
      operationId: listChannels
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of channels.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChannelList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags: [Channels]
      summary: Connect a channel
      description: >
        Connect a channel. The required fields depend on `channel`; no
        credential is ever returned.

        **Discord** (`channel: discord`) — supply `application_id` and
        `bot_token`, plus the `modes` selecting which Gateway flows to serve
        (direct messages, and/or @mention-opens-a-thread). Returns `501` when
        the deployment has no `CHANNEL_TOKEN_KEY` configured.

        **WhatsApp** (default) — one of two credential paths, both filling the
        same write-only secret:

        * **BYOT** (`credential_source: byot`, default) — supply
          `phone_number_id` and `access_token` from your own Meta app.

        * **Embedded signup** (`credential_source: embedded_signup`) — supply
          `code` and `waba_id` (and optionally `pin`) from the Meta popup;
          naturali exchanges the `code` for the token and subscribes its app to
          the WABA, so the customer never hands over a token. Returns `501` on
          deployments where Meta App credentials are not configured.
      operationId: createChannel
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChannelCreate'
      responses:
        '201':
          description: Channel created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Channel'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '501':
          $ref: '#/components/responses/NotImplemented'
        '502':
          $ref: '#/components/responses/UpstreamUnavailable'
  /v1/projects/{project_id}/channels/{channel_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/ChannelId'
    get:
      tags: [Channels]
      summary: Get a channel
      operationId: getChannel
      responses:
        '200':
          description: Channel details.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Channel'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Channels]
      summary: Update a channel
      description: >
        Rotate the credential, update the kind's config (`waba_id` /
        `credential_source` for WhatsApp, `modes` for Discord), or flip the
        naturali-side status. At least one field is required. Rotating a
        WhatsApp token stores a new write-only secret, repoints the send tool
        and deletes the old secret; rotating a Discord bot token reseals it and
        the gateway worker reconnects with it.
      operationId: updateChannel
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChannelUpdate'
      responses:
        '200':
          description: Channel updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Channel'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          $ref: '#/components/responses/UpstreamUnavailable'
    delete:
      tags: [Channels]
      summary: Delete a channel
      description: >
        Deletes the channel and whatever it provisioned — the WhatsApp send tool
        and write-only credential secret, or the Discord channel's sealed bot
        token (dropped with the row, closing its gateway connection).
      operationId: deleteChannel
      responses:
        '204':
          description: Channel deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          $ref: '#/components/responses/UpstreamUnavailable'
  /v1/projects/{project_id}/channels/{channel_id}/conversations:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/ChannelId'
    get:
      tags: [Channels]
      summary: List the channel's conversations
      description: >
        A cursor page of the channel's conversations, newest first. A
        conversation is one address's dialogue on the channel — the continuity
        anchor that resumes the same runtime session instead of starting fresh per
        message — so this is the read path for who has talked to the channel and
        which actor/session their dialogue resolved to.

        Conversations are created by the inbound path (the WhatsApp webhook, the
        Discord gateway worker) when a real message arrives; there is no way to
        create one directly.
      operationId: listChannelConversations
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: identifier
          in: query
          required: false
          description: >
            Filter to one address by its bare, channel-native identifier (a
            phone number on WhatsApp; a Discord user id, or `thread:<id>` for a
            guild thread) — the same value the channel itself reports, not the
            prefixed form the `identifier` field above returns. At most one row
            matches.
          schema:
            type: string
            example: '425678901234567890'
      responses:
        '200':
          description: A page of conversations.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConversationList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/projects/{project_id}/channels/{channel_id}/conversations/{conversation_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/ChannelId'
      - $ref: '#/components/parameters/ConversationId'
    get:
      tags: [Channels]
      summary: Get a conversation
      operationId: getChannelConversation
      responses:
        '200':
          description: The conversation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Conversation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/projects/{project_id}/channels/{channel_id}/conversations/{conversation_id}/messages:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/ChannelId'
      - $ref: '#/components/parameters/ConversationId'
    get:
      tags: [Channels]
      summary: Read a conversation's transcript
      description: >
        The conversation's messages, oldest first. naturali stores no message
        bodies — the dialogue lives in the runtime session the conversation maps
        to, so this reads through to the runtime. Pagination is `limit`/`offset`
        rather than an opaque cursor because the upstream is offset-based over a
        stable `position` ordering.
      operationId: listChannelConversationMessages
      parameters:
        - $ref: '#/components/parameters/MessagesLimit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: A page of messages.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConversationMessageList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          $ref: '#/components/responses/UpstreamUnavailable'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: A naturali API key (nat_sk_…) or a session JWT.
  parameters:
    Limit:
      name: limit
      in: query
      required: false
      description: Maximum items per page — an integer from 1 to 100 (default 20).
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
    Cursor:
      name: cursor
      in: query
      required: false
      description: Opaque pagination cursor from a previous response's next_cursor.
      schema:
        type: string
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Client-supplied key to make this mutating POST idempotent.
      schema:
        type: string
    ProjectId:
      name: project_id
      in: path
      required: true
      description: Project public ID (proj_ prefix).
      schema:
        type: string
        example: proj_V1StGXR8Z5jdHi6B
    ChannelId:
      name: channel_id
      in: path
      required: true
      description: Channel public ID (chan_ prefix).
      schema:
        type: string
        example: chan_V1StGXR8Z5jdHi6B
    ConversationId:
      name: conversation_id
      in: path
      required: true
      description: Conversation public ID (conv_ prefix).
      schema:
        type: string
        example: conv_V1StGXR8Z5jdHi6B
    MessagesLimit:
      name: limit
      in: query
      required: false
      description: Maximum messages per page.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 50
    Offset:
      name: offset
      in: query
      required: false
      description: Number of messages to skip.
      schema:
        type: integer
        minimum: 0
        default: 0
  responses:
    Unauthorized:
      description: Missing or invalid credentials.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    BadRequest:
      description: The request was malformed or failed validation.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    NotFound:
      description: The resource does not exist (existence is not leaked).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Conflict:
      description: The request conflicts with the resource's current state.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    NotImplemented:
      description: >
        The requested path is not enabled on this deployment (e.g. embedded
        signup before Meta App credentials are configured, or a Discord channel
        before `CHANNEL_TOKEN_KEY` is set).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    UpstreamUnavailable:
      description: >
        The upstream runtime, or Meta, could not complete the operation.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
  schemas:
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              example: bad_request
            message:
              type: string
              example: '`phone_number_id` is required.'
            details:
              type: object
              additionalProperties: true
    Channel:
      type: object
      properties:
        id:
          type: string
          description: Public channel ID (chan_ prefix).
          example: chan_V1StGXR8Z5jdHi6B
        project_id:
          type: string
          example: proj_V1StGXR8Z5jdHi6B
        channel:
          type: string
          enum: [whatsapp, discord]
          example: whatsapp
        status:
          type: string
          enum: [active, disabled]
          example: active
        credential_source:
          type: string
          enum: [byot, embedded_signup]
          description: How the credential was obtained.
          example: byot
        phone_number_id:
          type: string
          nullable: true
          description: WhatsApp phone number id; inbound webhooks route on this. Null for non-WhatsApp channels.
          example: '109999999999999'
        waba_id:
          type: string
          nullable: true
          description: WhatsApp Business Account id, when known.
          example: '104444444444444'
        application_id:
          type: string
          nullable: true
          description: >
            Discord application id — the tenant's app, one channel per app. The
            gateway worker holds one connection per channel. Null for
            non-Discord channels.
          example: '1290000000000000000'
        modes:
          $ref: '#/components/schemas/DiscordModes'
        default:
          $ref: '#/components/schemas/ChannelDefaultAction'
        has_credential:
          type: boolean
          description: Whether an access token is on file (its value is never returned).
          example: true
        created_at:
          type: string
          format: date-time
          example: '2026-07-23T00:00:00.000Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-07-23T00:00:00.000Z'
      required:
        - id
        - project_id
        - channel
        - status
        - credential_source
        - phone_number_id
        - waba_id
        - application_id
        - modes
        - default
        - has_credential
        - created_at
        - updated_at
    ChannelCreate:
      type: object
      description: >
        Connect a channel. The required fields depend on `channel`:

        * **whatsapp** — `phone_number_id`, plus a credential by
          `credential_source`: `byot` (default) requires `access_token`;
          `embedded_signup` requires `code` and `waba_id`.

        * **discord** — `application_id` and `bot_token` (write-only), plus
          the optional `modes` opting into the gateway flows. Discord runs over
          the Gateway, so the bot token is kept in naturali's encrypted store
          (the worker must present it to authenticate its WebSocket) rather than
          as a secret on the runtime; deployments without `CHANNEL_TOKEN_KEY` configured
          return `501`.

        No credential value is ever returned. `default` is required
        (CHANNELS-ROUTING.md §3.3.1) — connecting a channel without saying
        what it does when nothing else matches is the misconfiguration, so it
        is caught here rather than discovered as silence in production.
      required:
        - default
      properties:
        default:
          $ref: '#/components/schemas/ChannelDefaultActionInput'
        channel:
          type: string
          enum: [whatsapp, discord]
          default: whatsapp
          example: whatsapp
        phone_number_id:
          type: string
          description: Required for `whatsapp`.
          example: '109999999999999'
        credential_source:
          type: string
          enum: [byot, embedded_signup]
          default: byot
          example: byot
        access_token:
          type: string
          description: >
            Write-only. Required for `byot`. The WhatsApp access token from your
            own Meta app. Accepted on write, never returned.
          example: EAAG...ZDZD
        code:
          type: string
          description: >
            Required for `embedded_signup`. The short-lived authorization code
            from the Meta popup; naturali exchanges it for the access token
            server-side. Never stored or returned.
          example: AQD...abc
        pin:
          type: string
          description: >
            Optional (`embedded_signup` only). The number's two-step-verification
            PIN; when present, naturali registers the number for Cloud API
            sending. Never stored or returned.
          example: '123456'
        waba_id:
          type: string
          description: Required for `embedded_signup`; optional for `byot`.
          example: '104444444444444'
        application_id:
          type: string
          description: Required for `discord`. The Discord application id.
          example: '1290000000000000000'
        bot_token:
          type: string
          description: >
            Write-only. Required for `discord`. The bot token, sealed in
            naturali's encrypted store so the gateway worker can authenticate
            its WebSocket. Accepted on write, never returned.
          example: 'MT2...bot-token'
        modes:
          $ref: '#/components/schemas/DiscordModes'
    ChannelUpdate:
      type: object
      description: >
        At least one field is required. For WhatsApp, `access_token` rotates the
        write-only credential; for Discord, `bot_token` rotates the sealed bot
        token and `modes` turns the gateway flows on or off. `default` replaces
        the channel's default action whole when present. The worker picks
        either change up on its next poll and reconnects.
      properties:
        default:
          $ref: '#/components/schemas/ChannelDefaultActionInput'
        access_token:
          type: string
          description: WhatsApp. Write-only. Replaces the stored credential.
          example: EAAG...ZDZD
        waba_id:
          type: string
          example: '104444444444444'
        credential_source:
          type: string
          enum: [byot, embedded_signup]
          example: embedded_signup
        bot_token:
          type: string
          description: Discord. Write-only. Rotates the sealed bot token.
          example: 'MT2...bot-token'
        modes:
          $ref: '#/components/schemas/DiscordModes'
        status:
          type: string
          enum: [active, disabled]
          example: disabled
    DiscordModes:
      type: object
      nullable: true
      description: >
        Which Discord Gateway flows this channel serves; null for non-Discord
        channels. At least one must be enabled.

        * `dms` — the bot converses 1:1 in direct messages (the WhatsApp shape).
          Needs only the non-privileged `DIRECT_MESSAGES` intent.

        * `mention_threads` — an @mention in a server opens a thread, and the
          whole thread is one conversation. Reading the follow-up messages in
          the thread needs the **privileged** `MESSAGE_CONTENT` intent, which
          you enable on your own Discord app (Discord requires bot verification
          past ~100 servers), so it is off unless you ask for it.
      properties:
        dms:
          type: boolean
          default: true
          example: true
        mention_threads:
          type: boolean
          default: false
          example: false
    ChannelList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Channel'
        next_cursor:
          type: string
          nullable: true
          description: Cursor for the next page, or null when there are no more.
          example: null
    ChannelDefaultAction:
      type: object
      description: >
        What happens when no route matches and the address has no action of
        its own (CHANNELS-ROUTING.md §3.3.1) — the same shape a `route`
        (`routes.yaml`) and an `address` (`addresses.yaml`) carry. For a
        Discord channel, `config.discord.allowed_role_ids` /
        `allowed_user_ids` restrict who may invoke the agent in a server —
        either list admits, empty or absent lists mean everyone, decided
        before the agent is invoked so an unlisted member costs nothing.
      properties:
        action:
          type: string
          enum: [agent, message, silence]
          example: agent
        agent_id:
          type: string
          nullable: true
          description: The agent that answers, when `action` is `agent`.
          example: agent_V1StGXR8Z5jdHi6B
        text:
          type: string
          nullable: true
          description: The fixed text delivered, when `action` is `message`.
          example: 'Subscribe at https://example.com/pricing'
        repeat:
          description: >
            Null unless `action` is `message`. How often it fires
            (CHANNELS-ROUTING.md §3.3.3): `every`
            (default), `once`, or `{ after_seconds }`. Null unless `action` is
            `message`.
          oneOf:
            - type: string
              enum: [every, once]
            - type: object
              required: [after_seconds]
              properties:
                after_seconds:
                  type: integer
                  minimum: 1
          example: every
        language:
          type: string
          nullable: true
          description: Preferred reply language; null lets the agent decide.
          example: pt-BR
        config:
          type: object
          nullable: true
          additionalProperties: true
          description: Conversation config bag — media handling, persona overrides, the Discord allowlist.
          example:
            media:
              audio: transcribe
            discord:
              allowed_role_ids: ['1290000000000000042']
      required:
        - action
        - agent_id
        - text
        - repeat
        - language
        - config
    ChannelDefaultActionInput:
      type: object
      description: >
        `action` is required; `agent_id` is required when it is `agent`,
        `text` when it is `message`. `repeat` is only valid alongside
        `action: message`.
      required:
        - action
      properties:
        action:
          type: string
          enum: [agent, message, silence]
          example: agent
        agent_id:
          type: string
          example: agent_V1StGXR8Z5jdHi6B
        text:
          type: string
          example: 'Subscribe at https://example.com/pricing'
        repeat:
          oneOf:
            - type: string
              enum: [every, once]
            - type: object
              required: [after_seconds]
              properties:
                after_seconds:
                  type: integer
                  minimum: 1
          example: every
        language:
          type: string
          example: pt-BR
        config:
          type: object
          additionalProperties: true
          example:
            discord:
              allowed_role_ids: ['1290000000000000042']
    Conversation:
      type: object
      description: >
        An address's dialogue on a channel — naturali-native, keyed by
        (channel, address) so the same identifier never opens two
        conversations, and mapped 1:1 to the runtime session that carries the
        message history.
      required:
        [
          id,
          project_id,
          channel_id,
          address_id,
          route_id,
          identifier,
          actor_id,
          session_id,
          created_at,
          updated_at,
        ]
      properties:
        id:
          type: string
          example: conv_V1StGXR8Z5jdHi6B
        project_id:
          type: string
          example: proj_V1StGXR8Z5jdHi6B
        channel_id:
          type: string
          example: chan_V1StGXR8Z5jdHi6B
        address_id:
          type: string
          description: >
            The address the dialogue arrived at — the continuity key.
          example: addr_V1StGXR8Z5jdHi6B
        route_id:
          type: string
          description: >
            The route this conversation's action came from — a real route id,
            or one of the two sentinels for "the address's own action" /
            "the channel default" (CHANNELS-ROUTING.md §3.8). A message
            resolving to a different action opens a new conversation rather
            than re-pointing this one.
          example: route_V1StGXR8Z5jdHi6B
        identifier:
          type: string
          nullable: true
          description: >
            The prefixed, self-describing channel identifier (`whatsapp:dm:…`,
            `discord:dm:…`, `discord:thread:…`). Read through from the address
            above.
          example: 'whatsapp:dm:425678901234567890'
        actor_id:
          type: string
          nullable: true
          description: >
            The runtime actor the address speaks as. Null when the address has
            not needed one yet.
          example: actor_V1StGXR8Z5jdHi6B
        session_id:
          type: string
          description: The runtime session this conversation maps 1:1 to.
          example: sess_V1StGXR8Z5jdHi6B
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ConversationList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Conversation'
        next_cursor:
          type: string
          nullable: true
          description: Cursor for the next page, or null when there are no more.
          example: null
    ConversationMessage:
      type: object
      description: One message in the conversation's transcript, as the runtime records it.
      properties:
        document_id:
          type: string
          nullable: true
          description: The runtime document holding the message text.
        role:
          type: string
          nullable: true
          enum: [user, assistant, system, null]
          example: user
        content:
          type: string
          nullable: true
          example: como isso difere de uma orquestração?
        position:
          type: integer
          nullable: true
          description: Zero-based position in the conversation.
          example: 0
        actor_id:
          type: string
          nullable: true
          description: The actor who authored the message, when set.
        agent_id:
          type: string
          nullable: true
          description: The agent that produced the message, for assistant turns.
        metadata:
          type: object
          nullable: true
          additionalProperties: true
    ConversationMessageList:
      type: object
      required: [data, total, limit, offset]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/ConversationMessage'
        total:
          type: integer
          nullable: true
          description: Total messages in the conversation, when the upstream reports it.
          example: 4
        limit:
          type: integer
          example: 50
        offset:
          type: integer
          example: 0
