openapi: 3.1.0
info:
  title: SpeechKit Server API
  version: 1.0.0
  description: |
    Stable v1 HTTP and WebSocket contract for the SpeechKit Server.
    The canonical server API prefix is `/v1/`; `/api/v1/` remains
    available as a compatibility alias. These endpoints are covered by the same
    backwards-compatibility guarantees as the Framework's public
    `pkg/speechkit` packages — fields are additive across minor releases; no
    rename or removal without a version bump.

    Ships with the container image
    `ghcr.io/kombifyio/speechkit-server:v0.26.0` and later. Earlier images
    expose the same surface but were Server-internal pre-v0.26.

  contact:
    url: https://github.com/kombifyio/SpeechKit
  license:
    name: Apache-2.0
    url: https://www.apache.org/licenses/LICENSE-2.0

servers:
  - url: https://api.speechkit.dev
    description: Replace with your deployment's URL.

# ── Auth ────────────────────────────────────────────────────────────────────
# Default server deployments require bearer auth. `auth_mode = "none"` is only
# intended for loopback/dev deployments and must not be used on public binds.
security:
  - {}
  - bearer: []
  - edgeHmac: []

paths:

  # ── Health ──────────────────────────────────────────────────────────────
  /healthz:
    get:
      operationId: getHealthz
      summary: Liveness probe
      description: |
        Always returns 200 while the process is up. Public — no auth required.
      tags: [health]
      security: []
      responses:
        '200':
          description: Process is alive.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [ok]
                  version:
                    type: string
        '405': { $ref: '#/components/responses/MethodNotAllowed' }

  /readyz:
    get:
      operationId: getReadyz
      summary: Readiness probe
      description: |
        Mode readiness for orchestrators. Returns 200 when every blocking
        component required by the enabled modes reports OK. Optional provider
        probes stay visible in `components` with `blocking=false` and can make
        `strict_status` degraded without failing this endpoint. Pass
        `?strict=true` or call `/readyz/strict` to require every component to
        report OK. Public — no auth required.
      tags: [health]
      security: []
      parameters:
        - name: strict
          in: query
          required: false
          schema: { type: boolean }
          description: When true, use strict all-components readiness semantics.
      responses:
        '200': { $ref: '#/components/responses/Readiness' }
        '503': { $ref: '#/components/responses/Readiness' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }

  /readyz/strict:
    get:
      operationId: getStrictReadyz
      summary: Strict readiness probe
      description: |
        Diagnostic readiness that returns 200 only when every registered
        component reports OK, including optional provider probes. Public — no
        auth required.
      tags: [health]
      security: []
      responses:
        '200': { $ref: '#/components/responses/Readiness' }
        '503': { $ref: '#/components/responses/Readiness' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }

  /v1/deployment/status:
    get:
      operationId: getDeploymentStatus
      summary: Read headless deployment status
      description: |
        Authenticated, redacted runtime contract for deployers and CI smoke
        tests. The endpoint accepts the service bearer credential, local admin
        auth, or an edge-HMAC identity with role `admin`; normal edge user
        identities are rejected. The response reports which environment
        variable names are active and whether their values are present; it never
        returns secret values. Use it after container startup to confirm that
        Compose, Kubernetes, or Render injected the intended bearer, edge-auth,
        and provider credentials.
      tags: [deployment]
      security:
        - bearer: []
        - edgeHmac: []
      responses:
        '200':
          description: Redacted deployment status.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeploymentStatus' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403':
          description: Authenticated identity may not read deployment status.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
    head:
      operationId: headDeploymentStatus
      summary: Probe authenticated deployment status
      description: |
        Authenticated no-body variant of `GET /v1/deployment/status`.
      tags: [deployment]
      security:
        - bearer: []
        - edgeHmac: []
      responses:
        '200':
          description: Deployment status endpoint is reachable with credentials.
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }

  # ── API-first Control Plane ────────────────────────────────────────────
  /v1/server/settings:
    get:
      operationId: getServerSettings
      summary: Read setup and runtime settings snapshot
      description: |
        Public during first-run onboarding with a sanitized bootstrap snapshot.
        Authenticated callers receive the full non-secret runtime snapshot.
      tags: [setup]
      responses:
        '200':
          description: Server setup snapshot.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ServerSettingsSnapshot' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
    patch:
      operationId: patchServerSettings
      summary: Save first-run or admin setup settings
      description: |
        During first-run onboarding, this endpoint accepts one anonymous setup
        write so automated installers can configure server auth and optional
        admin login. After onboarding is complete, settings changes require an
        admin identity through bearer/edge auth or an enabled admin session.
      tags: [setup]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ServerSettingsPatch' }
      responses:
        '200':
          description: Settings saved. One-time generated secrets are returned only in this response.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ServerSettingsSaveResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/AdminRequired' }

  /v1/server/admin/session:
    post:
      operationId: createServerAdminSession
      summary: Create browser admin session
      description: |
        Validates the standalone setup username/password when admin auth is
        enabled and returns an HttpOnly signed session cookie. This endpoint is
        unavailable when `admin_auth.enabled` is false.
      tags: [setup]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username, password]
              properties:
                username: { type: string }
                password: { type: string, format: password }
      responses:
        '200':
          description: Admin session cookie set.
          headers:
            Set-Cookie:
              schema: { type: string }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '404': { $ref: '#/components/responses/BadRequest' }

  /v1/catalog/profiles:
    get:
      operationId: listCatalogProfiles
      summary: List provider profiles
      tags: [catalog]
      parameters:
        - name: mode
          in: query
          required: false
          schema: { type: string, enum: [dictation, assist, voiceagent, voice_agent] }
      responses:
        '200':
          description: Provider catalog.
          content:
            application/json:
              schema:
                type: object
                properties:
                  profiles:
                    type: array
                    items: { $ref: '#/components/schemas/ProviderProfile' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }

  /v1/catalog/contracts:
    get:
      operationId: listCatalogContracts
      summary: List strict mode contracts
      tags: [catalog]
      responses:
        '200':
          description: Mode contracts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  contracts:
                    type: array
                    items: { $ref: '#/components/schemas/ModeContract' }
        '401': { $ref: '#/components/responses/Unauthenticated' }

  /v1/catalog/readiness:
    get:
      operationId: listCatalogReadiness
      summary: List provider readiness snapshots
      tags: [catalog]
      responses:
        '200':
          description: Readiness for every provider profile.
          content:
            application/json:
              schema:
                type: object
                properties:
                  readiness:
                    type: array
                    items: { $ref: '#/components/schemas/ProviderReadiness' }
        '401': { $ref: '#/components/responses/Unauthenticated' }

  /v1/catalog/profiles/{id}/readiness:
    get:
      operationId: getCatalogProfileReadiness
      summary: Get provider readiness
      tags: [catalog]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Provider readiness.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProviderReadiness' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '404': { $ref: '#/components/responses/BadRequest' }

  /v1/config:
    get:
      operationId: getServerConfig
      summary: Read server configuration summary
      tags: [config]
      responses:
        '200':
          description: Read-only configuration summary.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConfigSummary' }
        '401': { $ref: '#/components/responses/Unauthenticated' }

  /v1/vocabulary/dictionary:
    get:
      operationId: listVocabularyDictionaryEntries
      summary: List dictionary entries
      tags: [vocabulary]
      parameters:
        - name: language
          in: query
          required: false
          schema: { type: string }
      responses:
        '200':
          description: Dictionary entries.
          content:
            application/json:
              schema:
                type: object
                properties:
                  entries:
                    type: array
                    items: { $ref: '#/components/schemas/DictionaryEntry' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
    post:
      operationId: replaceVocabularyDictionaryEntries
      summary: Replace dictionary entries
      description: Requires an admin identity.
      tags: [vocabulary]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                language: { type: string }
                entries:
                  type: array
                  items: { $ref: '#/components/schemas/DictionaryEntry' }
      responses:
        '200':
          description: Replaced entries.
          content:
            application/json:
              schema:
                type: object
                properties:
                  entries:
                    type: array
                    items: { $ref: '#/components/schemas/DictionaryEntry' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/AdminRequired' }

  /v1/transcripts:
    get:
      operationId: listTranscripts
      summary: List caller-scoped transcripts
      tags: [storage]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 200 }
      responses:
        '200':
          description: Transcripts visible to the caller.
          content:
            application/json:
              schema:
                type: object
                properties:
                  transcripts:
                    type: array
                    items: { $ref: '#/components/schemas/Transcript' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }

  /v1/transcripts/{id}:
    get:
      operationId: getTranscript
      summary: Get caller-scoped transcript
      tags: [storage]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        '200':
          description: Transcript.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Transcript' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/AdminRequired' }
        '404': { $ref: '#/components/responses/BadRequest' }

  /v1/voiceagent/sessions/{id}/transcript:
    get:
      operationId: getVoiceAgentSessionTranscript
      summary: Get persisted Voice Agent session transcript
      tags: [storage, voiceagent]
      parameters:
        - $ref: '#/components/parameters/SessionID'
      responses:
        '200':
          description: Session transcript.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VoiceAgentTranscript' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/AdminRequired' }
        '404': { $ref: '#/components/responses/BadRequest' }

  /v1/voiceagent/sessions/{id}/summary:
    get:
      operationId: getVoiceAgentSessionSummary
      summary: Get persisted Voice Agent session summary
      tags: [storage, voiceagent]
      parameters:
        - $ref: '#/components/parameters/SessionID'
      responses:
        '200':
          description: Session summary.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VoiceAgentSummaryResponse' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/AdminRequired' }
        '404': { $ref: '#/components/responses/BadRequest' }

  /v1/tts/synthesize:
    post:
      operationId: synthesizeTTS
      summary: Synthesize text to audio
      tags: [tts]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TTSSynthesizeRequest' }
      responses:
        '200':
          description: Synthesized audio as base64.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TTSSynthesizeResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }

  /v1/tts/voices:
    get:
      operationId: listTTSVoices
      summary: List configured TTS voices
      tags: [tts]
      responses:
        '200':
          description: Voice list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  voices:
                    type: array
                    items: { $ref: '#/components/schemas/Voice' }
        '401': { $ref: '#/components/responses/Unauthenticated' }

  # ── Dictation ───────────────────────────────────────────────────────────
  /v1/dictation/transcribe:
    post:
      operationId: transcribeDictation
      summary: Transcribe audio to text
      description: |
        Routes audio through the configured STT provider and returns the
        resulting transcript. Accepts either a multipart upload or JSON
        with base64-encoded audio.

        Supported audio formats: WAV (PCM 16-bit), MP3, raw PCM 16 kHz S16
        mono, WebM/Opus, OGG/Opus. WebM and OGG require ffmpeg in the
        container (default for Dockerfile.server).
      tags: [dictation]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [audio]
              properties:
                audio:
                  type: string
                  format: binary
                language:
                  type: string
                  description: BCP-47 hint, e.g. "de", "en", "auto".
                model:
                  type: string
                prompt:
                  type: string
          application/json:
            schema:
              $ref: '#/components/schemas/DictateJSONRequest'
      responses:
        '200':
          description: Transcription succeeded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DictateResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422':
          description: STT completed but returned no transcript.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }

  # ── Assist ──────────────────────────────────────────────────────────────
  /v1/assist/process:
    post:
      operationId: processAssist
      summary: STT → codeword/utility/LLM → optional TTS
      description: |
        Accepts either an audio payload (transcribed via STT first) or a
        text transcript directly. Runs the Framework's Assist pipeline,
        which routes to a tool intent or the LLM agent flow, and returns
        the result plus optional TTS audio as base64 in the JSON response.

        Tool execution is NOT done server-side; clients receive an
        `action: "execute"` signal plus the matched shortcut name and
        perform the action themselves.
      tags: [assist]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                audio:
                  type: string
                  format: binary
                text:
                  type: string
                locale:
                  type: string
                selection:
                  type: string
                context:
                  type: string
                tts:
                  type: string
                  description: '"true"/"false" — opt out of TTS audio in the response.'
                tts_format:
                  type: string
                  enum: [mp3, wav, opus]
                tts_voice:
                  type: string
          application/json:
            schema: { $ref: '#/components/schemas/AssistJSONRequest' }
      responses:
        '200':
          description: Pipeline succeeded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AssistResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422':
          description: STT returned an empty transcript (audio contained no speech).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }

  /v1/assist/self-test:
    post:
      operationId: selfTestAssist
      summary: Run a text-only Assist self-test
      description: |
        Runs a fixed text-only Assist prompt with TTS output ignored. Use this
        to separate client/proxy issues from server-side LLM/provider
        configuration failures.
      tags: [assist]
      responses:
        '200':
          description: Assist pipeline produced a non-empty response.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AssistSelfTestResponse' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }

  # ── Voice Agent ─────────────────────────────────────────────────────────
  /v1/voiceagent/sessions:
    post:
      operationId: createVoiceAgentSession
      summary: Create a Voice Agent session and ticket
      description: |
        Creates a session record and returns a one-time HMAC ticket the
        client uses to upgrade the WebSocket. Tickets expire (default 90 s),
        are single-use, and are bound to the session ID — they cannot be
        replayed against a different session. Expired tickets are not
        refreshed in v1; create a new session.

        The WebSocket upgrade itself happens at
        `GET /v1/voiceagent/sessions/{id}/ws` with the returned
        `ws_subprotocol`; it is documented separately because OpenAPI 3.1 has
        no first-class WebSocket primitive.

        When `[server.livekit]` is enabled, the response also includes a
        short-lived LiveKit join token for the same authenticated caller.
        The configured LiveKit deployment is the target media service.
        SpeechKit remains the token issuer and policy boundary.

        Missing realtime provider keys do not turn this endpoint into a 404.
        The session can still be created while `/readyz` reports the provider
        as degraded; the WebSocket start emits a provider connection error such
        as `provider_connect_failed`.

        WebSocket control frames are JSON text messages. The first client
        frame must be `{"type":"start"}` and may include `persona_id`,
        `role_id`, `sequence_id`, `media_transport`, `voice`, `locale`,
        `model`, `activity_detection`, and `system_prompt_override`.
        `media_transport` defaults to `websocket` for backwards compatibility.
        Set `media_transport` to `livekit` to keep the WebSocket as the
        control channel while microphone and model audio move through the
        LiveKit room from this response. v1 LiveKit media is limited to native
        realtime PCM providers (Gemini/OpenAI); cascaded providers remain on
        WebSocket audio until explicit transcoding is added. During a
        workflow, clients send `{"type":"advance_step","reason":"host"}` to
        move to the next sequence step. The server emits `sequence_step`
        frames with `status` values `entered`, `completed`, and
        `sequence_completed`. Tool calls use `tool_call` and `tool_response`.
      tags: [voiceagent]
      responses:
        '201':
          description: Session created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreateSessionResponse' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '409':
          description: Per-identity session limit exceeded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: Global session limit reached.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    get:
      operationId: listVoiceAgentSessions
      summary: List the caller's active sessions
      tags: [voiceagent]
      responses:
        '200':
          description: List returned (possibly empty).
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessions:
                    type: array
                    items: { $ref: '#/components/schemas/ListedSession' }
                  metrics:
                    $ref: '#/components/schemas/VoiceAgentSessionMetrics'
        '401': { $ref: '#/components/responses/Unauthenticated' }

  /v1/voiceagent/sessions/{id}/livekit-token:
    get:
      operationId: refreshVoiceAgentLiveKitToken
      summary: Refresh a LiveKit join token for a Voice Agent session
      tags: [voiceagent]
      parameters:
        - { $ref: '#/components/parameters/SessionID' }
      responses:
        '200':
          description: Fresh short-lived LiveKit join token.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LiveKitJoinInfo' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403':
          description: Caller does not own the session and is not admin.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Session not found or already closed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: LiveKit token minting is disabled or misconfigured.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/voiceagent/sessions/{id}:
    delete:
      operationId: deleteVoiceAgentSession
      summary: Force close a session
      tags: [voiceagent]
      parameters:
        - { $ref: '#/components/parameters/SessionID' }
      responses:
        '204': { description: Closed. }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403':
          description: Caller does not own the session and is not admin.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Session not found or already closed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Personas ────────────────────────────────────────────────────────────
  /v1/personas:
    get:
      operationId: listPersonas
      summary: List personas
      tags: [persona]
      responses:
        '200':
          description: List of personas (TOML seeds + admin overrides merged).
          content:
            application/json:
              schema:
                type: object
                properties:
                  personas:
                    type: array
                    items: { $ref: '#/components/schemas/Persona' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
    post:
      operationId: createPersona
      summary: Create a persona (admin)
      tags: [persona]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Persona' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Persona' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/AdminRequired' }
        '500': { $ref: '#/components/responses/PersistFailed' }

  /v1/personas/{id}:
    parameters:
      - { $ref: '#/components/parameters/PersonaID' }
    get:
      operationId: getPersona
      summary: Get a persona by id
      tags: [persona]
      responses:
        '200':
          description: Persona found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Persona' }
        '404':
          description: Persona not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    patch:
      operationId: updatePersona
      summary: Update a persona (admin)
      tags: [persona]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Persona' }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Persona' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/AdminRequired' }
        '500': { $ref: '#/components/responses/PersistFailed' }
    delete:
      operationId: deletePersona
      summary: Delete a persona (admin)
      tags: [persona]
      responses:
        '204': { description: Deleted. }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/AdminRequired' }
        '404':
          description: Not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/PersistFailed' }

  /v1/roles:
    get:
      operationId: listRoles
      summary: List roles
      tags: [persona]
      responses:
        '200':
          description: Roles list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  roles:
                    type: array
                    items: { $ref: '#/components/schemas/Role' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
    post:
      operationId: createRole
      summary: Create a role (admin)
      tags: [persona]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Role' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Role' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/AdminRequired' }
        '500': { $ref: '#/components/responses/PersistFailed' }

  /v1/roles/{id}:
    parameters:
      - { $ref: '#/components/parameters/RoleID' }
    get:
      operationId: getRole
      summary: Get role
      tags: [persona]
      responses:
        '200':
          description: Role.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Role' }
        '404':
          description: Not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    patch:
      operationId: updateRole
      summary: Update role (admin)
      tags: [persona]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Role' }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Role' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/AdminRequired' }
        '500': { $ref: '#/components/responses/PersistFailed' }
    delete:
      operationId: deleteRole
      summary: Delete role (admin)
      tags: [persona]
      responses:
        '204':
          description: Deleted.
        '403': { $ref: '#/components/responses/AdminRequired' }
        '404':
          description: Not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/PersistFailed' }

  /v1/sequences:
    get:
      operationId: listSequences
      summary: List sequences
      tags: [persona]
      responses:
        '200':
          description: Sequences list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sequences:
                    type: array
                    items: { $ref: '#/components/schemas/Sequence' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
    post:
      operationId: createSequence
      summary: Create sequence (admin)
      tags: [persona]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Sequence' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Sequence' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/AdminRequired' }
        '500': { $ref: '#/components/responses/PersistFailed' }

  /v1/sequences/{id}:
    parameters:
      - { $ref: '#/components/parameters/SequenceID' }
    get:
      operationId: getSequence
      summary: Get sequence
      tags: [persona]
      responses:
        '200':
          description: Sequence.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Sequence' }
        '404':
          description: Not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    patch:
      operationId: updateSequence
      summary: Update sequence (admin)
      tags: [persona]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Sequence' }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Sequence' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/AdminRequired' }
        '500': { $ref: '#/components/responses/PersistFailed' }
    delete:
      operationId: deleteSequence
      summary: Delete sequence (admin)
      tags: [persona]
      responses:
        '204':
          description: Deleted.
        '403': { $ref: '#/components/responses/AdminRequired' }
        '404':
          description: Not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/PersistFailed' }

# ── Components ──────────────────────────────────────────────────────────────
components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      description: |
        Static deployment-wide token from `$SPEECHKIT_SERVER_TOKEN`. The
        identity attached to the request context is `service@default/internal`.
        Suitable for internal service-to-service callers, product backends, or
        automation services on a private network.
    edgeHmac:
      type: apiKey
      in: header
      name: X-Edge-Auth-Hmac
      description: |
        HMAC-SHA256 of `userID + "\n" + orgID + "\n" + plan + "\n" + role` using
        `$EDGE_AUTH_SECRET`. Must be sent alongside `X-Edge-User-Id`,
        `X-Edge-Org-Id`, `X-Edge-Plan`, and optional `X-Edge-Role`. Used
        when fronted by a Cloudflare Worker / reverse proxy that has
        already validated a user JWT.

        Optional replay protection: when the edge also sends `X-Edge-Auth-Ts`
        (Unix seconds), the timestamp is appended to the signed string as a
        fifth field (`... + "\n" + ts`) and the server rejects requests whose
        timestamp is more than 5 minutes from server time. The header is
        optional and backward compatible — an edge that omits it uses the
        legacy four-field signature.

  parameters:
    SessionID:
      name: id
      in: path
      required: true
      schema: { type: string }
    PersonaID:
      name: id
      in: path
      required: true
      schema: { type: string }
    RoleID:
      name: id
      in: path
      required: true
      schema: { type: string }
    SequenceID:
      name: id
      in: path
      required: true
      schema: { type: string }

  responses:
    Readiness:
      description: Component health snapshot.
      content:
        application/json:
          schema:
            type: object
            properties:
              status:
                type: string
                enum: [ok, starting, degraded, unavailable]
              strict_status:
                type: string
                enum: [ok, starting, degraded, unavailable]
              components:
                type: object
                additionalProperties:
                  type: object
                  properties:
                    status:
                      type: string
                      enum: [ok, starting, degraded, unavailable]
                    detail: { type: string }
                    updated_at: { type: string, format: date-time }
                    blocking: { type: boolean }
                    kind: { type: string }
                    modes:
                      type: array
                      items: { type: string }
                    provider: { type: string }
              uptime_seconds: { type: integer }
              version: { type: string }
    BadRequest:
      description: The request body could not be decoded or failed validation.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    MethodNotAllowed:
      description: HTTP method is not allowed on this resource.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthenticated:
      description: Missing or invalid credentials.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    AdminRequired:
      description: Caller's identity does not have role=admin.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    PayloadTooLarge:
      description: Request body exceeds the configured maximum upload size.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    UnsupportedMediaType:
      description: Content-Type is not one this endpoint accepts.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Rate limit exceeded. Honour the `Retry-After` header.
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ProviderUnavailable:
      description: |
        No upstream provider was able to satisfy the request. Typical for
        STT/Genkit/TTS outages, or when the deployment has no provider
        keys configured for the requested mode.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    PersistFailed:
      description: |
        The durable storage layer rejected the write. The in-memory state
        is unchanged so the next read returns the previous value.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Stable machine-readable identifier.
            message:
              type: string
            details:
              type: object
              additionalProperties: true

    EnvStatus:
      type: object
      required: [env, configured]
      properties:
        env:
          type: string
          description: Environment variable name read by this process.
        configured:
          type: boolean
          description: True when the named environment variable has a non-empty value.

    DeploymentStatus:
      type: object
      required: [version, status, uptime_seconds, headless, auth, providers, store, modes]
      properties:
        version: { type: string }
        status:
          type: string
          enum: [ok, starting, degraded, unavailable]
        uptime_seconds: { type: integer }
        components:
          type: object
          additionalProperties:
            type: object
            additionalProperties: true
        headless:
          type: object
          required: [settings_path, settings_write, onboarding_ui]
          properties:
            settings_path: { type: string }
            settings_write:
              type: boolean
              description: Whether settings writes are enabled for this process.
            onboarding_ui:
              type: boolean
              description: Whether the setup/onboarding UI is enabled.
        auth:
          type: object
          required:
            - mode
            - bearer_token_env
            - bearer_token_set
            - edge_auth_secret_env
            - edge_auth_secret_set
          properties:
            mode:
              type: string
              enum: [none, bearer, edge_hmac, bearer_or_edge]
            bearer_token_env: { type: string }
            bearer_token_set:
              type: boolean
              description: True when the active bearer token env has a non-empty value.
            bearer_role: { type: string }
            edge_auth_secret_env: { type: string }
            edge_auth_secret_set:
              type: boolean
              description: True when the active edge-HMAC secret env has a non-empty value.
            admin_username: { type: string }
            admin_password_set:
              type: boolean
              description: True when an admin password hash is configured.
        providers:
          type: object
          properties:
            google:
              type: object
              properties:
                api_key: { $ref: '#/components/schemas/EnvStatus' }
                stt_key: { $ref: '#/components/schemas/EnvStatus' }
            openai:
              type: object
              properties:
                api_key: { $ref: '#/components/schemas/EnvStatus' }
            groq:
              type: object
              properties:
                api_key: { $ref: '#/components/schemas/EnvStatus' }
            huggingface:
              type: object
              properties:
                token: { $ref: '#/components/schemas/EnvStatus' }
            openrouter:
              type: object
              properties:
                api_key: { $ref: '#/components/schemas/EnvStatus' }
        store:
          type: object
          properties:
            backend: { type: string }
        modes:
          type: array
          items: { type: string }

    ProviderProfile:
      type: object
      properties:
        id: { type: string }
        mode: { type: string }
        name: { type: string }
        providerKind: { type: string }
        executionMode: { type: string }
        modelId: { type: string }
        source: { type: string }
        description: { type: string }
        license: { type: string }
        capabilities:
          type: array
          items: { type: string }
        adapterKind: { type: string }
        inferenceAllowed: { type: boolean }
        default: { type: boolean }
        recommended: { type: boolean }

    ServerSettingsSnapshot:
      type: object
      properties:
        version: { type: string }
        status: { type: string }
        onboarding:
          type: object
          properties:
            enabled: { type: boolean }
            complete: { type: boolean }
            required: { type: boolean }
            current_deploy_version: { type: string }
            completed_version: { type: string }
        auth:
          type: object
          properties:
            mode: { type: string }
            bearer_token_env: { type: string }
            bearer_token_set: { type: boolean }
            admin_auth_enabled: { type: boolean }
            admin_username: { type: string }
            admin_password_set: { type: boolean }
        editable:
          type: object
          properties:
            desired: { $ref: '#/components/schemas/ServerSettingsPatch' }

    ServerSettingsPatch:
      type: object
      properties:
        onboarding_complete: { type: boolean }
        server_auth:
          type: object
          properties:
            mode:
              type: string
              enum: [managed_bearer, self_managed]
            bearer_token_env: { type: string }
            generate_token: { type: boolean }
        admin_auth:
          type: object
          properties:
            enabled:
              type: boolean
              description: Enables standalone `/setup` username/password login.
            username: { type: string }
            password:
              type: string
              format: password
              description: Write-only. Stored as a bcrypt hash.
            password_hash:
              type: string
              description: Optional precomputed bcrypt hash for automation.

    ServerSettingsSaveResponse:
      type: object
      properties:
        status:
          type: string
          enum: [saved]
        restart_required: { type: boolean }
        desired: { $ref: '#/components/schemas/ServerSettingsPatch' }
        generated_token:
          type: object
          properties:
            token:
              type: string
              description: One-time server bearer token. Store it in the configured environment.
            env: { type: string }
            auth_mode: { type: string }
            header_name: { type: string }
        experimental: { type: boolean }

    ModeContract:
      type: object
      properties:
        mode: { type: string }
        intelligence: { type: string }
        input: { type: string }
        output: { type: string }
        allowed:
          type: array
          items: { type: string }
        forbidden:
          type: array
          items: { type: string }

    ProviderReadiness:
      type: object
      description: |
        Server-side readiness for a catalog profile. `active` means selected
        by server configuration; `default` is static catalog metadata.
        `ready` is independent of `active` and only reflects whether the
        profile could run if selected.
      properties:
        schemaVersion: { type: string }
        profileId: { type: string }
        mode: { type: string }
        providerKind: { type: string }
        executionMode: { type: string }
        modelId: { type: string }
        source: { type: string }
        active: { type: boolean }
        default: { type: boolean }
        configured: { type: boolean }
        credentialsReady: { type: boolean }
        runtimeReady: { type: boolean }
        capabilityReady: { type: boolean }
        ready: { type: boolean }
        missing:
          type: array
          items:
            type: string
            enum: [mode_disabled, provider_disabled, credentials, runtime]

    ConfigSummary:
      type: object
      properties:
        version: { type: string }
        public_url: { type: string }
        modes:
          type: array
          items: { type: string }
        auth_mode: { type: string }
        bearer_role: { type: string }
        max_upload_mb: { type: integer }
        read_header_timeout_sec: { type: integer }
        read_timeout_sec: { type: integer }
        idle_timeout_sec: { type: integer }
        max_header_bytes: { type: integer }
        max_decoded_audio_seconds: { type: integer }
        features:
          type: object
          additionalProperties: { type: boolean }
        ready_status: { type: string }

    DictionaryEntry:
      type: object
      properties:
        id: { type: integer }
        spoken: { type: string }
        canonical: { type: string }
        language: { type: string }
        source: { type: string }
        enabled: { type: boolean }
        usageCount: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    Transcript:
      type: object
      properties:
        id: { type: integer }
        text: { type: string }
        language: { type: string }
        provider: { type: string }
        model: { type: string }
        durationMs: { type: integer }
        latencyMs: { type: integer }
        audioPath: { type: string }
        audio: { $ref: '#/components/schemas/AudioAsset' }
        createdAt: { type: string, format: date-time }
        ownerUserId: { type: string }
        ownerOrgId: { type: string }
        ownerSource: { type: string }

    AudioAsset:
      type: object
      properties:
        storageKind: { type: string, enum: [local-file] }
        path: { type: string }
        mimeType:
          type: string
          description: MIME type preserved from the source upload when available.
        sizeBytes: { type: integer }
        durationMs: { type: integer }

    VoiceAgentTranscript:
      type: object
      properties:
        id: { type: integer }
        transcript: { type: string }
        turns:
          type: array
          items:
            type: object
            properties:
              role: { type: string }
              text: { type: string }
              createdAt: { type: string, format: date-time }
        language: { type: string }
        created_at: { type: string, format: date-time }

    VoiceAgentSummaryResponse:
      type: object
      properties:
        id: { type: integer }
        summary:
          type: object
          additionalProperties: true
        language: { type: string }
        created_at: { type: string, format: date-time }

    TTSSynthesizeRequest:
      type: object
      required: [text]
      properties:
        text: { type: string }
        locale: { type: string }
        voice: { type: string }
        speed: { type: number }
        format: { type: string, enum: [mp3, wav, opus, pcm] }

    TTSSynthesizeResponse:
      type: object
      properties:
        audio_base64: { type: string }
        format: { type: string }
        sample_rate: { type: integer }
        duration_ms: { type: integer }
        provider: { type: string }
        voice: { type: string }

    Voice:
      type: object
      description: Configured TTS voice entry. This is a server configuration snapshot, not live provider voice discovery.
      properties:
        provider: { type: string }
        id: { type: string }
        locale: { type: string }
        default: { type: boolean }
        discovery: { type: string, enum: [configured] }

    DictateJSONRequest:
      type: object
      required: [audio_base64]
      properties:
        audio_base64: { type: string }
        format:
          type: string
          enum: [wav, mp3, pcm16]
        language: { type: string }
        model: { type: string }
        prompt: { type: string }
        speaker:
          $ref: '#/components/schemas/SpeakerOptions'
        speaker_options:
          $ref: '#/components/schemas/SpeakerOptions'

    DictateResponse:
      type: object
      required: [text, duration_ms, latency_ms]
      properties:
        text: { type: string, minLength: 1 }
        language: { type: string }
        duration_ms: { type: integer }
        latency_ms: { type: integer }
        provider: { type: string }
        model: { type: string }
        confidence:
          type: number
          format: double
        source:
          $ref: '#/components/schemas/SourceMeta'
        speakers:
          $ref: '#/components/schemas/DiarizationResult'

    AssistJSONRequest:
      type: object
      properties:
        text: { type: string }
        audio_base64: { type: string }
        format:
          type: string
          enum: [wav, mp3, pcm16]
        locale: { type: string }
        selection: { type: string }
        context: { type: string }
        tts: { type: boolean }
        tts_format:
          type: string
          enum: [mp3, wav, opus]
        tts_voice: { type: string }
        tts_speed:
          type: number
          format: double
        speaker:
          $ref: '#/components/schemas/SpeakerOptions'
        speaker_options:
          $ref: '#/components/schemas/SpeakerOptions'

    AssistResponse:
      type: object
      required: [text, action, latency_ms]
      properties:
        text: { type: string, minLength: 1 }
        speak_text: { type: string }
        action:
          type: string
          enum: [respond, execute, silent, shortcut]
        locale: { type: string }
        shortcut: { type: string }
        surface: { type: string }
        kind: { type: string }
        transcript: { type: string }
        audio_base64: { type: string }
        audio_format: { type: string }
        latency_ms: { type: integer }
        source:
          $ref: '#/components/schemas/SourceMeta'
        speakers:
          $ref: '#/components/schemas/DiarizationResult'

    SpeakerOptions:
      type: object
      description: Optional speaker diarization/identification request. Omit it for text-only transcription.
      properties:
        enabled: { type: boolean }
        diarization: { type: boolean }
        identification: { type: boolean }
        attribution: { type: boolean }
        providerProfileId: { type: string }
        model: { type: string }
        diarizationModel: { type: string }
        language: { type: string }
        speakersExpected: { type: integer }
        minSpeakersExpected: { type: integer }
        maxSpeakersExpected: { type: integer }
        speakerType:
          type: string
          enum: [name, role]
        knownValues:
          type: array
          items: { type: string }
        knownSpeakers:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              displayName: { type: string }
              role: { type: string }
              description: { type: string }
        preferStreaming:
          type: boolean
          description: >-
            Reserved. Real-time streaming diarization is not implemented yet; all
            providers run batch today, so this flag has no effect.
        allowProviderMapping: { type: boolean }

    DiarizationResult:
      type: object
      properties:
        provider: { type: string }
        model: { type: string }
        level:
          type: string
          description: >-
            Speaker resolution depth actually produced. `biometric` is reserved
            for future voiceprint enrollment and is not produced by any provider
            today; current providers reach at most `provider_identification`.
          enum: [none, diarization, attribution, provider_identification, biometric]
        text: { type: string }
        language: { type: string }
        speakers:
          type: array
          items:
            $ref: '#/components/schemas/Speaker'
        segments:
          type: array
          items:
            $ref: '#/components/schemas/SpeakerSegment'
        words:
          type: array
          items:
            $ref: '#/components/schemas/SpeakerWord'

    SpeakerFrame:
      type: object
      description: Realtime speaker attribution frame emitted by Voice Agent streaming speaker add-ons.
      properties:
        provider: { type: string }
        model: { type: string }
        sequence: { type: integer }
        text: { type: string }
        isFinal: { type: boolean }
        segment:
          $ref: '#/components/schemas/SpeakerSegment'
        words:
          type: array
          items:
            $ref: '#/components/schemas/SpeakerWord'
        speakers:
          type: array
          items:
            $ref: '#/components/schemas/Speaker'
        latencyMs: { type: integer }

    Speaker:
      type: object
      properties:
        label: { type: string }
        personId: { type: string }
        displayName: { type: string }
        role: { type: string }
        confidence: { type: number }
        attributionConfidence: { type: number }

    SpeakerSegment:
      type: object
      properties:
        text: { type: string }
        startMs: { type: integer }
        endMs: { type: integer }
        speakerLabel: { type: string }
        speakerConfidence: { type: number }
        personId: { type: string }
        displayName: { type: string }
        role: { type: string }
        attributionConfidence: { type: number }
        words:
          type: array
          items:
            $ref: '#/components/schemas/SpeakerWord'

    SpeakerWord:
      type: object
      properties:
        text: { type: string }
        startMs: { type: integer }
        endMs: { type: integer }
        confidence: { type: number }
        speakerLabel: { type: string }
        speakerConfidence: { type: number }
        personId: { type: string }
        displayName: { type: string }
        role: { type: string }
        attributionConfidence: { type: number }

    AssistSelfTestResponse:
      type: object
      properties:
        status: { type: string, enum: [ok] }
        text: { type: string }
        action: { type: string }
        locale: { type: string }
        latency_ms: { type: integer }
        details:
          type: object
          additionalProperties: true

    CreateSessionResponse:
      type: object
      required: [session_id, ws_url, ws_subprotocol, ticket, expires_at]
      properties:
        session_id: { type: string }
        ws_url:
          type: string
          format: uri
          description: |
            Browser-reachable WebSocket URL without a ticket query. Open the
            WebSocket with `Sec-WebSocket-Protocol: <ws_subprotocol>`. Browser
            clients do not send bearer tokens in the WebSocket handshake.
            Tickets are HMAC-bound to the session id, single-use, and expire
            after the configured TTL (default 90 s). Expired tickets are not
            refreshed in v1; create a new session. Set SPEECHKIT_PUBLIC_URL
            for browser-facing Docker/proxy deployments so `ws_url` uses the
            public origin.
        ws_subprotocol:
          type: string
          description: WebSocket subprotocol value to pass during upgrade, e.g. `ticket.<value>`.
        ticket:
          type: string
          description: One-time WebSocket upgrade ticket. Prefer `ws_subprotocol` rather than putting this value in URLs.
        expires_at:
          type: string
          format: date-time
          description: Ticket expiry timestamp.
        livekit:
          $ref: '#/components/schemas/LiveKitJoinInfo'

    LiveKitJoinInfo:
      type: object
      properties:
        url:
          type: string
          format: uri
          description: LiveKit WebSocket URL, e.g. `wss://livekit.example.com`.
        room:
          type: string
          description: SpeechKit-managed LiveKit room name.
        token:
          type: string
          description: Short-lived LiveKit JWT scoped to this room and participant.
        token_expires_at:
          type: string
          format: date-time
        participant_identity:
          type: string

    ListedSession:
      type: object
      properties:
        session_id: { type: string }
        state:
          type: string
          enum: [pending_ws, active, closed]
        created_at:
          type: string
          format: date-time
        has_ws_client: { type: boolean }

    VoiceAgentSessionMetrics:
      type: object
      description: Current Voice Agent session occupancy and configured capacity.
      properties:
        total_sessions: { type: integer }
        active_sessions: { type: integer }
        pending_sessions: { type: integer }
        identity_sessions: { type: integer }
        max_global_sessions: { type: integer }
        max_per_identity_sessions: { type: integer }

    SourceMeta:
      type: object
      properties:
        format: { type: string }
        sample_rate: { type: integer }
        channels: { type: integer }
        duration_ms: { type: integer }

    Persona:
      type: object
      required: [id, display_name]
      properties:
        id: { type: string }
        display_name: { type: string }
        description: { type: string }
        voice: { type: string }
        locale: { type: string }
        default_role: { type: string }
        default_sequence:
          type: string
          description: Optional sequence started when the persona is selected without an explicit sequence_id.
        tags:
          type: array
          items: { type: string }
        metadata:
          type: object
          additionalProperties: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        source:
          type: string
          enum: [toml, store]

    Role:
      type: object
      required: [id, display_name, system_prompt]
      properties:
        id: { type: string }
        display_name: { type: string }
        system_prompt: { type: string }
        refinement_prompt: { type: string }
        locale: { type: string }
        vocabulary_hint: { type: string }
        tool_allowlist:
          type: array
          items: { type: string }
        temperature:
          type: number
          format: double
        thinking_enabled: { type: boolean }
        thinking_level:
          type: string
          enum: [off, low, medium, high]
        include_thoughts: { type: boolean }
        thinking_budget: { type: integer }
        automatic_activity_detection: { type: boolean }
        vad_start_sensitivity:
          type: string
          enum: [low, medium, high]
        vad_end_sensitivity:
          type: string
          enum: [low, medium, high]
        vad_prefix_padding_ms: { type: integer }
        vad_silence_duration_ms: { type: integer }
        activity_handling: { type: string }
        turn_coverage: { type: string }
        context_compression_enabled: { type: boolean }
        context_compression_trigger_tokens: { type: integer }
        context_compression_target_tokens: { type: integer }
        enable_affective_dialog: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        source:
          type: string
          enum: [toml, store]

    Sequence:
      type: object
      required: [id, display_name, steps]
      properties:
        id: { type: string }
        display_name: { type: string }
        description: { type: string }
        completion:
          type: string
          enum: [all_steps, explicit_close, max_turns]
        max_turns: { type: integer }
        steps:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/SequenceStep' }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        source:
          type: string
          enum: [toml, store]

    SequenceStep:
      type: object
      required: [id, instruction]
      properties:
        id: { type: string }
        instruction: { type: string }
        exit_criteria: { type: string }
        require_tools:
          type: array
          items: { type: string }
        max_turns: { type: integer }

# ── WebSocket protocol (informational) ───────────────────────────────────
# OpenAPI 3.1 has no first-class WebSocket primitive; the path under
# /v1/voiceagent/sessions/{id}/ws is documented via tags + the
# CreateSessionResponse.ws_url field above. The full protocol is described
# in docs/server/README.md and the wire types in
# internal/server/voiceagent/protocol.go.
