openapi: 3.0.3
info:
  title: Chief Public API
  version: "1.0.0"
  contact:
    name: Chief Support
    url: https://chief.bot
  description: |
    Chief's public REST API for programmatic access to chats and assets.

    All endpoints are authenticated with a Personal Access Token (PAT)
    sent in the `X-API-Key` header. PATs are minted out-of-band from the
    Chief settings UI.

    All endpoints that touch project-scoped resources require the
    `X-Project-Id` header. Missing or invalid project IDs respond with HTTP
    400 and the error code `publicapi.tenancy.project.missing`.

    ## Error envelope

    Every error response uses the same JSON envelope:

    ```json
    { "code": "publicapi.auth.invalid",
      "humane": "The provided API key is invalid or has been revoked.",
      "statusCode": 401 }
    ```

    `code` is a stable, dotted, lowercase identifier suitable for client-side
    i18n and analytics. `humane` is plain English safe to surface to end
    users. `statusCode` mirrors the HTTP status.

    ## Chat workflow — kickoff + poll

    Chats are asynchronous in v1. The API doesn't expose a lifecycle
    enum — `cp.messages` doesn't persist one — so callers poll the
    per-message endpoint until the content they need is present.

    1. `POST /v1/chats` with `{prompt}`. The server kicks off the
       workflow and returns `{chat_id, message_id, created_at}`
       immediately. `message_id` is the message you'll poll.
    2. `POST /v1/chats/{chat_id}/messages` with `{prompt}` to
       continue an existing chat — appends a new turn and returns
       `{message_id, created_at}`.
    3. `GET /v1/chats/{chat_id}/messages/{message_id}` to read the
       message. While the workflow is still writing, `prompt` and
       `response` are omitted; once written they're present. Treat the
       appearance of `response` as the signal that the assistant has
       produced output.
    4. `GET /v1/chats/{chat_id}` returns chat-level metadata only
       (`modified_at`). It does not carry messages.
    5. `GET /v1/chats/{chat_id}/messages` lists every message's id +
       `created_at` (no content); clients fetch one message at a time via
       `GET /v1/chats/{chat_id}/messages/{mid}` for the prompt + response
       body.

    ## Asset workflow — three-step upload

    Assets are uploaded directly to blob storage via a signed URL. The flow
    is:

    1. `POST /v1/assets` with `{filename, mime_type}`. The server returns
       `{asset_id, upload_url, upload_method:"PUT", upload_headers,
       expires_at}`.
    2. `PUT` the file bytes to `upload_url`, applying every header in
       `upload_headers` verbatim. The URL is valid until `expires_at`.
    3. `POST /v1/assets/{asset_id}/complete` with an empty body to
       hand the asset off to the ingest pipeline. The server reads the
       uploaded object's size and MD5 from blob storage at this step. The
       response carries the current `AssetResponse`; clients poll until
       `status` is `ready` or `failed`.

servers:
  - url: "{baseUrl}"
    description: Chief
    variables:
      baseUrl:
        default: https://api.storytell.ai
        description: Base URL of the deployment serving the API.

security:
  - apiKey: []

tags:
  - name: chats
    description: Public chat lifecycle — kickoff, list, fetch.
  - name: assets
    description: Public asset upload + ingest lifecycle.
  - name: labels
    description: Public label CRUD and asset-label attachment.

paths:
  /v1/chats:
    post:
      tags: [chats]
      summary: Start a chat
      description: |
        Starts a new chat. Returns immediately with the new `chat_id` +
        `message_id`; poll `GET /v1/chats/{chat_id}/messages/{message_id}`
        until that message's status is `done` or `error`. To append a turn
        to an existing chat, use `POST /v1/chats/{id}/messages`.
      operationId: createChat
      security:
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/XProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateChatRequest"
            example:
              prompt: "Summarize this quarter's product launches."
      responses:
        "202":
          description: Chat accepted; workflow running.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateChatResponse"
              example:
                chat_id: "chat_d8aad64dib2c7m3ae7j0"
                message_id: "message_d8aad64dib2c7m3ae7jg"
                created_at: "2026-05-23T10:15:30Z"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

    get:
      tags: [chats]
      summary: List chats
      description: |
        Returns chats accessible to the authenticated PAT in the focused
        project, newest first. Pagination is cursor-based: pass the previous
        response's `last_id` as `after_id` to fetch the next page.
        `has_more: false` means the end.
      operationId: listChats
      security:
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/XProjectId"
        - $ref: "#/components/parameters/AfterIDQuery"
        - $ref: "#/components/parameters/BeforeIDQuery"
        - $ref: "#/components/parameters/LimitQuery"
      responses:
        "200":
          description: Paginated list of chats.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ListChatsResponse"
              example:
                data:
                  - chat_id: "chat_d8aad64dib2c7m3ae7j0"
                    created_at: "2026-05-23T10:15:30Z"
                  - chat_id: "chat_d8aad64dib2c7m3ae7j1"
                    created_at: "2026-05-23T10:16:00Z"
                first_id: "chat_d8aad64dib2c7m3ae7j0"
                last_id: "chat_d8aad64dib2c7m3ae7j1"
                has_more: true
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/chats/{id}:
    get:
      tags: [chats]
      summary: Fetch chat metadata
      description: |
        Returns chat-level metadata only. Does not include messages — fetch
        them under `GET /v1/chats/{id}/messages` (list) or
        `GET /v1/chats/{id}/messages/{mid}` (single, with content).
      operationId: getChat
      security:
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/XProjectId"
        - in: path
          name: id
          required: true
          schema:
            type: string
            pattern: "^chat_[a-z0-9]{20}$"
          example: "chat_d8aad64dib2c7m3ae7j0"
          description: Chat identifier returned by `POST /v1/chats`.
      responses:
        "200":
          description: Chat metadata.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChatResponse"
              example:
                chat_id: "chat_d8aad64dib2c7m3ae7j0"
                modified_at: "2026-05-23T10:16:42Z"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/chats/{id}/messages:
    post:
      tags: [chats]
      summary: Send a message on an existing chat
      description: |
        Appends a new turn to the chat. Returns immediately with the new
        `message_id`; poll `GET /v1/chats/{id}/messages/{message_id}` for
        terminal status.

        Body shape mirrors `POST /v1/chats` minus the chat-creation
        fields — the chat id comes from the path.
      operationId: sendMessage
      security:
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/XProjectId"
        - in: path
          name: id
          required: true
          schema:
            type: string
            pattern: "^chat_[a-z0-9]{20}$"
          example: "chat_d8aad64dib2c7m3ae7j0"
          description: Chat identifier.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SendMessageRequest"
            example:
              prompt: "What about EMEA specifically?"
      responses:
        "202":
          description: Turn accepted; workflow running.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SendMessageResponse"
              example:
                message_id: "message_d8aad64dib2c7m3ae7jh"
                created_at: "2026-05-26T10:15:30Z"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

    get:
      tags: [chats]
      summary: List messages in a chat
      description: |
        Returns metadata for every message in the chat. The response does
        not include message content; clients fetch one message at a time
        via `GET /v1/chats/{id}/messages/{mid}` when they need the body.
      operationId: listMessages
      security:
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/XProjectId"
        - in: path
          name: id
          required: true
          schema:
            type: string
            pattern: "^chat_[a-z0-9]{20}$"
          example: "chat_d8aad64dib2c7m3ae7j0"
          description: Chat identifier.
      responses:
        "200":
          description: Message metadata.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ListMessagesResponse"
              example:
                messages:
                  - id: "message_d8aad64dib2c7m3ae7j8"
                    created_at: "2026-05-23T10:15:30Z"
                  - id: "message_d8aad64dib2c7m3ae7jg"
                    created_at: "2026-05-23T10:15:31Z"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/chats/{id}/messages/{mid}:
    get:
      tags: [chats]
      summary: Fetch a single message
      description: |
        Returns one message. Poll until `response` (and `prompt`) appear —
        that's the signal the workflow has written the recording. While
        the workflow is still in flight, content fields are omitted.
      operationId: getMessage
      security:
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/XProjectId"
        - in: path
          name: id
          required: true
          schema:
            type: string
            pattern: "^chat_[a-z0-9]{20}$"
          example: "chat_d8aad64dib2c7m3ae7j0"
          description: Chat identifier.
        - in: path
          name: mid
          required: true
          schema:
            type: string
            pattern: "^message_[a-z0-9]{20}$"
          example: "message_d8aad64dib2c7m3ae7jg"
          description: Message identifier.
      responses:
        "200":
          description: Message body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
              examples:
                inFlight:
                  summary: Workflow still writing
                  value:
                    id: "message_d8aad64dib2c7m3ae7jg"
                    created_at: "2026-05-23T10:15:31Z"
                done:
                  summary: Content written
                  value:
                    id: "message_d8aad64dib2c7m3ae7jg"
                    prompt: "Summarize this quarter's product launches."
                    response: "We shipped three launches: Pearl, Marble, and Coral."
                    created_at: "2026-05-23T10:15:31Z"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/assets:
    get:
      tags: [assets]
      summary: List assets
      description: |
        Returns a page of assets in the focused project. Each item carries
        its attached labels inline under `labels[]` so callers can filter
        to unlabeled assets in a single round-trip.

        Pagination is cursor-based: pass the previous response's `last_id` as
        `after_id` to fetch the next page. `has_more: false` means the end.
      operationId: listAssets
      security:
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/XProjectId"
        - $ref: "#/components/parameters/AfterIDQuery"
        - $ref: "#/components/parameters/BeforeIDQuery"
        - $ref: "#/components/parameters/LimitQuery"
      responses:
        "200":
          description: Paginated list of assets with attached labels inline.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ListAssetsResponse"
              example:
                data:
                  - asset_id: "asset_d8aad64dib2c7m3ae7j1"
                    status: "ready"
                    filename: "q2-launch-deck.pdf"
                    mime_type: "application/pdf"
                    size_in_bytes: 1048576
                    created_at: "2026-05-23T10:15:30Z"
                    labels:
                      - label_id: "label_d8aad64dib2c7m3ae7k0"
                        name: "q2-launches"
                  - asset_id: "asset_d8aad64dib2c7m3ae7j2"
                    status: "ready"
                    filename: "raw-notes.md"
                    mime_type: "text/markdown"
                    size_in_bytes: 4096
                    created_at: "2026-05-23T10:16:00Z"
                    labels: []
                first_id: "asset_d8aad64dib2c7m3ae7j1"
                last_id: "asset_d8aad64dib2c7m3ae7j2"
                has_more: false
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalServerError"

    post:
      tags: [assets]
      summary: Create an asset upload
      description: |
        Step 1 of 3. Reserves an asset record and mints a signed upload URL.
        The client must then PUT the exact bytes to `upload_url` (step 2) and
        call `POST /v1/assets/{id}/complete` (step 3) to hand off to
        the ingest pipeline.
      operationId: createAsset
      security:
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/XProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateAssetRequest"
            example:
              filename: "q2-launch-deck.pdf"
              mime_type: "application/pdf"
      responses:
        "201":
          description: Upload URL minted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateAssetResponse"
              example:
                asset_id: "asset_d8aad64dib2c7m3ae7j1"
                upload_url: "https://storage.googleapis.com/storytell-assets/asset_d8aad64dib2c7m3ae7j1?X-Goog-Signature=..."
                upload_method: "PUT"
                upload_headers:
                  Content-Type: "application/pdf"
                expires_at: "2026-05-23T10:30:00Z"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/assets/{id}/complete:
    post:
      tags: [assets]
      summary: Mark an asset upload complete
      description: |
        Step 3 of 3. Call after the PUT to `upload_url` returns 2xx. The
        server reads the uploaded object's size and MD5 from blob storage,
        records them on the asset row, and hands the asset to the ingest
        pipeline. The response carries the current asset status; clients
        poll `GET /v1/assets/{id}` until status is `ready` or `failed`.
      operationId: completeAsset
      security:
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/XProjectId"
        - in: path
          name: id
          required: true
          schema:
            type: string
            pattern: "^asset_[a-z0-9]{20}$"
          example: "asset_d8aad64dib2c7m3ae7j1"
          description: Asset identifier returned by `POST /v1/assets`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CompleteAssetRequest"
            example: {}
      responses:
        "200":
          description: Asset state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssetResponse"
              example:
                asset_id: "asset_d8aad64dib2c7m3ae7j1"
                status: "ingesting"
                filename: "q2-launch-deck.pdf"
                mime_type: "application/pdf"
                size_in_bytes: 1048576
                created_at: "2026-05-23T10:15:30Z"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/assets/{id}:
    get:
      tags: [assets]
      summary: Get asset state
      description: |
        Returns the current ingest state for an asset. Clients poll this
        endpoint after `POST /v1/assets/{id}/complete` until `status` is
        `ready` or `failed`.
      operationId: getAsset
      security:
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/XProjectId"
        - in: path
          name: id
          required: true
          schema:
            type: string
            pattern: "^asset_[a-z0-9]{20}$"
          example: "asset_d8aad64dib2c7m3ae7j1"
          description: Asset identifier returned by `POST /v1/assets`.
      responses:
        "200":
          description: Asset state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AssetResponse"
              example:
                asset_id: "asset_d8aad64dib2c7m3ae7j1"
                status: "ready"
                filename: "q2-launch-deck.pdf"
                mime_type: "application/pdf"
                size_in_bytes: 1048576
                created_at: "2026-05-23T10:15:30Z"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/assets/{id}/labels:
    post:
      tags: [labels]
      summary: Attach a label to an asset by name
      description: |
        Attaches a label to the asset. The label is resolved by name within
        the focused project; if no label with that name exists it is
        auto-created as a bare label (no color/icon) and then attached. To
        set display metadata up front, mint the label first via
        `POST /v1/labels`.

        Attachment is idempotent: re-sending the same `label_name` returns
        the same `LabelSummary` without creating a duplicate association.
      operationId: attachLabelToAsset
      security:
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/XProjectId"
        - in: path
          name: id
          required: true
          schema:
            type: string
            pattern: "^asset_[a-z0-9]{20}$"
          example: "asset_d8aad64dib2c7m3ae7j1"
          description: Asset identifier returned by `POST /v1/assets`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AttachLabelRequest"
            example:
              label_name: "q2-launches"
      responses:
        "201":
          description: Label attached (or already attached).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LabelSummary"
              example:
                label_id: "label_d8aad64dib2c7m3ae7k0"
                name: "q2-launches"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /v1/labels:
    get:
      tags: [labels]
      summary: List labels
      description: |
        Returns every label visible in the focused project. The envelope
        matches the chats / assets list shape; `has_more` is always false in
        v1 because the per-project label cap bounds the list.
      operationId: listLabels
      security:
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/XProjectId"
      responses:
        "200":
          description: All labels in the project.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ListLabelsResponse"
              example:
                data:
                  - label_id: "label_d8aad64dib2c7m3ae7k0"
                    name: "q2-launches"
                  - label_id: "label_d8aad64dib2c7m3ae7k1"
                    name: "research"
                first_id: "label_d8aad64dib2c7m3ae7k0"
                last_id: "label_d8aad64dib2c7m3ae7k1"
                has_more: false
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalServerError"

    post:
      tags: [labels]
      summary: Create a label
      description: |
        Mints a label in the focused project with optional display metadata
        (color, icon). Use this when you want to set metadata up front;
        `POST /v1/assets/{id}/labels` will otherwise auto-create a bare
        label by name on first attach.
      operationId: createLabel
      security:
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/XProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateLabelRequest"
            example:
              name: "q2-launches"
              color: "#3b82f6"
              icon: "rocket"
      responses:
        "201":
          description: Label created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LabelSummary"
              example:
                label_id: "label_d8aad64dib2c7m3ae7k0"
                name: "q2-launches"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalServerError"

components:
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        Personal Access Token, sent verbatim as `pat_<32 chars>`. PATs are
        minted from the Chief settings UI.

  parameters:
    XProjectId:
      in: header
      name: X-Project-Id
      required: true
      schema:
        type: string
        pattern: "^project_[a-z0-9]{20}$"
      example: "project_d8a4td4dib27i4beefb0"
      description: |
        Project identifier scoping the request. Required on every
        project-scoped public endpoint. Missing or malformed values respond
        with HTTP 400 and code `publicapi.tenancy.project.missing`.

    AfterIDQuery:
      in: query
      name: after_id
      required: false
      schema:
        type: string
      description: |
        Plain resource id from the previous response's `last_id`. Returns
        rows strictly past that id in the resource's natural sort order.
        Omit on the first request. Mutually exclusive with `before_id`.

    BeforeIDQuery:
      in: query
      name: before_id
      required: false
      schema:
        type: string
      description: |
        Plain resource id to paginate backward from. Mutually exclusive with
        `after_id`.

    LimitQuery:
      in: query
      name: limit
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25
      description: Maximum number of items to return in this page.

  responses:
    BadRequest:
      description: Malformed request, validation failure, or missing tenancy header.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            missingProject:
              summary: Missing X-Project-Id
              value:
                code: "publicapi.tenancy.project.missing"
                humane: "X-Project-Id header is required."
                statusCode: 400
    Unauthorized:
      description: Missing, malformed, or revoked credentials.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            code: "publicapi.auth.invalid"
            humane: "The provided API key is invalid or has been revoked."
            statusCode: 401
    NotFound:
      description: Resource does not exist or is outside the caller's reach.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    InternalServerError:
      description: Unexpected server error.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    Error:
      type: object
      required: [code, statusCode]
      properties:
        code:
          type: string
          pattern: '^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$'
          description: |
            Stable dotted, lowercase identifier, namespaced by domain and
            operation (`<domain>.<op>.<reason>`). Suitable for client-side
            i18n keys and analytics. Codes are additive; existing codes are
            never reworded.
          example: "publicapi.auth.invalid"
        humane:
          type: string
          description: Plain-English message safe to display to end users.
          example: "The provided API key is invalid or has been revoked."
        statusCode:
          type: integer
          minimum: 400
          maximum: 599
          description: |
            Mirror of the HTTP status code. (Field name is camelCase here
            only — owned by `nutils.Error` across the platform.)
          example: 401

    AssetStatus:
      type: string
      enum: [uploaded, ingesting, ready, failed]
      description: |
        Asset lifecycle. Progresses uploaded -> ingesting -> ready, or
        terminates at failed on any extraction error.

    CreateChatRequest:
      type: object
      required: [prompt]
      properties:
        prompt:
          type: string
          description: The user's message to start the chat.
        intelligence:
          type: string
          enum: [auto, fast, expert, research]
          description: |
            Mode preset that controls model class and tool selection.
            `auto` lets the backend classify the prompt and route. Defaults
            to `auto` when omitted.
        provider:
          type: string
          enum: [automatic, anthropic, openai, google]
          description: |
            Biases model selection within the chosen `intelligence` mode
            toward a specific vendor. `automatic` (or omitted) leaves the
            router's default ordering. The mode preset still controls
            thinking depth and tool selection — provider only re-ranks
            candidate models. Mirrors the UI's AI Provider picker.
        skills:
          type: array
          items:
            type: string
          description: |
            Skill names to pre-load before the LLM runs. The Bluesky UI's
            "Expert Voice" picker sends one entry; the API accepts many.
            Each name must reference a skill the caller can access.
        public_data:
          type: boolean
          description: |
            When `false`, disables the public-web-search tool for this
            turn. Omitting or sending `true` leaves the mode default in
            place.
        scope:
          $ref: "#/components/schemas/ScopeRequest"
          description: |
            Optional. Narrows the knowledge the assistant may consult on
            this turn. Each id list is independently validated for shape;
            access is enforced server-side.

    ScopeRequest:
      type: object
      description: |
        Caller-supplied references the assistant may consult on this turn.
        Tenancy (org, workspace, project) is derived from the
        `X-Project-Id` header and is never carried here.
      properties:
        asset_ids:
          type: array
          items: { type: string, pattern: "^asset_[a-z0-9]{20}$" }
          description: Specific assets (files) to include.
        chat_ids:
          type: array
          items: { type: string, pattern: "^chat_[a-z0-9]{20}$" }
          description: Past chats to include as additional context.
        label_ids:
          type: array
          items: { type: string, pattern: "^label_[a-z0-9]{20}$" }
          description: Labels to expand into associated assets and concepts.
        concept_ids:
          type: array
          items: { type: string, pattern: "^concept_[a-z0-9]{20}$" }
          description: Concepts to consult — semantically broader than assets.
        project_ids:
          type: array
          items: { type: string, pattern: "^project_[a-z0-9]{20}$" }
          description: Whole projects to consult. Wider scope than concepts or labels.
        view_ids:
          type: array
          items: { type: string, pattern: "^view_[a-z0-9]{20}$" }
          description: Saved views the assistant should consult.

    CreateChatResponse:
      type: object
      required: [chat_id, message_id, created_at]
      properties:
        chat_id:
          type: string
          pattern: "^chat_[a-z0-9]{20}$"
        message_id:
          type: string
          pattern: "^message_[a-z0-9]{20}$"
          description: |
            The new turn's id. Poll
            `GET /v1/chats/{chat_id}/messages/{message_id}` to follow its
            lifecycle.
        created_at:
          type: string
          format: date-time

    SendMessageRequest:
      type: object
      required: [prompt]
      description: |
        Body for `POST /v1/chats/{id}/messages` — append a turn to an
        existing chat. The chat id comes from the path.
      properties:
        prompt:
          type: string
          description: The user's message for this turn.
        intelligence:
          type: string
          enum: [auto, fast, expert, research]
          description: |
            Mode preset that controls model class and tool selection.
            Defaults to `auto` when omitted.
        provider:
          type: string
          enum: [automatic, anthropic, openai, google]
          description: |
            Biases model selection within the chosen `intelligence` mode
            toward a specific vendor.
        skills:
          type: array
          items:
            type: string
          description: |
            Skill names to pre-load before the LLM runs.
        public_data:
          type: boolean
          description: |
            When `false`, disables the public-web-search tool for this
            turn. Omitting or sending `true` leaves the mode default in
            place.
        scope:
          $ref: "#/components/schemas/ScopeRequest"
          description: |
            Optional. Narrows the knowledge the assistant may consult on
            this turn.

    SendMessageResponse:
      type: object
      required: [message_id, created_at]
      properties:
        message_id:
          type: string
          pattern: "^message_[a-z0-9]{20}$"
          description: |
            The new turn's id. Poll
            `GET /v1/chats/{id}/messages/{message_id}` to follow lifecycle.
        created_at:
          type: string
          format: date-time

    Message:
      type: object
      required: [id]
      properties:
        id:
          type: string
          pattern: "^message_[a-z0-9]{20}$"
        prompt:
          type: string
          description: |
            The user's text on this turn. Omitted until the workflow has
            written the recording. Rich-text prompts are rendered to
            markdown.
        response:
          type: string
          description: |
            The assistant's text on this turn (the final answer only;
            intermediate reasoning blocks are excluded). Omitted until the
            workflow has written the recording. Rich-text responses are
            rendered to markdown.
        created_at:
          type: string
          format: date-time
          description: From `cp.messages.created_at` — when the message row
            was persisted by the workflow.

    MessageSummary:
      type: object
      required: [id]
      properties:
        id:
          type: string
          pattern: "^message_[a-z0-9]{20}$"
        created_at:
          type: string
          format: date-time
          description: From `cp.messages.created_at`.

    ChatResponse:
      type: object
      required: [chat_id]
      properties:
        chat_id:
          type: string
          pattern: "^chat_[a-z0-9]{20}$"
        modified_at:
          type: string
          format: date-time
          description: |
            Timestamp of the most recent message. Omitted on chats with no
            turns.

    ChatSummary:
      type: object
      required: [chat_id, created_at]
      properties:
        chat_id:
          type: string
          pattern: "^chat_[a-z0-9]{20}$"
        created_at:
          type: string
          format: date-time

    ListChatsResponse:
      type: object
      required: [data, has_more]
      description: |
        Cursor-paginated chat list. `last_id` is the next request's `after_id`
        when `has_more` is true.
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/ChatSummary"
        first_id:
          type: string
          description: Id of the first chat in this page; omitted when empty.
        last_id:
          type: string
          description: Id of the last chat in this page; omitted when empty.
        has_more:
          type: boolean
          description: True when more rows exist past this page.

    ListMessagesResponse:
      type: object
      required: [messages]
      properties:
        messages:
          type: array
          items:
            $ref: "#/components/schemas/MessageSummary"

    CompleteAssetRequest:
      type: object
      additionalProperties: false
      description: |
        Body of the asset-complete call. Empty in v1; reserved as a named
        schema so future ingest hints can be added additively.

    CreateAssetRequest:
      type: object
      required: [filename, mime_type]
      properties:
        filename:
          type: string
          description: Original filename. Used for display and ingest hints.
        mime_type:
          type: string
          description: RFC 6838 media type of the upload.

    CreateAssetResponse:
      type: object
      required:
        [asset_id, upload_url, upload_method, upload_headers, expires_at]
      properties:
        asset_id:
          type: string
          pattern: "^asset_[a-z0-9]{20}$"
        upload_url:
          type: string
          format: uri
        upload_method:
          type: string
          example: "PUT"
          description: |
            HTTP method to use for the upload. `PUT` today; resumable
            multi-part uploads may add new values without breaking the
            existing PUT flow.
        upload_headers:
          type: object
          additionalProperties:
            type: string
          description: |
            Headers the client MUST apply verbatim to the upload request.
            Includes `Content-Type`.
        expires_at:
          type: string
          format: date-time

    AssetResponse:
      type: object
      required:
        [asset_id, status, filename, mime_type, size_in_bytes, created_at]
      properties:
        asset_id:
          type: string
          pattern: "^asset_[a-z0-9]{20}$"
        status:
          $ref: "#/components/schemas/AssetStatus"
        filename:
          type: string
        mime_type:
          type: string
        size_in_bytes:
          type: integer
          format: int64
        created_at:
          type: string
          format: date-time

    AssetSummary:
      type: object
      required:
        [asset_id, status, filename, mime_type, size_in_bytes, created_at, labels]
      description: |
        Per-item shape in `ListAssetsResponse`. Carries attached labels
        inline so callers can filter to unlabeled assets without an extra
        round-trip.
      properties:
        asset_id:
          type: string
          pattern: "^asset_[a-z0-9]{20}$"
        status:
          $ref: "#/components/schemas/AssetStatus"
        filename:
          type: string
        mime_type:
          type: string
        size_in_bytes:
          type: integer
          format: int64
        created_at:
          type: string
          format: date-time
        labels:
          type: array
          items:
            $ref: "#/components/schemas/LabelSummary"
          description: Labels currently attached to this asset; empty array when none.

    ListAssetsResponse:
      type: object
      required: [data, has_more]
      description: |
        Cursor-paginated asset list. `last_id` is the next request's
        `after_id` when `has_more` is true.
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/AssetSummary"
        first_id:
          type: string
          description: Id of the first asset in this page; omitted when empty.
        last_id:
          type: string
          description: Id of the last asset in this page; omitted when empty.
        has_more:
          type: boolean
          description: True when more rows exist past this page.


    LabelSummary:
      type: object
      required: [label_id, name]
      properties:
        label_id:
          type: string
          pattern: "^label_[a-z0-9]{20}$"
        name:
          type: string

    CreateLabelRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 2
          maxLength: 255
          description: Display name for the label.
        color:
          type: string
          description: Optional CSS color (e.g. `#3b82f6`).
        icon:
          type: string
          description: Optional icon identifier.

    AttachLabelRequest:
      type: object
      required: [label_name]
      description: |
        Attach a label to an asset by name. If no label with this name
        exists in the project, it is auto-created as a bare label (no
        color/icon) and then attached. To set display metadata up front,
        mint the label first via `POST /v1/labels`.
      properties:
        label_name:
          type: string
          minLength: 2
          maxLength: 255

    ListLabelsResponse:
      type: object
      required: [data, has_more]
      description: |
        Labels visible in the focused project. The per-project label cap
        bounds the list, so `has_more` is always false today — the envelope
        matches the chats/assets list shape so clients can normalize.
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/LabelSummary"
        first_id:
          type: string
          description: Id of the first label in this page; omitted when empty.
        last_id:
          type: string
          description: Id of the last label in this page; omitted when empty.
        has_more:
          type: boolean
          description: Always false for labels in v1.
