openapi: 3.1.0
info:
  title: Aibo Platform API
  version: 0.1.0
  summary: Multi-tenant API for tenant-owned and user-owned AI agents with DM and group chat.
  description: |
    ## Principals

    Every request is authenticated as one of:

    | Principal | Credential | Acts as |
    |---|---|---|
    | **Tenant** | `Authorization: Bearer ak_live_…` (API key, server-to-server only) | The tenant. Manages tenant agents, users, webhooks, usage. |
    | **Tenant on behalf of a user** | API key + `X-Aibo-User: {externalId}` | That end user, from the tenant's backend. |
    | **End user** | `Authorization: Bearer au_…` (minted via `POST /v1/users/{externalId}/tokens`) | That end user, safe to hand to a browser or mobile app. |

    API keys must never be shipped to clients. User tokens are short-lived and scoped to one user in one tenant.

    ## Scope rules

    * `/v1/agents` called as the **tenant** lists and creates *tenant* agents (`scope: tenant`).
    * `/v1/agents` called as a **user** lists tenant agents plus the user's own agents, and creates *user* agents (`scope: user`).
      Users may update or delete only their own agents.
    * Chats belong to exactly one user (one human per chat in v0). Members are that user plus tenant agents and/or the user's agents.

    ## Reply routing

    After a user message is stored, the platform decides which agents reply:
    1. agents explicitly `@mentioned`, in mention order;
    2. otherwise the agent in a DM;
    3. otherwise, in a group, one agent picked by the orchestrator.

    ## Errors

    Errors use a single envelope: `{ "error": { "code", "message", "details?" } }`.
    `402 payment_required` means the user's seat reached the tenant's token cap for the period
    (prompt + completion tokens in the current UTC calendar month ≥ `seatTokenCap`); the message is not stored.
    `429 rate_limited` (with `Retry-After`) is returned above ~120 requests/minute per principal.
    With an API key or console session, user-only endpoints (`/v1/chats…`) require `X-Aibo-User` (400 without it);
    an unknown `X-Aibo-User` is 404.

    ## Read state, reactions and presence

    * `Chat.unreadCount` counts agent and system messages after the user's read position; `POST /v1/chats/{chatId}/read` moves it
      (default: to the latest message; it never moves backwards). `Chat.lastMessage` previews the newest message.
    * Users react to messages with one emoji each via `PUT`/`DELETE …/messages/{messageId}/reactions/{emoji}`; both are idempotent.
      `Message.reactions` groups them by emoji, with `mine` relative to the acting user. Changes emit the `reaction.updated` webhook.
    * `Agent.online` is reserved for real presence. In v1 it is `true` unless the agent is archived.

    ## Moderation

    User messages containing a word or phrase from the tenant `blocklist` are rejected with `400 bad_request`.
servers:
  - url: https://api.heyaibo.com
  - url: http://localhost:4000
security:
  - apiKey: []
  - userToken: []

tags:
  - name: Console
  - name: Tenant
  - name: Billing
  - name: Users
  - name: Agents
  - name: Chats
  - name: Messages
  - name: Webhooks
  - name: Usage

paths:
  /v1/console/auth-config:
    get:
      tags: [Console]
      operationId: consoleAuthConfig
      summary: Which console sign-in methods this deployment offers.
      description: |
        `passwordLogin` mirrors `CONSOLE_PASSWORD_LOGIN` (default on outside production, off in production).
        `clerk` is true when the API has `CLERK_SECRET_KEY` or `CLERK_JWT_KEY`.
      security: []
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ConsoleAuthConfig' } } } }
        default: { $ref: '#/components/responses/Error' }

  /v1/console/signup:
    post:
      tags: [Console]
      operationId: consoleSignup
      summary: Create a tenant and its first admin. Returns a console session.
      description: |
        Two forms:

        * **Password**: `email` + `password`. Only when password login is enabled (`CONSOLE_PASSWORD_LOGIN`), else `403`.
        * **Clerk**: `clerkToken` (a Clerk session token). The admin's email is the Clerk user's *verified* primary
          email and the admin is linked to the Clerk user. `403` if the email is not verified, `409` if the Clerk user
          already has a workspace or the email or slug is taken, `501` if Clerk is not configured.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  title: PasswordSignup
                  required: [email, password, tenantName, tenantSlug]
                  properties:
                    email: { type: string, format: email }
                    password: { type: string, minLength: 10, maxLength: 200 }
                    tenantName: { type: string, minLength: 1, maxLength: 80 }
                    tenantSlug: { type: string, pattern: '^[a-z0-9-]{2,40}$' }
                - type: object
                  title: ClerkSignup
                  required: [clerkToken, tenantName, tenantSlug]
                  properties:
                    clerkToken: { type: string, minLength: 20, maxLength: 8192, description: Clerk session token (JWT). }
                    tenantName: { type: string, minLength: 1, maxLength: 80 }
                    tenantSlug: { type: string, pattern: '^[a-z0-9-]{2,40}$' }
      responses:
        '201': { description: Created, content: { application/json: { schema: { $ref: '#/components/schemas/ConsoleSession' } } } }
        default: { $ref: '#/components/responses/Error' }

  /v1/console/login:
    post:
      tags: [Console]
      operationId: consoleLogin
      summary: Log an admin in with email and password. Returns a console session.
      description: Only when password login is enabled (`CONSOLE_PASSWORD_LOGIN`); otherwise `403 forbidden`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string }
      responses:
        '200': { description: Logged in, content: { application/json: { schema: { $ref: '#/components/schemas/ConsoleSession' } } } }
        default: { $ref: '#/components/responses/Error' }

  /v1/console/clerk/exchange:
    post:
      tags: [Console]
      operationId: consoleClerkExchange
      summary: Exchange a Clerk session token for a console session.
      description: |
        Verifies the Clerk session token (signature, expiry, `azp` against `CLERK_AUTHORIZED_PARTIES`, and `iss` when
        `CLERK_PUBLISHABLE_KEY` is set). Then:

        1. An admin already linked to the Clerk user gets a session (`status: signed_in`).
        2. Otherwise the admin whose email equals the user's **verified** primary email is linked to the Clerk user
           (once) and gets a session. `409` if that admin is linked to a different Clerk user.
        3. Otherwise `status: needs_tenant`: create a workspace with `consoleSignup` and the same `clerkToken`.

        `401` for an invalid or expired token, `403` when the primary email is not verified, `501` when Clerk is not
        configured.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [clerkToken]
              properties:
                clerkToken: { type: string, minLength: 20, maxLength: 8192, description: Clerk session token (JWT). }
      responses:
        '200': { description: 'Signed in, or a workspace is needed', content: { application/json: { schema: { $ref: '#/components/schemas/ConsoleClerkExchangeResult' } } } }
        default: { $ref: '#/components/responses/Error' }

  /v1/api-keys:
    get:
      tags: [Console]
      operationId: listApiKeys
      summary: List API keys (secrets are never returned after creation).
      security: [{ consoleSession: [] }]
      responses:
        '200':
          description: Keys
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/ApiKey' } }
        default: { $ref: '#/components/responses/Error' }
    post:
      tags: [Console]
      operationId: createApiKey
      summary: Create an API key. The secret is returned once.
      security: [{ consoleSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label]
              properties:
                label: { type: string, minLength: 1, maxLength: 60 }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: '#/components/schemas/ApiKey' }
                  - type: object
                    required: [secret]
                    properties:
                      secret: { type: string, example: ak_live_3f9a… }
        default: { $ref: '#/components/responses/Error' }

  /v1/api-keys/{apiKeyId}:
    parameters:
      - name: apiKeyId
        in: path
        required: true
        schema: { type: string }
    delete:
      tags: [Console]
      operationId: revokeApiKey
      summary: Revoke an API key immediately.
      security: [{ consoleSession: [] }]
      responses:
        '204': { description: Revoked }
        default: { $ref: '#/components/responses/Error' }

  /v1/billing/checkout:
    post:
      tags: [Billing]
      operationId: createCheckout
      summary: Start a Stripe Checkout session for per-seat billing. 501 when billing is not configured.
      description: |
        Without `STRIPE_SECRET_KEY`/`STRIPE_PRICE_ID` returns HTTP 501 with `{ "error": { "code": "bad_request", "message": "billing not configured" } }`. `quantity` = `seats`.
      security: [{ consoleSession: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                seats: { type: integer, minimum: 1, default: 10 }
                successUrl: { type: string, format: uri }
                cancelUrl: { type: string, format: uri }
      responses:
        '200':
          description: Checkout session
          content:
            application/json:
              schema:
                type: object
                required: [url]
                properties:
                  url: { type: string, format: uri }
        default: { $ref: '#/components/responses/Error' }

  /v1/billing/webhook:
    post:
      tags: [Billing]
      operationId: stripeWebhook
      summary: Stripe webhook receiver (signature-verified).
      description: |
        Verifies `Stripe-Signature` with `STRIPE_WEBHOOK_SECRET` (400 on failure; 501 when unset). Handles `checkout.session.completed` and `customer.subscription.*`, updating tenant `seats` and `subscriptionStatus`.
      security: []
      responses:
        '200': { description: Processed }
        default: { $ref: '#/components/responses/Error' }

  /v1/tenant:
    get:
      tags: [Tenant]
      operationId: getTenant
      summary: Get tenant.
      security: [{ apiKey: [] }, { consoleSession: [] }]
      responses:
        '200': { description: The tenant, content: { application/json: { schema: { $ref: '#/components/schemas/Tenant' } } } }
        default: { $ref: '#/components/responses/Error' }
    patch:
      tags: [Tenant]
      operationId: updateTenant
      summary: Update tenant.
      security: [{ apiKey: [] }, { consoleSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 1, maxLength: 80 }
                seatTokenCap: { type: integer, minimum: 0 }
                brand: { $ref: '#/components/schemas/Brand' }
                blocklist:
                  type: array
                  maxItems: 500
                  items: { type: string, minLength: 1, maxLength: 60 }
                  description: Replaces the blocklist. Entries are lowercased; matched as whole words (or as substrings for multi-word phrases).
      responses:
        '200': { description: Updated, content: { application/json: { schema: { $ref: '#/components/schemas/Tenant' } } } }
        default: { $ref: '#/components/responses/Error' }

  /v1/users/{externalId}:
    parameters: [{ $ref: '#/components/parameters/ExternalId' }]
    put:
      tags: [Users]
      operationId: upsertUser
      summary: Create or update an end user. Counts as a seat once the user sends a message in a billing period.
      security: [{ apiKey: [] }, { consoleSession: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                displayName: { type: string, maxLength: 80 }
      responses:
        '200': { description: Existing user updated, content: { application/json: { schema: { $ref: '#/components/schemas/User' } } } }
        '201': { description: User created, content: { application/json: { schema: { $ref: '#/components/schemas/User' } } } }
        default: { $ref: '#/components/responses/Error' }
    get:
      tags: [Users]
      operationId: getUser
      summary: Get user.
      security: [{ apiKey: [] }, { consoleSession: [] }]
      responses:
        '200': { description: The user, content: { application/json: { schema: { $ref: '#/components/schemas/User' } } } }
        default: { $ref: '#/components/responses/Error' }
    delete:
      tags: [Users]
      operationId: deleteUser
      summary: Permanently delete the user, their agents, chats and messages.
      security: [{ apiKey: [] }, { consoleSession: [] }]
      responses:
        '204': { description: Deleted }
        default: { $ref: '#/components/responses/Error' }

  /v1/users/{externalId}/tokens:
    parameters: [{ $ref: '#/components/parameters/ExternalId' }]
    post:
      tags: [Users]
      operationId: createUserToken
      summary: Mint a short-lived bearer token that lets a client act as this user.
      security: [{ apiKey: [] }, { consoleSession: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                ttlSeconds: { type: integer, minimum: 60, maximum: 86400, default: 3600 }
      responses:
        '201':
          description: Token minted
          content:
            application/json:
              schema:
                type: object
                required: [token, expiresAt]
                properties:
                  token: { type: string, example: au_eyJ0Ijoi… }
                  expiresAt: { type: string, format: date-time }
        default: { $ref: '#/components/responses/Error' }

  /v1/agents:
    get:
      tags: [Agents]
      operationId: listAgents
      summary: List agents.
      parameters:
        - { $ref: '#/components/parameters/ActAsUser' }
        - { $ref: '#/components/parameters/Cursor' }
        - { $ref: '#/components/parameters/Limit' }
        - name: scope
          in: query
          schema: { $ref: '#/components/schemas/AgentScope' }
        - name: includeArchived
          in: query
          schema: { type: boolean, default: false }
      responses:
        '200': { description: Agents, content: { application/json: { schema: { $ref: '#/components/schemas/AgentPage' } } } }
        default: { $ref: '#/components/responses/Error' }
    post:
      tags: [Agents]
      operationId: createAgent
      summary: Create a tenant agent (as tenant) or a personal agent (as user). The handle is derived from the name and deduplicated.
      parameters: [{ $ref: '#/components/parameters/ActAsUser' }]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/CreateAgentInput' } } }
      responses:
        '201': { description: Created, content: { application/json: { schema: { $ref: '#/components/schemas/Agent' } } } }
        default: { $ref: '#/components/responses/Error' }

  /v1/agents/{agentId}:
    parameters:
      - { $ref: '#/components/parameters/AgentId' }
      - { $ref: '#/components/parameters/ActAsUser' }
    get:
      tags: [Agents]
      operationId: getAgent
      summary: Get agent.
      responses:
        '200': { description: The agent, content: { application/json: { schema: { $ref: '#/components/schemas/Agent' } } } }
        default: { $ref: '#/components/responses/Error' }
    patch:
      tags: [Agents]
      operationId: updateAgent
      summary: Update an agent. Renaming does not change its handle.
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/UpdateAgentInput' } } }
      responses:
        '200': { description: Updated, content: { application/json: { schema: { $ref: '#/components/schemas/Agent' } } } }
        default: { $ref: '#/components/responses/Error' }
    delete:
      tags: [Agents]
      operationId: archiveAgent
      summary: Archive an agent. It stops replying and leaves its chats; its past messages are kept.
      responses:
        '204': { description: Archived }
        default: { $ref: '#/components/responses/Error' }

  /v1/chats:
    get:
      tags: [Chats]
      operationId: listChats
      summary: The user's chats, most recent activity first.
      parameters:
        - { $ref: '#/components/parameters/ActAsUser' }
        - { $ref: '#/components/parameters/Cursor' }
        - { $ref: '#/components/parameters/Limit' }
        - name: type
          in: query
          schema: { $ref: '#/components/schemas/ChatType' }
      responses:
        '200': { description: Chats, content: { application/json: { schema: { $ref: '#/components/schemas/ChatPage' } } } }
        default: { $ref: '#/components/responses/Error' }
    post:
      tags: [Chats]
      operationId: createChat
      summary: Start a DM (exactly one agent) or a group (2–7 agents). Creating a DM with an agent that already has one returns the existing chat.
      description: |
        Agents must be non-archived tenant agents or the acting user's own agents; otherwise `400 bad_request` with `details.agentIds` listing the rejected ids.
      parameters: [{ $ref: '#/components/parameters/ActAsUser' }]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/CreateChatInput' } } }
      responses:
        '200': { description: Existing DM returned, content: { application/json: { schema: { $ref: '#/components/schemas/Chat' } } } }
        '201': { description: Created, content: { application/json: { schema: { $ref: '#/components/schemas/Chat' } } } }
        default: { $ref: '#/components/responses/Error' }

  /v1/chats/{chatId}:
    parameters:
      - { $ref: '#/components/parameters/ChatId' }
      - { $ref: '#/components/parameters/ActAsUser' }
    get:
      tags: [Chats]
      operationId: getChat
      summary: Get chat.
      responses:
        '200': { description: The chat, content: { application/json: { schema: { $ref: '#/components/schemas/Chat' } } } }
        default: { $ref: '#/components/responses/Error' }
    delete:
      tags: [Chats]
      operationId: deleteChat
      summary: Delete chat.
      responses:
        '204': { description: Deleted }
        default: { $ref: '#/components/responses/Error' }

  /v1/chats/{chatId}/read:
    parameters:
      - { $ref: '#/components/parameters/ChatId' }
      - { $ref: '#/components/parameters/ActAsUser' }
    post:
      tags: [Chats]
      operationId: markChatRead
      summary: Mark the chat read up to a message.
      description: |
        Sets the acting user's read position to `lastReadMessageId`, or to the chat's latest message when omitted
        (a no-op in an empty chat). The position never moves backwards: an older message leaves it unchanged.
        `404` if the message is not in this chat. Returns the chat with its new `unreadCount`.
      requestBody:
        required: false
        content: { application/json: { schema: { $ref: '#/components/schemas/MarkReadInput' } } }
      responses:
        '200': { description: The chat, content: { application/json: { schema: { $ref: '#/components/schemas/Chat' } } } }
        default: { $ref: '#/components/responses/Error' }

  /v1/chats/{chatId}/messages:
    parameters:
      - { $ref: '#/components/parameters/ChatId' }
      - { $ref: '#/components/parameters/ActAsUser' }
    get:
      tags: [Messages]
      operationId: listMessages
      summary: Messages newest first. Pass `nextCursor` to page backwards.
      parameters:
        - { $ref: '#/components/parameters/Cursor' }
        - { $ref: '#/components/parameters/Limit' }
      responses:
        '200': { description: Messages, content: { application/json: { schema: { $ref: '#/components/schemas/MessagePage' } } } }
        default: { $ref: '#/components/responses/Error' }
    post:
      tags: [Messages]
      operationId: sendMessage
      summary: Send a user message and trigger agent replies.
      description: |
        With `Accept: text/event-stream` the response streams the reply as server-sent events (see `StreamEvent`).
        Otherwise the call returns `202` once the user message is stored; replies arrive via
        `GET …/messages` and the `message.created` webhook.
      parameters:
        - name: Idempotency-Key
          in: header
          schema: { type: string, maxLength: 128 }
          description: |
            Scoped to tenant + acting user and kept for 24h. Retrying with the same key returns the original `202` JSON body
            (header `Idempotent-Replayed: true`) instead of sending twice, even when the retry asks for `text/event-stream`.
            A retry while the first request is still being accepted, or a reuse of the key with a different chat/body, is `409 conflict`.
            If the first request failed (e.g. 402), the key is released and may be retried.
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/SendMessageInput' } } }
      responses:
        '202':
          description: User message stored; replies are being generated.
          content:
            application/json:
              schema:
                type: object
                required: [message, responders]
                properties:
                  message: { $ref: '#/components/schemas/Message' }
                  responders:
                    type: array
                    description: Agent ids that will reply, in order.
                    items: { type: string }
            text/event-stream:
              schema: { $ref: '#/components/schemas/StreamEvent' }
        default: { $ref: '#/components/responses/Error' }

  /v1/chats/{chatId}/messages/{messageId}/replies:
    parameters:
      - { $ref: '#/components/parameters/ChatId' }
      - name: messageId
        in: path
        required: true
        schema: { type: string }
      - { $ref: '#/components/parameters/ActAsUser' }
    post:
      tags: [Messages]
      operationId: regenerateReplies
      summary: Generate the replies to one of the user's own messages again, without storing the message twice.
      description: |
        Use after a send whose stream ended with an `error` event, or to regenerate. Responders are routed
        from the message content exactly like `sendMessage`. With `Accept: text/event-stream` it streams the same
        `StreamEvent` sequence, starting with the existing message's `message.created`; otherwise it returns `202`.
        Only the user who sent the message may call it (404 otherwise). Subject to the seat token cap (402).
      responses:
        '202':
          description: Replies are being generated.
          content:
            application/json:
              schema:
                type: object
                required: [message, responders]
                properties:
                  message: { $ref: '#/components/schemas/Message' }
                  responders: { type: array, items: { type: string } }
            text/event-stream:
              schema: { $ref: '#/components/schemas/StreamEvent' }
        default: { $ref: '#/components/responses/Error' }

  /v1/chats/{chatId}/messages/{messageId}/reactions/{emoji}:
    parameters:
      - { $ref: '#/components/parameters/ChatId' }
      - name: messageId
        in: path
        required: true
        schema: { type: string }
      - name: emoji
        in: path
        required: true
        description: One emoji grapheme (e.g. `👍`, `🇯🇵`, `👩‍💻`), URL-encoded, at most 16 UTF-8 bytes. Anything else is `400`.
        schema: { type: string, minLength: 1 }
        example: '%F0%9F%91%8D'
      - { $ref: '#/components/parameters/ActAsUser' }
    put:
      tags: [Messages]
      operationId: addReaction
      summary: Add the acting user's reaction to a message.
      description: Idempotent. Emits `reaction.updated` when the reaction was not already there. Returns the message with its reactions.
      responses:
        '200': { description: The message, content: { application/json: { schema: { $ref: '#/components/schemas/Message' } } } }
        default: { $ref: '#/components/responses/Error' }
    delete:
      tags: [Messages]
      operationId: removeReaction
      summary: Remove the acting user's reaction from a message.
      description: Idempotent. Emits `reaction.updated` when a reaction was removed. Returns the message with its reactions.
      responses:
        '200': { description: The message, content: { application/json: { schema: { $ref: '#/components/schemas/Message' } } } }
        default: { $ref: '#/components/responses/Error' }

  /v1/webhooks:
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: List webhooks.
      security: [{ apiKey: [] }, { consoleSession: [] }]
      responses:
        '200':
          description: Endpoints
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/WebhookEndpoint' } }
        default: { $ref: '#/components/responses/Error' }
    post:
      tags: [Webhooks]
      operationId: createWebhook
      summary: Register an HTTPS endpoint. The signing secret is returned once.
      security: [{ apiKey: [] }, { consoleSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: '#/components/schemas/WebhookEndpoint' }
                  - type: object
                    required: [secret]
                    properties:
                      secret: { type: string, description: 'HMAC-SHA256 key. Each delivery has `Aibo-Signature: t={unix},v1={hex}` over `{t}.{body}`.' }
        default: { $ref: '#/components/responses/Error' }

  /v1/webhooks/{webhookId}:
    parameters:
      - name: webhookId
        in: path
        required: true
        schema: { type: string }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      summary: Delete webhook.
      security: [{ apiKey: [] }, { consoleSession: [] }]
      responses:
        '204': { description: Deleted }
        default: { $ref: '#/components/responses/Error' }

  /v1/usage:
    get:
      tags: [Usage]
      operationId: getUsage
      summary: Token usage per user per day.
      security: [{ apiKey: [] }, { consoleSession: [] }]
      parameters:
        - { name: from, in: query, required: true, schema: { type: string, format: date } }
        - { name: to, in: query, required: true, schema: { type: string, format: date } }
        - { name: externalId, in: query, schema: { type: string } }
      responses:
        '200':
          description: Usage rows
          content:
            application/json:
              schema:
                type: object
                required: [seatTokenCap, data]
                properties:
                  seatTokenCap: { type: integer }
                  data:
                    type: array
                    items:
                      type: object
                      required: [externalId, day, promptTokens, completionTokens]
                      properties:
                        externalId: { type: string }
                        day: { type: string, format: date }
                        promptTokens: { type: integer }
                        completionTokens: { type: integer }
        default: { $ref: '#/components/responses/Error' }

webhooks:
  message.created:
    post:
      operationId: messageCreatedWebhook
      summary: A message was stored in a chat (user or agent).
      description: |
        Headers: `Aibo-Signature: t={unix},v1={hex hmac_sha256(secret, t + "." + rawBody)}` (secret as UTF-8 bytes),
        `Aibo-Event-Id` and `Idempotency-Key` (both the event id). Retried with exponential backoff (10s doubling, max 1h) for 24h.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [id, type, createdAt, data]
              properties:
                id: { type: string, description: Event id; use it to deduplicate (delivery is at-least-once). }
                type: { const: message.created }
                createdAt: { type: string, format: date-time }
                data:
                  type: object
                  required: [chatId, ownerExternalId, message]
                  properties:
                    chatId: { type: string }
                    ownerExternalId: { type: string }
                    message: { $ref: '#/components/schemas/Message' }
      responses:
        '2XX': { description: Acknowledged. Non-2xx responses are retried with backoff for 24h. }
  reaction.updated:
    post:
      operationId: reactionUpdatedWebhook
      summary: A message's reactions changed.
      description: |
        Sent when a user adds or removes a reaction (not for idempotent repeats). `reactions` is the full new list;
        `mine` is relative to the chat owner. Signed and retried exactly like `message.created`.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [id, type, createdAt, data]
              properties:
                id: { type: string, description: Event id; use it to deduplicate (delivery is at-least-once). }
                type: { const: reaction.updated }
                createdAt: { type: string, format: date-time }
                data:
                  type: object
                  required: [chatId, ownerExternalId, messageId, reactions]
                  properties:
                    chatId: { type: string }
                    ownerExternalId: { type: string }
                    messageId: { type: string }
                    reactions: { type: array, items: { $ref: '#/components/schemas/Reaction' } }
      responses:
        '2XX': { description: Acknowledged. Non-2xx responses are retried with backoff for 24h. }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: Tenant API key (`ak_live_…` / `ak_test_…`). Server-to-server only.
    consoleSession:
      type: http
      scheme: bearer
      description: Admin console session (`ac_…`) from consoleSignup, consoleLogin or consoleClerkExchange. Has every tenant permission plus API-key and billing management.
    userToken:
      type: http
      scheme: bearer
      description: End-user token (`au_…`) from `POST /v1/users/{externalId}/tokens`.

  parameters:
    ExternalId:
      name: externalId
      in: path
      required: true
      schema: { $ref: '#/components/schemas/ExternalId' }
    AgentId:
      name: agentId
      in: path
      required: true
      schema: { type: string }
    ChatId:
      name: chatId
      in: path
      required: true
      schema: { type: string }
    ActAsUser:
      name: X-Aibo-User
      in: header
      required: false
      description: With an API key, act as this end user (externalId). Ignored for user tokens. Required for user-only endpoints when using an API key.
      schema: { $ref: '#/components/schemas/ExternalId' }
    Cursor:
      name: cursor
      in: query
      schema: { type: string }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }

  responses:
    Error:
      description: Error
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    ExternalId:
      type: string
      minLength: 1
      maxLength: 128
      pattern: '^[A-Za-z0-9_.:@-]+$'
    Handle:
      type: string
      pattern: '^[a-z0-9]{1,32}$'
    Tone:
      type: string
      enum: [warm, direct, playful, gentle, sharp]
    AgentScope:
      type: string
      enum: [tenant, user]
    ChatType:
      type: string
      enum: [dm, group]

    ConsoleSession:
      type: object
      required: [token, expiresAt, tenant, admin]
      properties:
        token: { type: string, example: ac_… }
        expiresAt: { type: string, format: date-time }
        tenant: { $ref: '#/components/schemas/Tenant' }
        admin:
          type: object
          required: [id, email]
          properties:
            id: { type: string }
            email: { type: string, format: email }

    ConsoleAuthConfig:
      type: object
      required: [passwordLogin, clerk]
      properties:
        passwordLogin: { type: boolean }
        clerk: { type: boolean }

    ConsoleClerkExchangeResult:
      oneOf:
        - title: SignedIn
          allOf:
            - $ref: '#/components/schemas/ConsoleSession'
            - type: object
              required: [status]
              properties:
                status: { type: string, const: signed_in }
        - title: NeedsTenant
          type: object
          required: [status, email]
          properties:
            status: { type: string, const: needs_tenant }
            email: { type: string, format: email, description: The verified email the new workspace's admin will use. }

    ApiKey:
      type: object
      required: [id, label, prefix, lastUsedAt, revokedAt, createdAt]
      properties:
        id: { type: string }
        label: { type: string }
        prefix: { type: string, description: "First characters of the key, for identification." }
        lastUsedAt: { type: [string, 'null'], format: date-time }
        revokedAt: { type: [string, 'null'], format: date-time }
        createdAt: { type: string, format: date-time }

    Brand:
      type: object
      properties:
        productName: { type: string, maxLength: 40 }
        accentColor: { type: string, pattern: '^#[0-9a-fA-F]{6}$' }
        logoUrl: { type: string, format: uri }

    Tenant:
      type: object
      required: [id, slug, name, brand, seatTokenCap, blocklist, seatsUsed, seats, subscriptionStatus, createdAt]
      properties:
        id: { type: string }
        slug: { type: string }
        name: { type: string }
        brand: { $ref: '#/components/schemas/Brand' }
        seatTokenCap: { type: integer, description: Tokens each seat may use per billing period (UTC calendar month). }
        blocklist: { type: array, items: { type: string }, description: Lowercased words/phrases rejected in user messages. }
        seatsUsed: { type: integer, description: Distinct users with token usage in the current UTC calendar month. }
        seats: { type: [integer, 'null'], description: Seats purchased through billing; null without a subscription. }
        subscriptionStatus: { type: [string, 'null'], description: "Stripe subscription status (e.g. active, past_due, canceled)." }
        createdAt: { type: string, format: date-time }

    User:
      type: object
      required: [id, externalId, displayName, createdAt]
      properties:
        id: { type: string }
        externalId: { $ref: '#/components/schemas/ExternalId' }
        displayName: { type: [string, 'null'] }
        createdAt: { type: string, format: date-time }

    Agent:
      type: object
      required: [id, scope, ownerExternalId, name, role, handle, avatarSeed, bio, tone, systemPromptOverride, archivedAt, createdAt, updatedAt, online]
      properties:
        id: { type: string }
        scope: { $ref: '#/components/schemas/AgentScope' }
        ownerExternalId: { type: [string, 'null'], description: Owner of a user agent; null for tenant agents. }
        name: { type: string }
        role: { type: string }
        handle: { $ref: '#/components/schemas/Handle' }
        avatarSeed: { type: string }
        bio: { type: string }
        tone: { $ref: '#/components/schemas/Tone' }
        systemPromptOverride: { type: [string, 'null'] }
        archivedAt: { type: [string, 'null'], format: date-time }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        online:
          type: boolean
          description: Reserved for real presence. In v1 it is always `true` unless the agent is archived.

    CreateAgentInput:
      type: object
      required: [name, role, bio, tone]
      properties:
        name: { type: string, minLength: 1, maxLength: 24 }
        role: { type: string, minLength: 1, maxLength: 40 }
        bio: { type: string, minLength: 1, maxLength: 240, description: What the agent helps with. }
        tone: { $ref: '#/components/schemas/Tone' }
        avatarSeed: { type: string, maxLength: 64, description: Defaults to a random seed. }
        systemPromptOverride: { type: string, maxLength: 8000, description: Replaces the generated bio + tone prompt. }

    UpdateAgentInput:
      type: object
      properties:
        name: { type: string, minLength: 1, maxLength: 24 }
        role: { type: string, minLength: 1, maxLength: 40 }
        bio: { type: string, minLength: 1, maxLength: 240 }
        tone: { $ref: '#/components/schemas/Tone' }
        avatarSeed: { type: string, maxLength: 64 }
        systemPromptOverride: { type: [string, 'null'], maxLength: 8000 }

    ChatMember:
      oneOf:
        - type: object
          required: [type, externalId]
          properties:
            type: { const: user }
            externalId: { $ref: '#/components/schemas/ExternalId' }
        - type: object
          required: [type, agentId, handle]
          properties:
            type: { const: agent }
            agentId: { type: string }
            handle: { $ref: '#/components/schemas/Handle' }
      discriminator: { propertyName: type }

    Chat:
      type: object
      required: [id, type, name, members, lastMessageAt, createdAt, unreadCount, lastMessage]
      properties:
        id: { type: string }
        type: { $ref: '#/components/schemas/ChatType' }
        name: { type: [string, 'null'] }
        members: { type: array, items: { $ref: '#/components/schemas/ChatMember' } }
        lastMessageAt: { type: [string, 'null'], format: date-time }
        createdAt: { type: string, format: date-time }
        unreadCount:
          type: integer
          minimum: 0
          description: Agent and system messages after the acting user's read position (all of them if never read).
        lastMessage:
          description: The newest message, or null for an empty chat.
          oneOf:
            - { $ref: '#/components/schemas/MessagePreview' }
            - { type: 'null' }

    MessagePreview:
      type: object
      required: [id, sender, content, createdAt]
      properties:
        id: { type: string }
        sender: { $ref: '#/components/schemas/MessageSender' }
        content: { type: string, maxLength: 140, description: The first 140 characters of the message. }
        createdAt: { type: string, format: date-time }

    MarkReadInput:
      type: object
      properties:
        lastReadMessageId: { type: string, description: "Defaults to the chat's latest message." }

    CreateChatInput:
      type: object
      required: [type, agentIds]
      properties:
        type: { $ref: '#/components/schemas/ChatType' }
        name: { type: string, minLength: 1, maxLength: 60 }
        agentIds:
          type: array
          minItems: 1
          maxItems: 7
          uniqueItems: true
          items: { type: string }
          description: Exactly one for a dm; two or more for a group.

    MessageSender:
      oneOf:
        - type: object
          required: [type, externalId]
          properties:
            type: { const: user }
            externalId: { $ref: '#/components/schemas/ExternalId' }
        - type: object
          required: [type, agentId]
          properties:
            type: { const: agent }
            agentId: { type: string }
        - type: object
          required: [type]
          properties:
            type: { const: system }
      discriminator: { propertyName: type }

    Reaction:
      type: object
      required: [emoji, count, mine]
      properties:
        emoji: { type: string, example: 👍 }
        count: { type: integer, minimum: 1 }
        mine: { type: boolean, description: Whether the acting user reacted with this emoji. }

    Message:
      type: object
      required: [id, chatId, sender, kind, content, createdAt, reactions]
      properties:
        id: { type: string }
        chatId: { type: string }
        sender: { $ref: '#/components/schemas/MessageSender' }
        kind: { type: string, enum: [text, system] }
        content: { type: string }
        createdAt: { type: string, format: date-time }
        reactions:
          type: array
          description: Grouped by emoji, in order of first reaction.
          items: { $ref: '#/components/schemas/Reaction' }

    SendMessageInput:
      type: object
      required: [content]
      properties:
        content: { type: string, minLength: 1, maxLength: 8000 }

    StreamEvent:
      description: |
        Server-sent events emitted by `sendMessage` with `Accept: text/event-stream`. Each SSE `event:` name matches `type`.
        Order: `message.created` (user message) → for each responder: `reply.started` → `reply.delta`* → `reply.completed` → finally `done`.
        If generation fails, an `error` event is sent instead of the remaining events and the stream ends (no `done`).
      oneOf:
        - type: object
          required: [type, message]
          properties:
            type: { const: message.created }
            message: { $ref: '#/components/schemas/Message' }
        - type: object
          required: [type, agentId, replyId]
          properties:
            type: { const: reply.started }
            agentId: { type: string }
            replyId: { type: string }
        - type: object
          required: [type, replyId, text]
          properties:
            type: { const: reply.delta }
            replyId: { type: string }
            text: { type: string }
        - type: object
          required: [type, message]
          properties:
            type: { const: reply.completed }
            message: { $ref: '#/components/schemas/Message' }
        - type: object
          required: [type]
          properties:
            type: { const: done }
        - type: object
          required: [type, error]
          properties:
            type: { const: error }
            error: { $ref: '#/components/schemas/Error/properties/error' }
      discriminator: { propertyName: type }

    WebhookEndpoint:
      type: object
      required: [id, url, createdAt]
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        createdAt: { type: string, format: date-time }

    AgentPage:
      type: object
      required: [data, nextCursor]
      properties:
        data: { type: array, items: { $ref: '#/components/schemas/Agent' } }
        nextCursor: { type: [string, 'null'] }
    ChatPage:
      type: object
      required: [data, nextCursor]
      properties:
        data: { type: array, items: { $ref: '#/components/schemas/Chat' } }
        nextCursor: { type: [string, 'null'] }
    MessagePage:
      type: object
      required: [data, nextCursor]
      properties:
        data: { type: array, items: { $ref: '#/components/schemas/Message' } }
        nextCursor: { type: [string, 'null'] }

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum: [bad_request, unauthorized, forbidden, not_found, conflict, payment_required, rate_limited, internal]
            message: { type: string }
            details: {}
