openapi: 3.1.0
info:
  title: smao Public API
  version: "1.0.0"
  summary: Programmatic access to assistants and their configuration, contact data, calls and recordings, and usage statistics.
  description: |
    The smao Public API gives you programmatic access to the resources you
    manage in the dashboard: assistants and their configuration (prompts, knowledge
    items, glossary, pronunciation, forwardings, calendars, tools), contact data,
    call history with transcripts and recordings, outbound call triggering, and
    usage statistics.

    ## Base URL

    Production: `https://api.smao.ai/api/v1`

    ## Authentication

    All endpoints (except `/openapi.*` and `/docs`) require a bearer token in the
    `Authorization` header:

    ```
    Authorization: Bearer <your_api_key>
    ```

    API keys are created in the smao dashboard under *Settings → API Keys*.
    Each key is scoped to a single organization and a single role.

    ## Response shape

    **Success (2xx):**
    ```json
    { "data": { … } }
    ```
    or for paginated list endpoints:
    ```json
    { "data": [ … ], "meta": { "has_more": false, "next_cursor": null } }
    ```

    **Error (4xx / 5xx):**
    ```json
    { "error": { "code": "validation_failed", "message": "…", "details": [] } }
    ```

    ## Field naming

    API-defined request and response field names use `snake_case`. Caller-defined
    JSON objects such as tool parameters and custom webhook headers retain the
    keys supplied by the caller. Timestamps are ISO-8601 strings in UTC. Object
    IDs are 24-character lowercase hex strings.

    ## Pagination

    List endpoints use **opaque cursor pagination**. Send `?limit=N` on the first
    request; if `meta.has_more` is `true`, pass `meta.next_cursor` back as the
    `cursor` query parameter for the next page. Cursors are opaque — do not parse
    or construct them.

    ## Rate limiting

    Matched operations are rate-limited per API key. After authentication, the
    feature gate, and the limiter succeed, responses include:

    - `X-RateLimit-Limit` — bucket limit
    - `X-RateLimit-Remaining` — remaining requests in the current window
    - `X-RateLimit-Reset` — unix timestamp when the window resets

    Default buckets (deployments may override these values):

    | Category | Limit | Applies to |
    |---|---|---|
    | `read` | 1000 / min | All GET endpoints |
    | `write` | 200 / min | POST / PATCH / DELETE on all resources except `/calls` |
    | `outbound_trigger` | 60 / hour | POST /calls |

    `POST /calls` also permits only one outbound call per assistant in any
    60-second window. When a bucket is exhausted you receive HTTP `429` with
    `error.code = rate_limit_exceeded` and `Retry-After`. The per-assistant
    cooldown and limiter-storage failure use the same status/code but do not
    include `Retry-After`. Authentication failures, feature-gate failures,
    unmatched routes, and limiter-storage failures do not include the complete
    rate-limit header set.

  contact:
    name: smao Support
    email: support@smao.ai
  license:
    name: Proprietary
    identifier: LicenseRef-Proprietary

servers:
  - url: https://api.smao.ai/api/v1
    description: Production

tags:
  - name: Meta
    description: Health and connectivity.
  - name: Contacts
    description: Create and manage contact records — phone numbers, names, emails, group assignment.
  - name: Groups
    description: Organize contacts, knowledge items, forwardings, glossary entries, and pronunciation rules.
  - name: Knowledge-items
    description: Knowledge-base entries used by assistants for RAG.
  - name: Prompts
    description: |
      Reusable assistant prompts (answering and email templates).

      When automated prompt rating is enabled for the deployment, every save
      (`POST` or `PATCH`) triggers an asynchronous quality review. See the
      `Prompt` schema for the rating fields and their lifecycle.
  - name: Assistants
    description: Voice assistants — the phone-answering bots that run your conversations.
  - name: Forwardings
    description: Call-forwarding targets (phone or SIP) that assistants route to.
  - name: Glossary
    description: Domain-specific terms and abbreviations assistants use during calls.
  - name: Pronunciation
    description: Word-level pronunciation overrides that assistants apply when speaking during calls. Each rule matches one written token, not a multi-word phrase. Replacement text and spell-out affect both voice generations; speaking-rate rules and IPA transcriptions are applied on v2 (KugelAudio) voices and ignored on v1 (ElevenLabs) voices.
  - name: Calendars
    description: Opening-hours calendars — multi-schedule (opening/service/office/operating), weekly templates, and date-specific overrides that assistants consult during calls.
  - name: Calls
    description: Read call history, fetch recordings, and trigger outbound calls.
  - name: Statistics
    description: Aggregated call statistics — weekly and monthly usage breakdowns, per-assistant rollups, and last-24h hourly counts. Read-only, computed at query time.
  - name: Tools
    description: >-
      Custom webhook tools that assistants invoke during calls. v1 supports
      webhook-type tools only (integration tools are created via the dashboard
      OAuth flow). Auth secrets are encrypted at rest with AES-256-GCM and are
      never returned by the API — the response surfaces `has_secret: boolean`
      instead.

security:
  - bearerAuth: []

paths:

  # ── Meta ──────────────────────────────────────────────────────────────────
  /ping:
    get:
      operationId: getPing
      tags: [Meta]
      summary: Health check
      description: Confirms the API is reachable and your API key is valid.
      responses:
        "200":
          description: OK
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/XRateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/XRateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/XRateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ok: { type: boolean, example: true }
                      now: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ── Contacts ──────────────────────────────────────────────────────────────
  /contacts:
    get:
      operationId: listContacts
      tags: [Contacts]
      summary: List contacts (paginated)
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit200"
        - name: group_id
          in: query
          schema: { type: string, pattern: "^[a-f0-9]{24}$" }
          description: Filter by contacts group.
      responses:
        "200":
          description: Page of contacts.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PageContact" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      operationId: createContact
      tags: [Contacts]
      summary: Create a contact
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContactCreate" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Contact" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /contacts/{contact_id}:
    parameters:
      - $ref: "#/components/parameters/ContactId"
    get:
      operationId: getContact
      tags: [Contacts]
      summary: Get a contact
      responses:
        "200":
          description: The contact.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Contact" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      operationId: updateContact
      tags: [Contacts]
      summary: Update a contact
      description: Partial update — only included fields are changed.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContactUpdate" }
      responses:
        "200":
          description: Updated contact.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Contact" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      operationId: deleteContact
      tags: [Contacts]
      summary: Delete a contact
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmptySuccess" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ── Groups ────────────────────────────────────────────────────────────────
  /groups:
    get:
      operationId: listGroups
      tags: [Groups]
      summary: List groups (paginated)
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit200"
        - name: type
          in: query
          schema: { type: string, enum: [contacts, knowledges, forwardings, glossaries, pronunciations] }
          description: Filter by group type.
      responses:
        "200":
          description: Page of groups.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PageGroup" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      operationId: createGroup
      tags: [Groups]
      summary: Create a group
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/GroupCreate" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Group" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /groups/{group_id}:
    parameters:
      - $ref: "#/components/parameters/GroupId"
    get:
      operationId: getGroup
      tags: [Groups]
      summary: Get a group
      responses:
        "200":
          description: The group.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Group" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      operationId: updateGroup
      tags: [Groups]
      summary: Update a group
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/GroupUpdate" }
      responses:
        "200":
          description: Updated group.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Group" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      operationId: deleteGroup
      tags: [Groups]
      summary: Delete a group
      description: |
        Permanently deletes the group and all contacts, knowledge items,
        forwardings, glossary entries, or pronunciation rules it contains.
        Knowledge chunks are also removed. This operation is not reversible.

        Fails with 409 `conflict` if the group is assigned to an assistant or is
        the organization's last group of that type.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmptySuccess" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ── Knowledge-items ───────────────────────────────────────────────────────
  /knowledge-items:
    get:
      operationId: listKnowledgeItems
      tags: [Knowledge-items]
      summary: List knowledge items (paginated)
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/LimitDefault"
        - name: group_id
          in: query
          schema: { type: string, pattern: "^[a-f0-9]{24}$" }
          description: Filter by knowledges group.
      responses:
        "200":
          description: Page of knowledge items.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PageKnowledgeItem" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      operationId: createKnowledgeItem
      tags: [Knowledge-items]
      summary: Create a knowledge item
      description: |
        Creates a new knowledge item and asynchronously embeds its text for
        retrieval. `sync_status.state` follows the lifecycle:

        `pending` → `syncing` → `synced` (success) or `failed` (terminal).

        Poll the item via `GET` to observe the transition. `pending` is
        non-terminal: use a client-side polling timeout because a queueing
        failure can leave the item pending. A later valid `PATCH` re-enqueues
        indexing; contact support if the state does not progress.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/KnowledgeItemCreate" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/KnowledgeItem" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /knowledge-items/{knowledge_item_id}:
    parameters:
      - $ref: "#/components/parameters/KnowledgeItemId"
    get:
      operationId: getKnowledgeItem
      tags: [Knowledge-items]
      summary: Get a knowledge item (with full text)
      responses:
        "200":
          description: The knowledge item.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/KnowledgeItemDetail" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      operationId: updateKnowledgeItem
      tags: [Knowledge-items]
      summary: Update a knowledge item
      description: |
        Every update currently re-indexes the item, including changes limited
        to `name`, `group_id`, or `active`. `sync_status.state` resets to
        `pending` and follows the same lifecycle as a fresh create:

        `pending` → `syncing` → `synced` (success) or `failed` (terminal).

        Use a client-side polling timeout because a queueing failure can leave
        the item pending.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/KnowledgeItemUpdate" }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/KnowledgeItem" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      operationId: deleteKnowledgeItem
      tags: [Knowledge-items]
      summary: Delete a knowledge item
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmptySuccess" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ── Prompts ───────────────────────────────────────────────────────────────
  /prompts:
    get:
      operationId: listPrompts
      tags: [Prompts]
      summary: List prompts (paginated)
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/LimitDefault"
      responses:
        "200":
          description: Page of prompts.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PagePrompt" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      operationId: createPrompt
      tags: [Prompts]
      summary: Create a prompt
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PromptCreate" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Prompt" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /prompts/{prompt_id}:
    parameters:
      - $ref: "#/components/parameters/PromptId"
    get:
      operationId: getPrompt
      tags: [Prompts]
      summary: Get a prompt
      responses:
        "200":
          description: The prompt.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Prompt" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      operationId: updatePrompt
      tags: [Prompts]
      summary: Update a prompt
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PromptUpdate" }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Prompt" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      operationId: deletePrompt
      tags: [Prompts]
      summary: Delete a prompt
      description: |
        Before deleting, clear both `prompt_id` and `email_prompt_id` references
        from assistants. The server returns 409 `conflict` when the prompt is
        still assigned through `prompt_id`; clients must not rely on that guard
        to detect an `email_prompt_id` reference.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmptySuccess" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ── Assistants ────────────────────────────────────────────────────────────
  /assistants:
    get:
      operationId: listAssistants
      tags: [Assistants]
      summary: List assistants (paginated)
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/LimitDefault"
      responses:
        "200":
          description: Page of assistants.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PageAssistant" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      operationId: createAssistant
      tags: [Assistants]
      summary: Create an assistant
      description: |
        Creates a new assistant and provisions a phone number for it. The
        `phone_number` field on the response is assigned by the platform —
        callers cannot set or change it via this API. Cross-resource refs
        (prompt_id, knowledge_group_ids, etc.) are validated to belong to
        your organization before the assistant is saved.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AssistantCreate" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Assistant" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /assistants/{assistant_id}:
    parameters:
      - $ref: "#/components/parameters/AssistantId"
    get:
      operationId: getAssistant
      tags: [Assistants]
      summary: Get an assistant
      responses:
        "200":
          description: The assistant.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Assistant" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      operationId: updateAssistant
      tags: [Assistants]
      summary: Update an assistant
      description: |
        Partial update — only included fields are changed. `phone_number` and
        `carrier` are read-only and cannot be updated via this endpoint.
        Cross-resource refs are validated to belong to your organization.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AssistantUpdate" }
      responses:
        "200":
          description: Updated assistant.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Assistant" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      operationId: deleteAssistant
      tags: [Assistants]
      summary: Delete an assistant
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmptySuccess" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ── Forwardings ───────────────────────────────────────────────────────────
  /forwardings:
    get:
      operationId: listForwardings
      tags: [Forwardings]
      summary: List forwardings (paginated)
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/LimitDefault"
        - name: group_id
          in: query
          schema: { type: string, pattern: "^[a-f0-9]{24}$" }
          description: Filter by forwardings group.
      responses:
        "200":
          description: Page of forwardings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PageForwarding" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      operationId: createForwarding
      tags: [Forwardings]
      summary: Create a forwarding
      description: |
        Creates a new forwarding target inside a forwardings-type group. The
        referenced `group_id` must belong to your organization and have
        `type: forwardings`. When `destination_type` is `sip`, `sip_uri` is
        required. When `destination_type` is `sip_refer`, `sip_refer_target`
        is required. For a usable `phone` destination, provide a non-empty
        `phone_number`; current request validation also accepts an omitted or
        empty value, which produces a target that cannot be dialed.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ForwardingCreate" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Forwarding" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: The referenced `group_id` does not exist in your organization.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: not_found, message: "Group not found" } }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /forwardings/{forwarding_id}:
    parameters:
      - $ref: "#/components/parameters/ForwardingId"
    get:
      operationId: getForwarding
      tags: [Forwardings]
      summary: Get a forwarding
      responses:
        "200":
          description: The forwarding.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Forwarding" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      operationId: updateForwarding
      tags: [Forwardings]
      summary: Update a forwarding
      description: |
        Partial update — only included fields are changed. `group_id` cannot
        be changed via this endpoint; create a new forwarding in the target
        group instead. When changing to `destination_type: phone`, also send a
        non-empty `phone_number`; the validator permits it to be omitted or
        empty, but the resulting target cannot be dialed.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ForwardingUpdate" }
      responses:
        "200":
          description: Updated forwarding.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Forwarding" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      operationId: deleteForwarding
      tags: [Forwardings]
      summary: Delete a forwarding
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmptySuccess" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ── Glossary ──────────────────────────────────────────────────────────────
  /glossary:
    get:
      operationId: listGlossary
      tags: [Glossary]
      summary: List glossary items (paginated)
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/LimitDefault"
        - name: group_id
          in: query
          schema: { type: string, pattern: "^[a-f0-9]{24}$" }
          description: Filter by glossaries group.
      responses:
        "200":
          description: Page of glossary items.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PageGlossary" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      operationId: createGlossary
      tags: [Glossary]
      summary: Create a glossary item
      description: |
        Creates a new glossary item inside a glossaries-type group. The
        referenced `group_id` must belong to your organization and have
        `type: glossaries`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/GlossaryCreate" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Glossary" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: The referenced `group_id` does not exist in your organization.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: not_found, message: "Group not found" } }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /glossary/{glossary_id}:
    parameters:
      - $ref: "#/components/parameters/GlossaryId"
    get:
      operationId: getGlossary
      tags: [Glossary]
      summary: Get a glossary item
      responses:
        "200":
          description: The glossary item.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Glossary" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      operationId: updateGlossary
      tags: [Glossary]
      summary: Update a glossary item
      description: |
        Partial update — only included fields are changed. `group_id` cannot
        be changed via this endpoint; create a new glossary item in the target
        group instead.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/GlossaryUpdate" }
      responses:
        "200":
          description: Updated glossary item.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Glossary" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      operationId: deleteGlossary
      tags: [Glossary]
      summary: Delete a glossary item
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmptySuccess" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ── Pronunciation ───────────────────────────────────────────────────────────
  /pronunciation:
    get:
      operationId: listPronunciation
      tags: [Pronunciation]
      summary: List pronunciation items (paginated)
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/LimitDefault"
        - name: group_id
          in: query
          schema: { type: string, pattern: "^[a-f0-9]{24}$" }
          description: Filter by pronunciations group.
      responses:
        "200":
          description: Page of pronunciation items.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PagePronunciation" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      operationId: createPronunciation
      tags: [Pronunciation]
      summary: Create a pronunciation item
      description: |
        Creates a new pronunciation item inside a pronunciations-type group. The
        referenced `group_id` must belong to your organization and have
        `type: pronunciations`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PronunciationCreate" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Pronunciation" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: The referenced `group_id` does not exist in your organization.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: not_found, message: "Group not found" } }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /pronunciation/{pronunciation_id}:
    parameters:
      - $ref: "#/components/parameters/PronunciationId"
    get:
      operationId: getPronunciation
      tags: [Pronunciation]
      summary: Get a pronunciation item
      responses:
        "200":
          description: The pronunciation item.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Pronunciation" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      operationId: updatePronunciation
      tags: [Pronunciation]
      summary: Update a pronunciation item
      description: |
        Partial update — only included fields are changed. `group_id` cannot
        be changed via this endpoint; create a new pronunciation item in the
        target group instead.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PronunciationUpdate" }
      responses:
        "200":
          description: Updated pronunciation item.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Pronunciation" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      operationId: deletePronunciation
      tags: [Pronunciation]
      summary: Delete a pronunciation item
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmptySuccess" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ── Calendars ─────────────────────────────────────────────────────────────
  /calendars:
    get:
      operationId: listCalendars
      tags: [Calendars]
      summary: List calendars (paginated)
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/LimitDefault"
      responses:
        "200":
          description: Page of calendars.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PageCalendar" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      operationId: createCalendar
      tags: [Calendars]
      summary: Create a calendar
      description: |
        Creates a new calendar. You can optionally seed one or more schedules
        (opening hours, service hours, etc.) along with their weekly template
        and date-specific overrides in a single request. Calendar names must
        be unique within your organization.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CalendarCreate" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Calendar" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: A calendar with that name already exists.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: conflict, message: "A calendar with this name already exists" } }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /calendars/{calendar_id}:
    parameters:
      - $ref: "#/components/parameters/CalendarId"
    get:
      operationId: getCalendar
      tags: [Calendars]
      summary: Get a calendar
      responses:
        "200":
          description: The calendar.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Calendar" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      operationId: updateCalendar
      tags: [Calendars]
      summary: Update a calendar
      description: |
        Partial update — only included fields are changed. Passing
        `schedules` replaces the calendar's full schedules array.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CalendarUpdate" }
      responses:
        "200":
          description: Updated calendar.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Calendar" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: A calendar with that name already exists.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: conflict, message: "A calendar with this name already exists" } }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      operationId: deleteCalendar
      tags: [Calendars]
      summary: Delete a calendar
      description: |
        Deletes a calendar. Fails with `409 conflict` if the calendar is still
        assigned to one or more assistants — unlink it from every assistant
        first (via the Assistants endpoint) and retry.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmptySuccess" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Calendar is still assigned to one or more assistants.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: conflict, message: "This calendar is still being used by assistants" } }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ── Calls ─────────────────────────────────────────────────────────────────
  /calls:
    get:
      operationId: listCalls
      tags: [Calls]
      summary: List calls (paginated)
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/LimitCalls"
        - name: assistant_id
          in: query
          schema: { type: string, pattern: "^[a-f0-9]{24}$" }
          description: Filter by assistant.
        - name: assistant_phone
          in: query
          schema:
            type: string
            pattern: "^\\+\\d{6,20}$"
            example: "+491701234567"
          description: Filter by assistant phone number. E.164 format — a leading `+` followed by 6–20 digits.
        - name: created_from
          in: query
          schema: { type: string, format: date-time }
        - name: created_to
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Page of calls.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PageCall" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      operationId: createCall
      tags: [Calls]
      summary: Trigger an outbound call
      description: |
        Starts an outbound call. Provide exactly one of `assistant_id` or
        `assistant_phone` to select the assistant, and exactly one of `to` or
        `target_phone` for the destination number (`to` is preferred, `target_phone`
        is an alias retained for backwards compatibility).

        This operation uses the per-key `outbound_trigger` bucket (60 requests
        per hour by default) and a separate one-outbound-call-per-assistant
        cooldown over a rolling 60-second window. A cooldown rejection is 429
        `rate_limit_exceeded` without `Retry-After`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TriggerOutbound" }
      responses:
        "201":
          description: Call created and queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [call_id, assistant_id, assistant_phone, to, status, created_at]
                    properties:
                      call_id: { type: string, pattern: "^[a-f0-9]{24}$", example: "65f8fa6f1a9d8e0012ab34cd" }
                      assistant_id: { type: string, pattern: "^[a-f0-9]{24}$", example: "65a1234567890abcdef01234" }
                      assistant_phone:
                        type: [string, "null"]
                        pattern: "^\\+\\d{6,20}$"
                        example: "+491701234567"
                        description: E.164 phone number of the assistant, or null if the assistant has no phone number.
                      to:
                        type: string
                        pattern: "^\\+\\d{6,20}$"
                        example: "+491701234999"
                        description: Normalized E.164 destination number.
                      status: { type: string, example: "queued" }
                      created_at: { type: string, format: date-time }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /calls/{call_id}:
    parameters:
      - $ref: "#/components/parameters/CallId"
    get:
      operationId: getCall
      tags: [Calls]
      summary: Get a call with transcript, analyses, and notes
      responses:
        "200":
          description: The call.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/CallDetail" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /calls/{call_id}/recording:
    parameters:
      - $ref: "#/components/parameters/CallId"
    get:
      operationId: getCallRecording
      tags: [Calls]
      summary: Download the call recording
      description: |
        Returns the audio recording as a binary stream. Supports HTTP `Range`
        for partial content. Returns 404 with JSON envelope when no recording
        exists for the call.
      responses:
        "200":
          description: |
            Audio stream. The actual `Content-Type` depends on the recording's
            stored format — current recordings are FLAC, legacy recordings may
            be WAV. Clients should dispatch on the response `Content-Type` rather
            than assuming a single format.
          content:
            audio/flac:
              schema: { type: string, format: binary }
            audio/wav:
              schema: { type: string, format: binary }
            audio/mpeg:
              schema: { type: string, format: binary }
        "206":
          description: Partial content (byte range response).
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ── Statistics ────────────────────────────────────────────────────────────
  /statistics/summary:
    get:
      operationId: getStatisticsSummary
      tags: [Statistics]
      summary: List available statistics filters
      description: |
        Returns the filter values that populate statistics dashboards:
        every (week, year) pair that has at least one completed call in the
        organization, plus every assistant in the organization. The `all`
        assistant sentinel is **not** returned — clients can always pass
        `assistant_id=all` to the `/usage` endpoint regardless of this list.
      responses:
        "200":
          description: Filter values.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/StatisticsSummary" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /statistics/usage:
    get:
      operationId: getStatisticsUsage
      tags: [Statistics]
      summary: Usage breakdown for a week + assistant filter
      description: |
        Returns the monthly + weekly + last-24h breakdown for the selected
        week/year window. `assistant_id=all` aggregates across every assistant;
        passing a concrete 24-hex ID filters to that assistant's calls only.
        Supplying an ID that does not belong to the caller's organization
        returns 404.

        All duration fields are integer **seconds** (billable, with 60s minimum
        per call), not minutes. `hourly_call_counts_last_24h` always reflects
        the 24h window ending at request time — it ignores the `week`/`year`
        filter.
      parameters:
        - name: week
          in: query
          required: true
          schema: { type: integer, minimum: 1, maximum: 53, example: 17 }
          description: ISO week number (1–53).
        - name: year
          in: query
          required: true
          schema: { type: integer, minimum: 2020, maximum: 2100, example: 2026 }
        - name: assistant_id
          in: query
          required: true
          description: Either the sentinel `all` or a 24-hex assistant id.
          schema:
            oneOf:
              - type: string
                enum: [all]
              - type: string
                pattern: "^[a-f0-9]{24}$"
          examples:
            all: { value: "all", summary: "Aggregate across every assistant" }
            one: { value: "65a1234567890abcdef01234", summary: "Filter to one assistant" }
      responses:
        "200":
          description: Usage breakdown.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/StatisticsUsage" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: The supplied `assistant_id` does not exist in this organization.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: not_found, message: "Assistant not found" } }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /statistics/assistants:
    get:
      operationId: getStatisticsAssistants
      tags: [Statistics]
      summary: Per-assistant usage for a week
      description: |
        Returns one breakdown row per assistant that had at least one completed
        call in the selected week. Includes assistants that have since been
        deleted — their calls still count and the row carries `is_deleted: true`.
        All duration fields are integer seconds (billable, with 60s minimum).
      parameters:
        - name: week
          in: query
          required: true
          schema: { type: integer, minimum: 1, maximum: 53, example: 17 }
        - name: year
          in: query
          required: true
          schema: { type: integer, minimum: 2020, maximum: 2100, example: 2026 }
      responses:
        "200":
          description: Per-assistant usage.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/StatisticsAssistantsUsage" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ── Tools ────────────────────────────────────────────────────────────────
  /tools:
    get:
      operationId: listTools
      tags: [Tools]
      summary: List tools (paginated)
      description: |
        Returns the organization's custom tools, ordered by `created_at` DESC.
        Auth configuration is summarized — the stored secret value is never
        returned; `auth.has_secret` indicates whether a credential is set.
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/LimitDefault"
      responses:
        "200":
          description: Page of tools.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PageTool" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      operationId: createTool
      tags: [Tools]
      summary: Create a tool
      description: |
        Creates a new webhook-type tool. `tool_key` must be unique within your
        organization; reuse returns 409. Auth secrets are stored AES-256-GCM
        encrypted and are never returned — submit them on create / PATCH to set
        or rotate. Integration-type tools must be created via the dashboard
        (OAuth flow) and cannot be created through the public API in v1.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ToolCreate" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Tool" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: A tool with the requested `tool_key` already exists in this organization.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: conflict, message: "A tool with that tool_key already exists." } }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /tools/{tool_id}:
    parameters:
      - $ref: "#/components/parameters/ToolId"
    get:
      operationId: getTool
      tags: [Tools]
      summary: Get a tool
      responses:
        "200":
          description: The tool.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Tool" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      operationId: updateTool
      tags: [Tools]
      summary: Update a tool
      description: |
        Partial update — only included fields are changed. `tool_key` and
        `execution_type` are immutable (create a new tool instead). To rotate
        an auth secret, submit a full `auth` object with the new value; the
        previous secret is overwritten in place.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ToolUpdate" }
      responses:
        "200":
          description: Updated tool.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Tool" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      operationId: deleteTool
      tags: [Tools]
      summary: Delete a tool
      description: |
        Deletes the tool and removes its `_id` from any assistant's `tool_ids`
        list so no assistant is left referencing a missing tool.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmptySuccess" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        API key in the `Authorization: Bearer <key>` header.
        Keys are created in the smao dashboard (*Settings → API Keys*).

  headers:
    XRateLimitLimit:
      description: Maximum requests allowed in the current window for this bucket.
      schema: { type: integer, example: 1000 }
    XRateLimitRemaining:
      description: Requests remaining in the current window.
      schema: { type: integer, example: 997 }
    XRateLimitReset:
      description: Unix timestamp (seconds) when the window resets.
      schema: { type: integer, example: 1776902940 }
    RetryAfter:
      description: Seconds to wait before retrying after a 429 response.
      schema: { type: integer, example: 60 }

  parameters:
    Cursor:
      name: cursor
      in: query
      description: |
        Opaque cursor from a prior `meta.next_cursor`. Base64url-encoded
        (unpadded) — do not parse or construct it manually.
      schema:
        type: string
        pattern: "^[A-Za-z0-9_-]+$"
    # Page-size buckets: contacts and groups allow up to 200 per page, calls
    # default lower (25), everything else is the standard 1–100/50.
    Limit200:
      name: limit
      in: query
      description: Items per page (1–200, default 50).
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    LimitDefault:
      name: limit
      in: query
      description: Items per page (1–100, default 50).
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
    LimitCalls:
      name: limit
      in: query
      description: Items per page (1–100, default 25).
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    ContactId:
      name: contact_id
      in: path
      required: true
      schema: { type: string, pattern: "^[a-f0-9]{24}$" }
    GroupId:
      name: group_id
      in: path
      required: true
      schema: { type: string, pattern: "^[a-f0-9]{24}$" }
    KnowledgeItemId:
      name: knowledge_item_id
      in: path
      required: true
      schema: { type: string, pattern: "^[a-f0-9]{24}$" }
    PromptId:
      name: prompt_id
      in: path
      required: true
      schema: { type: string, pattern: "^[a-f0-9]{24}$" }
    AssistantId:
      name: assistant_id
      in: path
      required: true
      schema: { type: string, pattern: "^[a-f0-9]{24}$" }
    ForwardingId:
      name: forwarding_id
      in: path
      required: true
      schema: { type: string, pattern: "^[a-f0-9]{24}$" }
    GlossaryId:
      name: glossary_id
      in: path
      required: true
      schema: { type: string, pattern: "^[a-f0-9]{24}$" }
    PronunciationId:
      name: pronunciation_id
      in: path
      required: true
      schema: { type: string, pattern: "^[a-f0-9]{24}$" }
    CalendarId:
      name: calendar_id
      in: path
      required: true
      schema: { type: string, pattern: "^[a-f0-9]{24}$" }
    CallId:
      name: call_id
      in: path
      required: true
      schema: { type: string, pattern: "^[a-f0-9]{24}$" }
    ToolId:
      name: tool_id
      in: path
      required: true
      schema: { type: string, pattern: "^[a-f0-9]{24}$" }

  responses:
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: unauthorized, message: "Missing API key" } }
    Forbidden:
      description: The API key's role lacks permission for this action.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: forbidden, message: "No permission" } }
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: not_found, message: "Not found" } }
    ValidationFailed:
      description: Request body or query parameters failed validation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: validation_failed
              message: "Validation failed"
    Conflict:
      description: Resource is in use or the operation would violate an invariant.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: conflict, message: "Group is assigned to an assistant" } }
    RateLimited:
      description: >-
        Rate limit exceeded. Bucket exhaustion includes the rate-limit headers
        and `Retry-After`. The separate outbound per-assistant cooldown, or a
        fail-closed limiter-storage error, uses the same 429/code without
        `Retry-After`; a storage error may omit all rate-limit headers.
      headers:
        X-RateLimit-Limit: { $ref: "#/components/headers/XRateLimitLimit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/XRateLimitRemaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/XRateLimitReset" }
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: rate_limit_exceeded, message: "Rate limit exceeded" } }
    SubscriptionRequired:
      description: A feature required by this endpoint is not enabled for the organization.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: subscription_required, message: "This feature is not available in your current subscription. Please upgrade to a higher plan" } }
    InternalError:
      description: |
        Unexpected server error. The operation did not complete. Safe to retry
        idempotent reads; for mutations, check the resource state before retrying.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: internal_error, message: "An unexpected error occurred." } }

  schemas:

    # ── Shared ────────────────────────────────────────────────────────────
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - unauthorized
                - forbidden
                - not_found
                - validation_failed
                - conflict
                - rate_limit_exceeded
                - subscription_required
                - internal_error
            message: { type: string }
            details:
              type: array
              description: Optional validation details. Present only for schema/model validation failures.
              items:
                type: object
                properties:
                  path:
                    type: array
                    items: { type: [string, integer] }
                  message: { type: string }

    EmptySuccess:
      type: object
      properties:
        data:
          type: object
          description: Empty — present for envelope consistency.

    PageMeta:
      type: object
      required: [has_more, next_cursor]
      properties:
        has_more: { type: boolean }
        next_cursor:
          type: [string, "null"]
          description: Opaque cursor for the next page, or `null` on the last page.

    # ── Contacts ──────────────────────────────────────────────────────────
    Contact:
      type: object
      required:
        - id
        - group_id
        - group_name
        - phone_numbers
        - first_name
        - last_name
        - email
        - company
        - gender
        - prompt
        - preferred_language
        - forwarding_id
        - created_at
        - updated_at
      properties:
        id: { type: string, example: "65f8fa6f1a9d8e0012ab34cd" }
        group_id:
          type: [string, "null"]
          example: "65a1234567890abcdef01234"
          description: >-
            `null` for legacy or orphaned records with no group. Deleting a
            contacts group through this API deletes its contacts rather than
            clearing their group reference.
        group_name: { type: [string, "null"], example: "VIP Customers" }
        phone_numbers:
          type: array
          items: { type: string, example: "+491701234567" }
        first_name: { type: string, example: "Ada" }
        last_name: { type: string, example: "Lovelace" }
        email: { type: string, example: "ada@example.com" }
        company: { type: string, example: "Analytical Engine Inc" }
        gender: { type: string, example: "female" }
        prompt: { type: string, description: "Optional per-contact prompt fragment." }
        preferred_language: { type: string, example: "en" }
        forwarding_id:
          type: [string, "null"]
          example: "65a1234567890abcdef09999"
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ContactCreate:
      type: object
      required: [group_id, phone_numbers]
      additionalProperties: false
      properties:
        group_id: { type: string, pattern: "^[a-f0-9]{24}$" }
        phone_numbers:
          type: array
          minItems: 1
          items: { type: string, minLength: 1, example: "+491701234567" }
        first_name: { type: string, default: "" }
        last_name: { type: string, default: "" }
        email:
          type: string
          format: email
          default: ""
          description: Email address. Empty string is accepted if you don't have the contact's email.
        company: { type: string, default: "" }
        prompt: { type: string, default: "" }
        preferred_language:
          type: string
          enum: [de, en, fr, it, de-CH, es, pt, tr, sv, fi, unknown]
          example: "en"
        gender: { type: string, default: "" }
        forwarding_id:
          type: [string, "null"]
          pattern: "^[a-f0-9]{24}$"
          default: null

    ContactUpdate:
      type: object
      minProperties: 1
      additionalProperties: false
      description: Partial update — include only the fields you want to change.
      properties:
        group_id: { type: string, pattern: "^[a-f0-9]{24}$" }
        phone_numbers:
          type: array
          minItems: 1
          items: { type: string, minLength: 1 }
        first_name: { type: string }
        last_name: { type: string }
        email:
          type: string
          format: email
          description: Email address. Empty string is accepted if you don't have the contact's email.
        company: { type: string }
        prompt: { type: string }
        preferred_language:
          type: string
          enum: [de, en, fr, it, de-CH, es, pt, tr, sv, fi, unknown]
        gender: { type: string }
        forwarding_id: { type: [string, "null"], pattern: "^[a-f0-9]{24}$" }

    PageContact:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Contact" }
        meta: { $ref: "#/components/schemas/PageMeta" }

    # ── Groups ────────────────────────────────────────────────────────────
    Group:
      type: object
      required: [id, type, name, description, created_at, updated_at]
      properties:
        id: { type: string }
        type: { type: string, enum: [contacts, knowledges, forwardings, glossaries, pronunciations] }
        name: { type: string, example: "VIP Customers" }
        description: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    GroupCreate:
      type: object
      required: [type, name]
      additionalProperties: false
      properties:
        type: { type: string, enum: [contacts, knowledges, forwardings, glossaries, pronunciations] }
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Trimmed of leading/trailing whitespace before storage.
        description: { type: string, maxLength: 500, default: "" }

    GroupUpdate:
      type: object
      minProperties: 1
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Trimmed of leading/trailing whitespace before storage.
        description: { type: string, maxLength: 500 }

    PageGroup:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Group" }
        meta: { $ref: "#/components/schemas/PageMeta" }

    # ── Knowledge-items ───────────────────────────────────────────────────
    KnowledgeItemSyncStatus:
      type: object
      required: [state, synced_at, total_chunks, estimated_duration_ms, error]
      properties:
        state:
          type: string
          enum: [pending, syncing, synced, failed]
          description: Indexing status.
        synced_at:
          type: [string, "null"]
          format: date-time
        total_chunks: { type: [integer, "null"] }
        estimated_duration_ms: { type: [integer, "null"] }
        error: { type: [string, "null"] }

    KnowledgeItem:
      type: object
      description: Summary shape — `text` is omitted; use the detail endpoint for full body.
      required: [id, group_id, name, active, text_length, sync_status, created_at, updated_at]
      properties:
        id: { type: string }
        group_id: { type: [string, "null"], pattern: "^[a-f0-9]{24}$" }
        name: { type: string, example: "Shipping Policy" }
        active: { type: boolean }
        text_length:
          type: integer
          maximum: 5000000
          description: Character count of the stored `text` body.
        sync_status: { $ref: "#/components/schemas/KnowledgeItemSyncStatus" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    KnowledgeItemDetail:
      allOf:
        - $ref: "#/components/schemas/KnowledgeItem"
        - type: object
          required: [text]
          properties:
            text: { type: string }

    KnowledgeItemCreate:
      type: object
      required: [group_id, name, text]
      additionalProperties: false
      properties:
        group_id: { type: string, pattern: "^[a-f0-9]{24}$" }
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Trimmed of leading/trailing whitespace before storage.
        text: { type: string, minLength: 1, maxLength: 5000000 }
        active: { type: boolean, default: true }

    KnowledgeItemUpdate:
      type: object
      minProperties: 1
      additionalProperties: false
      properties:
        group_id: { type: string, pattern: "^[a-f0-9]{24}$" }
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Trimmed of leading/trailing whitespace before storage.
        text: { type: string, minLength: 1, maxLength: 5000000 }
        active: { type: boolean }

    PageKnowledgeItem:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/KnowledgeItem" }
        meta: { $ref: "#/components/schemas/PageMeta" }

    # ── Prompts ───────────────────────────────────────────────────────────
    Prompt:
      type: object
      required:
        - id
        - name
        - description
        - prompt
        - type
        - health_score
        - approved
        - feedback
        - rated_at
        - rating_status
        - created_at
        - updated_at
      description: |
        A reusable assistant prompt. When automated prompt rating is enabled
        for the deployment, every save (POST/PATCH) enqueues a quality review.
        The rating fields (`health_score`, `approved`, `feedback`, `rated_at`,
        `rating_status`) are read-only — create/update requests reject them —
        and are populated asynchronously following the `rating_status`
        lifecycle:

        `null` (never rated) → `"pending"` (queued/in-flight) → `"rated"`
        or `"failed"`.

        On `"failed"`, all rating fields except `rating_status` are cleared
        — the next save enqueues a fresh attempt. When rating is disabled,
        `rating_status`, `health_score`, `approved`, and `rated_at` remain
        `null`, and `feedback` remains an empty string.
      properties:
        id: { type: string, example: "65f8fa6f1a9d8e0012ab34cd" }
        name: { type: string, example: "Sales — Initial Outreach" }
        description: { type: string, example: "Opening prompt for cold outbound calls." }
        prompt:
          type: string
          description: The full prompt body passed to the assistant.
        type:
          type: string
          enum: [answering, email]
          description: Prompt kind — `answering` for voice/chat, `email` for email replies.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        health_score:
          type: [integer, "null"]
          minimum: 1
          maximum: 10
          example: 8
          description: |
            Automated quality score from 1–10. `null` until the first rating
            completes, and after a failed rating attempt.
        approved:
          type: [boolean, "null"]
          example: true
          description: |
            Whether the review considers the prompt production-ready.
            Independent of `health_score` — a prompt does not need a
            perfect score to be approved.
        feedback:
          type: string
          example: "The prompt follows the template exactly and is production-ready. No changes required."
          description: |
            Review feedback, written in the prompt's own language. Empty
            string until the first rating completes, and after a failed
            rating attempt.
        rated_at:
          type: [string, "null"]
          format: date-time
          description: When the latest rating completed. `null` until the first rating completes, and after a failed attempt.
        rating_status:
          type: [string, "null"]
          enum: [null, "pending", "rated", "failed"]
          example: "rated"
          description: |
            Rating lifecycle status:
            - `null` — never rated, or automated rating is disabled.
            - `"pending"` — save accepted, review queued or in flight. Other rating fields still reflect the previous state (or `null` on first save).
            - `"rated"` — review complete; `health_score` / `approved` / `feedback` populated.
            - `"failed"` — review attempt failed. All other rating fields are cleared; the next save enqueues a fresh attempt.

    PromptCreate:
      type: object
      required: [name, prompt]
      additionalProperties: false
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        prompt: { type: string, minLength: 1, maxLength: 100000 }
        description: { type: string, maxLength: 2000, default: "" }
        type:
          type: string
          enum: [answering, email]
          default: answering

    PromptUpdate:
      type: object
      minProperties: 1
      additionalProperties: false
      description: Partial update — include only the fields you want to change.
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        prompt: { type: string, minLength: 1, maxLength: 100000 }
        description: { type: string, maxLength: 2000 }
        type: { type: string, enum: [answering, email] }

    PagePrompt:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Prompt" }
        meta: { $ref: "#/components/schemas/PageMeta" }

    # ── Assistants ────────────────────────────────────────────────────────
    Assistant:
      type: object
      required:
        - id
        - name
        - description
        - language
        - voice_id
        - record_call
        - pickup_delay
        - speed
        - tts_style
        - background_ambience
        - interruptable
        - appointment_booking_enabled
        - allow_preferred_language
        - whitelist
        - introduction
        - company_name
        - company_industry
        - phone_number
        - carrier
        - prompt_id
        - email_prompt_id
        - knowledge_group_ids
        - glossary_group_ids
        - pronunciation_group_ids
        - forwarding_group_ids
        - contact_group_ids
        - calendar_ids
        - tool_ids
        - analysis_ids
        - created_at
        - updated_at
      properties:
        id: { type: string, example: "65f8fa6f1a9d8e0012ab34cd" }
        name: { type: string, example: "Sarah" }
        description: { type: string, example: "Receptionist assistant for inbound calls." }
        language:
          type: string
          enum: [de, en, fr, it, de-CH, es, pt, tr, sv, fi]
          example: "de"
        voice_id:
          type: string
          example: "cgSgspJ2msm6clMCkdW9"
          description: "Voice provider voice ID. Must be one of the platform-supported values; the full list is available via the dashboard. Sending an unknown ID returns 400."
        record_call: { type: boolean }
        pickup_delay:
          type: integer
          minimum: 0
          maximum: 5
          description: Seconds to wait before the assistant picks up on inbound.
        speed:
          type: number
          minimum: 0.7
          maximum: 1.2
          description: Voice playback rate multiplier. Some voices only support 0.8–1.2; out-of-range values for such voices return 400.
        tts_style:
          type: string
          enum: [consistent, natural, expressive]
          description: Voice style. Maps to provider-specific expressiveness controls.
        background_ambience:
          type: string
          enum: [clean, office]
          description: Background ambience bed mixed under the assistant voice during phone calls. "clean" plays no bed.
        interruptable: { type: boolean }
        appointment_booking_enabled: { type: boolean }
        allow_preferred_language: { type: boolean }
        whitelist: { type: boolean }
        introduction:
          type: string
          description: Opening sentence the assistant says when it picks up a call.
        company_name: { type: string }
        company_industry: { type: string }
        phone_number:
          type: [string, "null"]
          description: Assigned by platform on create. Read-only — cannot be set or changed via this API.
          example: "+491701234567"
        carrier:
          type: [string, "null"]
          description: Telephony carrier assigned by the platform. Read-only.
        prompt_id: { type: [string, "null"], pattern: "^[a-f0-9]{24}$" }
        email_prompt_id: { type: [string, "null"], pattern: "^[a-f0-9]{24}$" }
        knowledge_group_ids:
          type: array
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
        glossary_group_ids:
          type: array
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
        pronunciation_group_ids:
          type: array
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
        forwarding_group_ids:
          type: array
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
        contact_group_ids:
          type: array
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
        calendar_ids:
          type: array
          maxItems: 1
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
          description: At most one calendar per assistant.
        tool_ids:
          type: array
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
          description: |
            Webhook-type tools only. Integration-type / internal tools cannot be
            referenced via the public API and will return 404 on create/update.
        analysis_ids:
          type: array
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
          description: Call-analysis definition IDs to run on this assistant's calls.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    AssistantCreate:
      type: object
      required: [name, language, voice_id, company_name, company_industry, introduction]
      additionalProperties: false
      properties:
        name: { type: string, minLength: 1, maxLength: 100 }
        description: { type: string, maxLength: 500, default: "" }
        language:
          type: string
          enum: [de, en, fr, it, de-CH, es, pt, tr, sv, fi]
          description: "BCP-47-ish language code from the platform-supported list."
        voice_id: { type: string, description: "Voice provider voice ID. Must be one of the platform-supported values; the full list is available via the dashboard. Sending an unknown ID returns 400." }
        company_name: { type: string, minLength: 1, maxLength: 200 }
        company_industry: { type: string, minLength: 1, maxLength: 200 }
        introduction: { type: string, minLength: 1, maxLength: 5000 }
        record_call: { type: boolean }
        pickup_delay: { type: integer, minimum: 0, maximum: 5 }
        speed:
          type: number
          minimum: 0.7
          maximum: 1.2
          description: Voice playback rate multiplier. Some voices only support 0.8–1.2; out-of-range values for such voices return 400.
        tts_style:
          type: string
          enum: [consistent, natural, expressive]
          description: Voice style. Omit to use natural.
        background_ambience:
          type: string
          enum: [clean, office]
          description: Background ambience bed mixed under the assistant voice during phone calls. Omit to use clean (no bed).
        interruptable: { type: boolean }
        appointment_booking_enabled: { type: boolean }
        allow_preferred_language: { type: boolean }
        whitelist: { type: boolean }
        prompt_id: { type: [string, "null"], pattern: "^[a-f0-9]{24}$" }
        email_prompt_id: { type: [string, "null"], pattern: "^[a-f0-9]{24}$" }
        knowledge_group_ids:
          type: array
          default: []
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
        glossary_group_ids:
          type: array
          default: []
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
        pronunciation_group_ids:
          type: array
          default: []
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
        forwarding_group_ids:
          type: array
          default: []
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
        contact_group_ids:
          type: array
          default: []
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
        calendar_ids:
          type: array
          default: []
          maxItems: 1
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
        tool_ids:
          type: array
          default: []
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
        analysis_ids:
          type: array
          default: []
          items: { type: string, pattern: "^[a-f0-9]{24}$" }

    AssistantUpdate:
      type: object
      minProperties: 1
      additionalProperties: false
      description: Partial update — include only the fields you want to change.
      properties:
        name: { type: string, minLength: 1, maxLength: 100 }
        description: { type: string, maxLength: 500 }
        language:
          type: string
          enum: [de, en, fr, it, de-CH, es, pt, tr, sv, fi]
        voice_id: { type: string, description: "Voice provider voice ID. Must be one of the platform-supported values; the full list is available via the dashboard. Sending an unknown ID returns 400." }
        company_name: { type: string, minLength: 1, maxLength: 200 }
        company_industry: { type: string, minLength: 1, maxLength: 200 }
        introduction: { type: string, minLength: 1, maxLength: 5000 }
        record_call: { type: boolean }
        pickup_delay: { type: integer, minimum: 0, maximum: 5 }
        speed:
          type: number
          minimum: 0.7
          maximum: 1.2
          description: Voice playback rate multiplier. Some voices only support 0.8–1.2; out-of-range values for such voices return 400.
        tts_style:
          type: string
          enum: [consistent, natural, expressive]
          description: Voice style.
        background_ambience:
          type: string
          enum: [clean, office]
          description: Background ambience bed mixed under the assistant voice during phone calls.
        interruptable: { type: boolean }
        appointment_booking_enabled: { type: boolean }
        allow_preferred_language: { type: boolean }
        whitelist: { type: boolean }
        prompt_id: { type: [string, "null"], pattern: "^[a-f0-9]{24}$" }
        email_prompt_id: { type: [string, "null"], pattern: "^[a-f0-9]{24}$" }
        knowledge_group_ids:
          type: array
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
        glossary_group_ids:
          type: array
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
        pronunciation_group_ids:
          type: array
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
        forwarding_group_ids:
          type: array
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
        contact_group_ids:
          type: array
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
        calendar_ids:
          type: array
          maxItems: 1
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
        tool_ids:
          type: array
          items: { type: string, pattern: "^[a-f0-9]{24}$" }
          description: |
            Webhook-type tools only. Integration-type / internal tools cannot be
            referenced via the public API and will return 404 on create/update.
        analysis_ids:
          type: array
          items: { type: string, pattern: "^[a-f0-9]{24}$" }

    PageAssistant:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Assistant" }
        meta: { $ref: "#/components/schemas/PageMeta" }

    # ── Forwardings ───────────────────────────────────────────────────────
    DtmfSequenceItem:
      type: object
      required: [action]
      properties:
        action:
          type: string
          enum: [pause, dtmf]
          description: |
            Step kind:
              - `pause` — pause for `duration` ms before the next step.
              - `dtmf` — send the `digits` as DTMF tones.
        duration:
          type: [integer, "null"]
          description: Pause duration in milliseconds. Relevant when `action=pause`.
        digits:
          type: [string, "null"]
          description: DTMF digit string to send. Relevant when `action=dtmf`.

    Forwarding:
      type: object
      required:
        - id
        - group_id
        - group_name
        - type
        - gender
        - job_title
        - first_name
        - last_name
        - email
        - competence
        - activation_keywords
        - destination_type
        - phone_number
        - sip_uri
        - sip_username
        - sip_refer_target
        - has_sip_credentials
        - active
        - fallback
        - timeout
        - dtmf_sequence
        - created_at
        - updated_at
      properties:
        id: { type: string, example: "65f8fa6f1a9d8e0012ab34cd" }
        group_id: { type: string, example: "65a1234567890abcdef01234" }
        group_name: { type: [string, "null"], example: "Escalations" }
        type:
          type: [string, "null"]
          enum: [personal, queue, fallback, null]
          description: Forwarding kind. `null` if unset.
        gender: { type: string, example: "female" }
        job_title: { type: string, example: "Head of Support" }
        first_name: { type: string, example: "Ada" }
        last_name: { type: string, example: "Lovelace" }
        email: { type: string, example: "ada@example.com" }
        competence: { type: string, example: "billing" }
        activation_keywords:
          type: array
          description: |
            Optional per-forwarding activation keywords. When every active forwarding
            has at least one custom activation keyword, generic transfer words are
            not used for forwarding activation.
          items:
            type: string
            maxLength: 80
          maxItems: 50
        destination_type:
          type: string
          enum: [phone, sip, sip_refer]
          description: Where the call routes — an E.164 phone number, SIP URI, or SIP REFER target.
        phone_number:
          type: string
          example: "+491701234567"
          description: >-
            Destination number for `phone` forwarding. May be an empty string
            when it was omitted at write time; such a target cannot be dialed.
        sip_uri:
          type: string
          description: SIP URI in `sip:user@host` form. Relevant when `destination_type=sip`.
          example: "sip:forwarding@example.com"
        sip_username:
          type: [string, "null"]
          description: Optional SIP auth username. Relevant when `destination_type=sip`.
        sip_refer_target:
          type: [string, "null"]
          description: SIP REFER target. Relevant when `destination_type=sip_refer`.
        has_sip_credentials:
          type: [boolean, "null"]
          description: Whether SIP credentials are stored for this forwarding. `sip_password` is never returned.
        active: { type: boolean, default: true }
        fallback: { type: boolean, default: false }
        timeout:
          type: integer
          minimum: 10
          maximum: 60
          default: 30
          description: Ring timeout in seconds before the call gives up on this target.
        dtmf_sequence:
          type: [array, "null"]
          description: Optional DTMF steps to run after the callee picks up. `null` if none configured.
          items: { $ref: "#/components/schemas/DtmfSequenceItem" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ForwardingCreate:
      type: object
      required: [group_id]
      additionalProperties: false
      description: |
        When `destination_type` is `sip`, `sip_uri` must be provided. When
        `destination_type` is `sip_refer`, `sip_refer_target` must be provided.
        For a usable `phone` destination, provide a non-empty `phone_number`.
        The current validator permits it to be omitted or empty, but such a
        forwarding cannot be dialed.
      properties:
        group_id: { type: string, pattern: "^[a-f0-9]{24}$" }
        type:
          type: string
          enum: [personal, queue, fallback]
        destination_type:
          type: string
          enum: [phone, sip, sip_refer]
          default: phone
        gender: { type: string }
        job_title: { type: string }
        first_name: { type: string }
        last_name: { type: string }
        email: { type: string }
        competence: { type: string }
        activation_keywords:
          type: array
          items: { type: string, maxLength: 80 }
          maxItems: 50
        phone_number: { type: string, example: "+491701234567" }
        sip_uri:
          type: string
          pattern: "^sip:"
          description: SIP URI. Required when `destination_type=sip`.
          example: "sip:forwarding@example.com"
        sip_username:
          type: [string, "null"]
          description: Optional SIP auth username. Only allowed when `destination_type=sip`.
        sip_password:
          type: [string, "null"]
          format: password
          description: Optional SIP auth password. Only allowed when `destination_type=sip`; never returned in responses.
        sip_refer_target:
          type: string
          description: SIP REFER target. Required when `destination_type=sip_refer`.
        active: { type: boolean }
        fallback: { type: boolean }
        timeout: { type: integer, minimum: 10, maximum: 60 }
        dtmf_sequence:
          type: array
          items: { $ref: "#/components/schemas/DtmfSequenceItem" }
      allOf:
        - if:
            properties:
              destination_type:
                const: sip
            required: [destination_type]
          then:
            properties:
              sip_uri: {}
            required: [sip_uri]
        - if:
            properties:
              destination_type:
                const: sip_refer
            required: [destination_type]
          then:
            properties:
              sip_refer_target: {}
            required: [sip_refer_target]

    ForwardingUpdate:
      type: object
      minProperties: 1
      additionalProperties: false
      description: |
        Partial update — include only the fields you want to change.
        `group_id` cannot be changed via this endpoint.
      properties:
        type:
          type: string
          enum: [personal, queue, fallback]
        destination_type:
          type: string
          enum: [phone, sip, sip_refer]
        gender: { type: string }
        job_title: { type: string }
        first_name: { type: string }
        last_name: { type: string }
        email: { type: string }
        competence: { type: string }
        activation_keywords:
          type: array
          items: { type: string, maxLength: 80 }
          maxItems: 50
        phone_number: { type: string }
        sip_uri:
          type: string
          pattern: "^sip:"
        sip_username:
          type: [string, "null"]
          description: Optional SIP auth username. Only allowed when the effective `destination_type` is `sip`.
        sip_password:
          type: [string, "null"]
          format: password
          description: Optional SIP auth password. Only allowed when the effective `destination_type` is `sip`; never returned in responses.
        sip_refer_target:
          type: string
          description: SIP REFER target. Required when changing `destination_type` to `sip_refer`.
        active: { type: boolean }
        fallback: { type: boolean }
        timeout: { type: integer, minimum: 10, maximum: 60 }
        dtmf_sequence:
          type: array
          items: { $ref: "#/components/schemas/DtmfSequenceItem" }

    PageForwarding:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Forwarding" }
        meta: { $ref: "#/components/schemas/PageMeta" }

    # ── Glossary ──────────────────────────────────────────────────────────
    Glossary:
      type: object
      required:
        - id
        - group_id
        - group_name
        - abbreviation
        - name
        - description
        - active
        - created_at
        - updated_at
      properties:
        id: { type: string, example: "65f8fa6f1a9d8e0012ab34cd" }
        group_id: { type: string, example: "65a1234567890abcdef01234" }
        group_name: { type: [string, "null"], example: "Product terminology" }
        abbreviation: { type: string, example: "API" }
        name: { type: string, example: "Application Programming Interface" }
        description:
          type: string
          example: "A set of definitions and protocols for building software."
        active: { type: boolean, default: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    GlossaryCreate:
      type: object
      required: [group_id, name]
      additionalProperties: false
      properties:
        group_id: { type: string, pattern: "^[a-f0-9]{24}$" }
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Whitespace is trimmed; the trimmed name must be at least 1 character.
        abbreviation: { type: string, maxLength: 100, default: "" }
        description: { type: string, maxLength: 5000, default: "" }
        active: { type: boolean, default: true }

    GlossaryUpdate:
      type: object
      minProperties: 1
      additionalProperties: false
      description: |
        Partial update — include only the fields you want to change.
        `group_id` cannot be changed via this endpoint.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Whitespace is trimmed; the trimmed name must be at least 1 character.
        abbreviation: { type: string, maxLength: 100 }
        description: { type: string, maxLength: 5000 }
        active: { type: boolean }

    PageGlossary:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Glossary" }
        meta: { $ref: "#/components/schemas/PageMeta" }

    # ── Pronunciation ──────────────────────────────────────────────────────
    Pronunciation:
      type: object
      required:
        - id
        - group_id
        - group_name
        - word
        - replacement
        - ipa
        - spell_out
        - rate
        - case_sensitive
        - active
        - created_at
        - updated_at
      properties:
        id: { type: string, example: "65f8fa6f1a9d8e0012ab34cd" }
        group_id: { type: string, example: "65a1234567890abcdef01234" }
        group_name: { type: [string, "null"], example: "Brand names" }
        word:
          type: string
          minLength: 1
          maxLength: 200
          pattern: "^[^\\s<>]+$"
          example: "smao"
          description: The written single-token form to match in generated speech. Whitespace and angle brackets (`<`, `>`) are rejected.
        replacement:
          type: string
          example: "ess-mao"
          description: Phonetic respelling spoken in place of the word.
        ipa:
          type: string
          example: "ɛsmaʊ"
          description: International Phonetic Alphabet transcription. Applied on v2 (KugelAudio) voices; ignored on v1 (ElevenLabs) voices.
        spell_out:
          type: boolean
          default: false
          description: When true, the matched word is spoken letter by letter — `API` is spoken as `A`, `P`, `I`.
        rate:
          type: [string, "null"]
          enum: [null, slow, medium, fast]
          default: null
          description: Speaking-rate override applied when the word is spoken. `null` inherits the voice/request speed; `medium` is explicit normal speed. Applied on v2 (KugelAudio) voices; ignored on v1 (ElevenLabs) voices.
        case_sensitive: { type: boolean, default: false }
        active: { type: boolean, default: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    PronunciationCreate:
      type: object
      required: [group_id, word]
      additionalProperties: false
      properties:
        group_id: { type: string, pattern: "^[a-f0-9]{24}$" }
        word:
          type: string
          minLength: 1
          maxLength: 200
          pattern: "^[^\\s<>]+$"
          description: |
            The written single-token form to match. Whitespace and angle
            brackets (`<`, `>`) are rejected.
        replacement:
          type: string
          maxLength: 1000
          default: ""
          description: Phonetic respelling. Angle brackets (`<`, `>`) are rejected. Must be empty when `spell_out` is true.
        ipa:
          type: string
          maxLength: 1000
          default: ""
          description: International Phonetic Alphabet transcription. Applied on v2 (KugelAudio) voices; ignored on v1 (ElevenLabs) voices. Must be empty when `spell_out` is true.
        spell_out:
          type: boolean
          default: false
          description: When true, the matched word is spoken letter by letter — `API` is spoken as `A`, `P`, `I`.
        rate:
          type: [string, "null"]
          enum: [null, slow, medium, fast]
          default: null
          description: Speaking-rate override. `null` inherits the voice/request speed; `medium` is explicit normal speed.
        case_sensitive: { type: boolean, default: false }
        active: { type: boolean, default: true }

    PronunciationUpdate:
      type: object
      minProperties: 1
      additionalProperties: false
      description: |
        Partial update — include only the fields you want to change.
        `group_id` cannot be changed via this endpoint.
        The final merged state must not combine `spell_out: true` with a
        non-empty `replacement` or `ipa`.
      properties:
        word:
          type: string
          minLength: 1
          maxLength: 200
          pattern: "^[^\\s<>]+$"
          description: |
            The written single-token form to match. Whitespace and angle
            brackets (`<`, `>`) are rejected.
        replacement:
          type: string
          maxLength: 1000
          description: Angle brackets (`<`, `>`) are rejected. Must be empty when the final `spell_out` state is true.
        ipa:
          type: string
          maxLength: 1000
          description: International Phonetic Alphabet transcription. Applied on v2 (KugelAudio) voices; ignored on v1 (ElevenLabs) voices. Must be empty when the final `spell_out` state is true.
        spell_out:
          type: boolean
          description: When true, the matched word is spoken letter by letter — `API` is spoken as `A`, `P`, `I`.
        rate:
          type: [string, "null"]
          enum: [null, slow, medium, fast]
          description: Speaking-rate override. Send `null` to return to the default inherited speed, or `medium` for an explicit normal-speed span.
        case_sensitive: { type: boolean }
        active: { type: boolean }

    PagePronunciation:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Pronunciation" }
        meta: { $ref: "#/components/schemas/PageMeta" }

    # ── Calendars ─────────────────────────────────────────────────────────
    CalendarSlot:
      type: object
      required: [from, to]
      additionalProperties: false
      properties:
        from:
          type: string
          pattern: "^([01][0-9]|2[0-3]):[0-5][0-9]$"
          example: "09:00"
        to:
          type: string
          pattern: "^([01][0-9]|2[0-3]):[0-5][0-9]$"
          example: "17:00"

    CalendarDaySchedule:
      type: object
      required: [is_open, slots]
      additionalProperties: false
      properties:
        is_open: { type: boolean }
        slots:
          type: array
          items: { $ref: "#/components/schemas/CalendarSlot" }

    CalendarWeeklySchedule:
      type: object
      description: |
        Weekly opening-hours template. Each weekday is optional. Omitted
        weekdays (`mon`–`fri`) default to open 09:00–17:00; omitted weekend
        days (`sat`–`sun`) default to closed with no slots. To close an omitted
        weekday explicitly, send `{ "is_open": false, "slots": [] }`.
        Keys use 3-letter day abbreviations: `mon`, `tue`, `wed`, `thu`, `fri`,
        `sat`, `sun`.
      additionalProperties: false
      properties:
        mon: { $ref: "#/components/schemas/CalendarDaySchedule" }
        tue: { $ref: "#/components/schemas/CalendarDaySchedule" }
        wed: { $ref: "#/components/schemas/CalendarDaySchedule" }
        thu: { $ref: "#/components/schemas/CalendarDaySchedule" }
        fri: { $ref: "#/components/schemas/CalendarDaySchedule" }
        sat: { $ref: "#/components/schemas/CalendarDaySchedule" }
        sun: { $ref: "#/components/schemas/CalendarDaySchedule" }

    CalendarOverride:
      type: object
      required: [date, type]
      additionalProperties: false
      description: |
        Date-specific override — either closes the calendar for the day
        (`type: closed`) or replaces the day's slots (`type: modified`).
        `slots` is required (and must contain at least one entry) when
        `type` is `modified`; it is optional/omitted when `type` is `closed`.
      properties:
        date:
          type: string
          pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
          example: "2026-12-24"
          description: >-
            Date in `YYYY-MM-DD` shape. Send a real calendar date; current
            request validation checks the shape but not month/day validity.
        type:
          type: string
          enum: [closed, modified]
        reason:
          type: [string, "null"]
          example: "Christmas Eve"
        slots:
          type: array
          description: |
            Always present in responses. On request input, required and
            non-empty when `type: modified`. For `type: closed`, supplied
            values are accepted, stored, and returned, but ignored when
            effective opening hours are computed; omit them to receive `[]`.
          items: { $ref: "#/components/schemas/CalendarSlot" }

    CalendarSchedule:
      type: object
      required: [id, type, weekly_schedule, overrides]
      additionalProperties: false
      description: |
        Schedule as returned in responses. `id` is always present and
        server-assigned. For write payloads see `CalendarScheduleCreate` and
        `CalendarScheduleUpdate`.
      properties:
        id: { type: string, example: "65f8fa6f1a9d8e0012ab34cd" }
        type:
          type: string
          enum: [opening_hours, service_hours, office_hours, operating_hours]
        weekly_schedule: { $ref: "#/components/schemas/CalendarWeeklySchedule" }
        overrides:
          type: array
          items: { $ref: "#/components/schemas/CalendarOverride" }

    CalendarScheduleCreate:
      type: object
      required: [type]
      additionalProperties: false
      description: |
        Schedule shape for create requests. `id` is server-assigned on POST
        and therefore omitted; for the response shape see `CalendarSchedule`.
      properties:
        type:
          type: string
          enum: [opening_hours, service_hours, office_hours, operating_hours]
        weekly_schedule: { $ref: "#/components/schemas/CalendarWeeklySchedule" }
        overrides:
          type: array
          items: { $ref: "#/components/schemas/CalendarOverride" }

    CalendarScheduleUpdate:
      type: object
      required: [type]
      additionalProperties: false
      description: |
        Schedule shape accepted when replacing `schedules` on PATCH. Include
        the response `id` to preserve an existing schedule's identifier; omit
        it to let the server assign a new identifier.
      properties:
        id:
          type: string
          pattern: "^[a-f0-9]{24}$"
        type:
          type: string
          enum: [opening_hours, service_hours, office_hours, operating_hours]
        weekly_schedule: { $ref: "#/components/schemas/CalendarWeeklySchedule" }
        overrides:
          type: array
          items: { $ref: "#/components/schemas/CalendarOverride" }

    Calendar:
      type: object
      required:
        - id
        - name
        - timezone
        - schedules
        - created_at
        - updated_at
      properties:
        id: { type: string, example: "65f8fa6f1a9d8e0012ab34cd" }
        name: { type: string, example: "Main Office" }
        timezone: { type: string, example: "Europe/Berlin" }
        schedules:
          type: array
          items: { $ref: "#/components/schemas/CalendarSchedule" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CalendarCreate:
      type: object
      required: [name]
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
          description: Whitespace is trimmed; the trimmed name must be at least 1 character.
        timezone:
          type: string
          minLength: 1
          maxLength: 100
          default: "Europe/Berlin"
          description: IANA timezone string. Validated server-side.
        schedules:
          type: array
          items: { $ref: "#/components/schemas/CalendarScheduleCreate" }
          default: []

    CalendarUpdate:
      type: object
      minProperties: 1
      additionalProperties: false
      description: |
        Partial update — include only the fields you want to change. Passing
        `schedules` replaces the full schedules array for this calendar.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
          description: Whitespace is trimmed; the trimmed name must be at least 1 character.
        timezone: { type: string, minLength: 1, maxLength: 100 }
        schedules:
          type: array
          items: { $ref: "#/components/schemas/CalendarScheduleUpdate" }

    PageCalendar:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Calendar" }
        meta: { $ref: "#/components/schemas/PageMeta" }

    # ── Calls ─────────────────────────────────────────────────────────────
    RecordingSummary:
      type: object
      required: [available]
      properties:
        available: { type: boolean }
        format: { type: [string, "null"], example: "flac" }
        mime_type: { type: [string, "null"], example: "audio/flac" }
        duration_ms: { type: [integer, "null"] }
        size_bytes: { type: [integer, "null"] }

    Call:
      type: object
      required:
        - id
        - direction
        - source
        - done
        - success
        - created_at
      properties:
        id: { type: string }
        assistant_id: { type: [string, "null"] }
        assistant_name: { type: [string, "null"] }
        assistant_phone: { type: [string, "null"] }
        direction: { type: string, enum: [inbound, outbound] }
        source:
          type: string
          enum: [inbound_phone, outbound_widget, outbound_api, outbound_manual]
          description: |
            How the call was originated:
              - `inbound_phone` — caller dialed in from a real phone number.
              - `outbound_widget` — chat widget kicked off an outbound call.
              - `outbound_api` — Public API (POST /calls) triggered the call.
              - `outbound_manual` — dashboard user manually triggered the call.
        phone_number: { type: [string, "null"], description: "Remote party phone number." }
        duration_seconds: { type: [integer, "null"] }
        done: { type: boolean }
        success: { type: boolean }
        language: { type: [string, "null"] }
        started_at: { type: [string, "null"], format: date-time }
        ended_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    CallTranscriptMessage:
      type: object
      required: [role, content]
      properties:
        role: { type: string, example: "assistant" }
        content: { type: string }
        timestamp: { type: [string, "null"], format: date-time }

    CallAnalysis:
      type: object
      required: [id, status]
      properties:
        id: { type: string }
        name: { type: [string, "null"] }
        status: { type: string, example: "completed" }
        body_result:
          description: Raw analysis result. Shape depends on the configured analysis.
          oneOf:
            - type: object
            - type: string
            - type: "null"
        started_at: { type: [string, "null"], format: date-time }
        completed_at: { type: [string, "null"], format: date-time }

    CallNote:
      type: object
      required: [id, content, created_at]
      properties:
        id: { type: string }
        content: { type: string }
        author_id: { type: [string, "null"] }
        author_name: { type: [string, "null"] }
        created_at: { type: string, format: date-time }

    CallDetail:
      allOf:
        - $ref: "#/components/schemas/Call"
        - type: object
          required: [transcript, analyses, notes, recording]
          properties:
            transcript:
              type: array
              items: { $ref: "#/components/schemas/CallTranscriptMessage" }
            analyses:
              type: array
              items: { $ref: "#/components/schemas/CallAnalysis" }
            notes:
              type: array
              items: { $ref: "#/components/schemas/CallNote" }
            recording: { $ref: "#/components/schemas/RecordingSummary" }

    PageCall:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Call" }
        meta: { $ref: "#/components/schemas/PageMeta" }

    TriggerOutbound:
      type: object
      additionalProperties: false
      description: |
        Provide **exactly one** of `assistant_id` or `assistant_phone`, and
        **exactly one** of `to` or `target_phone` (`target_phone` is a legacy alias).
      properties:
        assistant_id:
          type: string
          pattern: "^[a-f0-9]{24}$"
          description: The assistant that will make the call.
        assistant_phone:
          type: string
          pattern: "^\\+\\d{6,20}$"
          example: "+491701234567"
          description: >-
            Alternative to `assistant_id` — look up the assistant by its phone
            number in E.164 format (leading `+` followed by 6–20 digits).
        to:
          type: string
          pattern: "^\\+\\d{6,20}$"
          example: "+491701234999"
          description: >-
            Destination phone number in E.164 format (leading `+` followed by
            6–20 digits).
        target_phone:
          type: string
          pattern: "^\\+\\d{6,20}$"
          description: Legacy alias for `to`. Same E.164 format.
        details:
          type: string
          maxLength: 1000
          description: Optional free-text context passed to the assistant prompt.
      oneOf:
        - required: [assistant_id, to]
        - required: [assistant_id, target_phone]
        - required: [assistant_phone, to]
        - required: [assistant_phone, target_phone]

    # ── Statistics ────────────────────────────────────────────────────────
    StatisticsWeekYear:
      type: object
      required: [week, year]
      properties:
        week: { type: integer, example: 17 }
        year: { type: integer, example: 2026 }

    StatisticsAvailableAssistant:
      type: object
      required: [id, name]
      properties:
        id: { type: string, pattern: "^[a-f0-9]{24}$", example: "65a1234567890abcdef01234" }
        name: { type: string, example: "Inbound Receptionist" }

    StatisticsDailyCallCount:
      type: object
      required: [date, count]
      properties:
        date: { type: string, example: "2026-04-22", description: "ISO date (YYYY-MM-DD), Europe/Berlin timezone." }
        count: { type: integer, example: 12 }

    StatisticsMonthlyBreakdown:
      type: object
      required: [call_count, overall_call_seconds, average_calls_per_day, daily_call_counts]
      properties:
        call_count:
          type: integer
          example: 240
          description: >-
            Completed, non-error calls in the selected ISO-week window,
            including calls that have no usable duration.
        overall_call_seconds:
          type: integer
          example: 60000
          description: >-
            Sum of billable seconds. Calls with a usable duration have a
            60-second minimum; calls without one contribute 0 seconds even
            though they remain included in `call_count`.
        average_calls_per_day:
          type: number
          example: 12
          description: >-
            Calls divided by the number of days that had calls (not all
            calendar days in the month), rounded to the nearest whole number.
        daily_call_counts:
          type: array
          items: { $ref: "#/components/schemas/StatisticsDailyCallCount" }

    StatisticsSummary:
      type: object
      required: [available_weeks, available_assistants]
      properties:
        available_weeks:
          type: array
          description: Every (week, year) pair with at least one completed call.
          items: { $ref: "#/components/schemas/StatisticsWeekYear" }
        available_assistants:
          type: array
          description: >-
            Every assistant currently in the organization. The sentinel
            `all` is NOT included — clients may always pass
            `assistant_id=all` to `/usage`.
          items: { $ref: "#/components/schemas/StatisticsAvailableAssistant" }

    StatisticsUsage:
      type: object
      required:
        - period
        - assistant_id
        - monthly
        - weekly_call_counts
        - weekly_call_seconds
        - hourly_call_counts_last_24h
      properties:
        period: { $ref: "#/components/schemas/StatisticsWeekYear" }
        assistant_id:
          oneOf:
            - type: string
              enum: [all]
            - type: string
              pattern: "^[a-f0-9]{24}$"
          description: Echoes the filter the client sent.
        monthly:
          type: object
          description: >-
            Breakdown for the month or months intersecting the selected ISO
            week, keyed by month name from the server runtime locale (for
            example `April`). Only months with at least one completed,
            non-error call appear. Treat keys as display labels, not stable
            identifiers.
          additionalProperties: { $ref: "#/components/schemas/StatisticsMonthlyBreakdown" }
        weekly_call_counts:
          type: array
          description: >-
            7 integers, Mon..Sun, for the selected (week, year). Unlike
            `monthly.*.call_count`, this array counts only calls with a usable
            duration greater than zero. The service returns an empty array
            when no calls match.
          minItems: 0
          maxItems: 7
          items: { type: integer }
        weekly_call_seconds:
          type: array
          description: >-
            7 integers of billable seconds, Mon..Sun, for the selected week.
            The service returns an empty array when no calls match.
          minItems: 0
          maxItems: 7
          items: { type: integer }
        hourly_call_counts_last_24h:
          type: array
          description: >-
            24 integers for the last 24 hours ending at request time
            (index 0 = 23–24h ago, index 23 = 0–1h ago). Counts only calls with
            a positive recorded `callTime` (including the legacy nested
            fallback). This array is organization-wide and independent of the
            `week`, `year`, and `assistant_id` filters.
          minItems: 24
          maxItems: 24
          items: { type: integer }

    StatisticsAssistantEntry:
      type: object
      required:
        - assistant_id
        - assistant_name
        - assistant_language
        - is_deleted
        - total_calls
        - total_call_seconds
        - average_call_seconds
        - average_calls_per_day
        - weekly_call_counts
        - weekly_call_seconds
        - daily_call_counts
      properties:
        assistant_id:
          type: [string, "null"]
          pattern: "^[a-f0-9]{24}$"
          description: >-
            Null when the call was recorded without an assistant reference
            (legacy or orphaned calls).
        assistant_name: { type: string }
        assistant_language:
          type: [string, "null"]
          description: >-
            Assistant language tag (commonly a 2-letter code like `de`/`en`,
            or BCP-47 like `de-DE`), or `null` if unset. Format is
            provider-defined and not validated.
        is_deleted:
          type: boolean
          description: True if the assistant has since been deleted.
        total_calls:
          type: integer
          description: >-
            Completed, non-error calls in the week, including calls without a
            usable duration.
        total_call_seconds:
          type: integer
          description: >-
            Billable seconds for the week. Calls with a usable duration have a
            60-second minimum; durationless calls contribute 0 seconds but
            remain included in `total_calls` and count arrays.
        average_call_seconds:
          type: number
          description: Mean billable seconds per call in the week, rounded to the nearest whole second.
        average_calls_per_day:
          type: number
          description: Mean calls per day across days that had calls, rounded to one decimal place.
        weekly_call_counts:
          type: array
          minItems: 7
          maxItems: 7
          items: { type: integer }
          description: >-
            Mon..Sun completed, non-error call counts, including calls without
            a usable duration.
        weekly_call_seconds:
          type: array
          minItems: 7
          maxItems: 7
          items: { type: integer }
          description: Mon..Sun billable seconds.
        daily_call_counts:
          type: array
          items: { $ref: "#/components/schemas/StatisticsDailyCallCount" }

    StatisticsAssistantsUsage:
      type: object
      required: [period, total_assistants, total_calls, assistants]
      properties:
        period: { $ref: "#/components/schemas/StatisticsWeekYear" }
        total_assistants: { type: integer }
        total_calls: { type: integer }
        assistants:
          type: array
          items: { $ref: "#/components/schemas/StatisticsAssistantEntry" }

    # ── Tools ─────────────────────────────────────────────────────────────
    Tool:
      type: object
      required:
        - id
        - tool_key
        - display_name
        - description
        - parameters
        - execution_type
        - source
        - webhook
        - integration
        - auth
        - created_at
        - updated_at
      additionalProperties: false
      properties:
        id: { type: string, pattern: "^[a-f0-9]{24}$" }
        tool_key: { type: string, example: "get_opening_hours" }
        display_name: { type: string, example: "Get Opening Hours" }
        description: { type: string, example: "Returns the opening hours for the requested weekday." }
        parameters:
          type: object
          additionalProperties: true
          description: JSON Schema for tool arguments. Customer-defined.
        execution_type:
          type: string
          enum: [webhook]
          description: >-
            Public API list/get operations expose webhook tools only.
            Integration-type tools created in the dashboard are filtered out.
        source:
          type: string
          enum: [custom, template]
        webhook:
          oneOf:
            - { $ref: "#/components/schemas/ToolWebhookConfig" }
            - { type: "null" }
        integration:
          type: "null"
          description: Always `null` because integration tools are not exposed by this API.
        auth:
          oneOf:
            - { $ref: "#/components/schemas/ToolAuthSummary" }
            - { type: "null" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ToolWebhookConfig:
      type: object
      required: [url]
      additionalProperties: false
      properties:
        url:
          type: string
          format: uri
          pattern: "^https://"
          description: >-
            Endpoint that receives tool invocations. Use HTTPS in every
            environment: although non-production request validation accepts
            `http://localhost`, the executor rejects every non-HTTPS URL.
            At invocation time, URLs that resolve to private, loopback,
            link-local, or cloud-metadata addresses are rejected and redirects
            are not followed — an HTTPS URL that redirects to a private
            address will fail.
        method:
          type: string
          enum: [GET, POST, PUT, PATCH, DELETE]
          default: "POST"
        headers:
          type: object
          additionalProperties: true
          description: >-
            Custom HTTP headers. WARNING — do NOT put secrets in headers.
            Use the `auth` object for bearer tokens / API keys / basic auth.
            Values in this object are stored and returned as plaintext.
        body_template:
          type: string
          maxLength: 50000
          description: >-
            Template string with `@key` placeholders for argument
            interpolation.
        timeout_ms:
          type: integer
          minimum: 500
          maximum: 60000
        retry_count:
          type: integer
          minimum: 0
          maximum: 5

    ToolAuthSummary:
      type: object
      required: [type, has_secret]
      additionalProperties: false
      description: >-
        RESPONSE-ONLY auth shape. Never contains the stored secret value; the
        API surfaces `has_secret` as the only signal of whether a credential
        is configured. To rotate, PATCH the tool with a new `auth` input.
      properties:
        type:
          type: string
          enum: [apiKey, bearer, basic]
        header_name:
          type: [string, "null"]
          description: Custom header name for `apiKey` / `bearer` auth types.
        username:
          type: [string, "null"]
          description: Username for `basic` auth.
        has_secret:
          type: boolean
          description: >-
            True when an auth secret / password is stored. The actual value
            is never returned by the API — submit a new value on PATCH to
            rotate.

    ToolAuthInput:
      description: |
        Auth credentials sent on POST/PATCH. Shape varies by `type`.
        Note: `secret` and `password` are write-only; responses use `ToolAuthSummary`.
      oneOf:
        - title: ApiKey
          type: object
          additionalProperties: false
          required: [type, header_name, secret]
          properties:
            type: { type: string, const: apiKey }
            header_name: { type: string, minLength: 1 }
            secret: { type: string, minLength: 1, writeOnly: true }
        - title: Bearer
          type: object
          additionalProperties: false
          required: [type, header_name, secret]
          properties:
            type: { type: string, const: bearer }
            header_name: { type: string, minLength: 1 }
            secret: { type: string, minLength: 1, writeOnly: true }
        - title: Basic
          type: object
          additionalProperties: false
          required: [type, username, password]
          properties:
            type: { type: string, const: basic }
            username: { type: string, minLength: 1 }
            password: { type: string, minLength: 1, writeOnly: true }
      discriminator:
        propertyName: type

    ToolCreate:
      type: object
      required: [tool_key, display_name, execution_type, webhook]
      additionalProperties: false
      properties:
        tool_key:
          type: string
          pattern: "^[a-z][a-z0-9_]*$"
          minLength: 1
          maxLength: 80
          example: "get_opening_hours"
        display_name:
          type: string
          minLength: 1
          maxLength: 200
          description: Leading/trailing whitespace is trimmed.
        description:
          type: string
          default: ""
          maxLength: 1000
        execution_type:
          type: string
          enum: [webhook]
          description: >-
            v1 accepts `webhook` only. Integration-type tools must be
            created via the dashboard (OAuth flow).
        parameters:
          type: object
          additionalProperties: true
          description: JSON Schema defining the tool arguments.
        webhook: { $ref: "#/components/schemas/ToolWebhookConfig" }
        auth: { $ref: "#/components/schemas/ToolAuthInput" }

    ToolUpdate:
      type: object
      minProperties: 1
      additionalProperties: false
      description: >-
        Partial update — include only fields you want to change. `tool_key`
        and `execution_type` are immutable.
      properties:
        display_name:
          type: string
          minLength: 1
          maxLength: 200
          description: Leading/trailing whitespace is trimmed.
        description:
          type: string
          maxLength: 1000
        parameters:
          type: object
          additionalProperties: true
        webhook: { $ref: "#/components/schemas/ToolWebhookConfig" }
        auth: { $ref: "#/components/schemas/ToolAuthInput" }

    PageTool:
      type: object
      required: [data, meta]
      additionalProperties: false
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Tool" }
        meta: { $ref: "#/components/schemas/PageMeta" }
