openapi: 3.1.0
info:
  title: VoiceThere Control Plane API
  version: 0.1.0
  description: |
    M2 stub — hand-maintained. Full codegen deferred.
servers:
  - url: https://app.voicethere.dev/api/v1
    description: Staging
  - url: http://localhost:3000/api/v1
    description: Local dev
security:
  - bearerAuth: []
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: API key (vth_dev_* or vth_live_*)
  schemas:
    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id, error_id]
          properties:
            code:
              type: string
              example: NWRTC_UNAUTHORIZED
            message:
              type: string
            request_id:
              type: string
              format: uuid
            error_id:
              type: string
              format: uuid
    Project:
      type: object
      properties:
        id: { type: string, format: uuid }
        org_id: { type: string, format: uuid }
        name: { type: string }
        slug: { type: string }
        active_build_id: { type: string, format: uuid, nullable: true }
        created_at: { type: string, format: date-time }
    Build:
      type: object
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        storage_path: { type: string }
        size_bytes: { type: integer }
        checksum_sha256: { type: string }
        validation_status:
          type: string
          enum: [pending, passed, failed]
        created_at: { type: string, format: date-time }
    SessionErrorCode:
      type: string
      enum:
        - AGENT_HANDLER_FAILED
        - AGENT_CHILD_CRASHED
        - RUNNER_INTERNAL
        - SESSION_END_FAILED
        - IDLE_TIMEOUT_CALLBACK_FAILED
        - IDLE_TIMEOUT_CALLBACK_TIMED_OUT
        - SESSION_IDLE_TIMEOUT
        - PROVISIONING_FAILED
        - PROVISIONING_TIMEOUT
        - WEBRTC_CONNECTION_FAILED
        - WEBRTC_CONNECTION_CLOSED
        - WEBRTC_CONNECT_TIMEOUT
    SessionErrorSource:
      type: string
      enum: [agent, runner, provisioning]
    SessionErrorRow:
      type: object
      properties:
        id: { type: string, format: uuid }
        org_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        session_id: { type: string, format: uuid, nullable: true }
        orchestrator_session_id: { type: string }
        source: { $ref: "#/components/schemas/SessionErrorSource" }
        code: { $ref: "#/components/schemas/SessionErrorCode" }
        message: { type: string }
        stack_trace: { type: string, nullable: true }
        context: { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time }
    AgentLogLevel:
      type: string
      enum: [debug, info, warn, error]
    AgentLogRow:
      type: object
      properties:
        id: { type: string, format: uuid }
        org_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        session_id: { type: string, format: uuid, nullable: true }
        orchestrator_session_id: { type: string, nullable: true }
        level: { $ref: "#/components/schemas/AgentLogLevel" }
        message: { type: string }
        fields: { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time }
    RecordSessionErrorRequest:
      type: object
      required: [code, message]
      properties:
        code: { $ref: "#/components/schemas/SessionErrorCode" }
        message: { type: string, maxLength: 4000 }
        source: { $ref: "#/components/schemas/SessionErrorSource" }
        stack_trace: { type: string, maxLength: 32000 }
        context: { type: object, additionalProperties: true }
        session_row_id: { type: string, format: uuid }
    ProjectSessionSettings:
      type: object
      properties:
        error_message: { type: string, maxLength: 500 }
        idle_timeout_enabled: { type: boolean }
        idle_timeout_seconds: { type: integer, minimum: 30, maximum: 120 }
        data_only_idle_timeout_seconds: { type: integer, minimum: 30, maximum: 120 }
        idle_timeout_voice_activity: { type: boolean }
        idle_timeout_dc_inbound: { type: boolean }
    SessionSettingsLimits:
      type: object
      required: [idle_timeout_max_seconds, data_only_idle_timeout_max_seconds]
      properties:
        idle_timeout_max_seconds:
          type: integer
          minimum: 30
          maximum: 86400
          description: Max voice idle timeout (seconds) allowed via API for this org/tier.
        data_only_idle_timeout_max_seconds:
          type: integer
          minimum: 30
          maximum: 86400
          description: Max data-only idle timeout (seconds) allowed via API for this org/tier.
    UpdateSessionSettingRequest:
      type: object
      required: [key, value]
      properties:
        key:
          type: string
          enum:
            - error_message
            - idle_timeout_enabled
            - idle_timeout_seconds
            - data_only_idle_timeout_seconds
            - idle_timeout_voice_activity
            - idle_timeout_dc_inbound
        value:
          oneOf:
            - { type: boolean }
            - { type: number }
            - { type: string }
paths:
  /projects:
    get:
      summary: List projects in org
      responses:
        "200":
          description: Project list
    post:
      summary: Create project
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, slug]
              properties:
                name: { type: string }
                slug: { type: string }
      responses:
        "201":
          description: Created project
  /projects/{projectId}:
    get:
      summary: Project detail with active build
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Project detail
  /projects/{projectId}/builds:
    get:
      summary: List builds (newest first)
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Build list
    post:
      summary: Upload agent bundle
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [bundle]
              properties:
                bundle:
                  type: string
                  format: binary
      responses:
        "201":
          description: Build created
  /projects/{projectId}/promote:
    post:
      summary: Promote build to active
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                build_id: { type: string, format: uuid }
      responses:
        "200":
          description: Promoted
  /projects/{projectId}/rollback:
    post:
      summary: Rollback to previous passed build
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                build_id: { type: string, format: uuid }
      responses:
        "200":
          description: Rolled back
  /projects/{projectId}/metrics:
    get:
      summary: Project usage metrics (sessions, billable seconds, error rate)
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: period
          in: query
          description: Preset window (ignored when start and end are both set)
          schema:
            type: string
            enum: [1h, 24h, 7d, 30d]
            default: 24h
        - name: start
          in: query
          description: Custom range start (ISO 8601). Requires end.
          schema:
            type: string
            format: date-time
        - name: end
          in: query
          description: Custom range end (ISO 8601). Requires start; max 90 days.
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: Metrics snapshot
  /audit-events:
    get:
      summary: Org audit log (actor, IP, action)
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200 }
        - name: cursor
          in: query
          schema: { type: string, format: date-time }
        - name: start
          in: query
          description: Offset for paged list (mutually exclusive with cursor)
          schema: { type: integer, minimum: 0 }
        - name: project_id
          in: query
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Audit events page
  /sessions:
    post:
      summary: Start voice session
      responses:
        "200":
          description: Join credentials
  /projects/{projectId}/session-settings:
    get:
      summary: Get project session settings (idle timeout, crash TTS message)
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Settings and defaults
          content:
            application/json:
              schema:
                type: object
                properties:
                  project_id: { type: string, format: uuid }
                  settings: { $ref: "#/components/schemas/ProjectSessionSettings" }
                  defaults: { $ref: "#/components/schemas/ProjectSessionSettings" }
                  limits: { $ref: "#/components/schemas/SessionSettingsLimits" }
    patch:
      summary: Update one session setting
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateSessionSettingRequest" }
      responses:
        "200":
          description: Updated settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  project_id: { type: string, format: uuid }
                  settings: { $ref: "#/components/schemas/ProjectSessionSettings" }
                  defaults: { $ref: "#/components/schemas/ProjectSessionSettings" }
                  limits: { $ref: "#/components/schemas/SessionSettingsLimits" }
  /projects/{projectId}/voice-advanced-settings:
    get:
      summary: Get advanced voice pipeline settings (VAD, barge-in, STT lifecycle)
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Resolved settings, stored overrides, and defaults
    patch:
      summary: Update one advanced voice setting or reset all overrides
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  required: [key, value]
                  properties:
                    key: { type: string }
                    value:
                      oneOf:
                        - { type: boolean }
                        - { type: number }
                        - { type: string }
                - type: object
                  required: [reset]
                  properties:
                    reset: { type: boolean, enum: [true] }
      responses:
        "200":
          description: Updated settings
  /projects/{projectId}/logs:
    get:
      summary: List searchable agent logs for project
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: q
          in: query
          schema: { type: string }
        - name: level
          in: query
          schema: { $ref: "#/components/schemas/AgentLogLevel" }
        - name: fieldPath
          in: query
          schema: { type: string }
        - name: fieldValue
          in: query
          schema: { type: string }
      responses:
        "200":
          description: Agent log list
          content:
            application/json:
              schema:
                type: object
                properties:
                  project_id: { type: string, format: uuid }
                  logs:
                    type: array
                    items: { $ref: "#/components/schemas/AgentLogRow" }
  /projects/{projectId}/sessions/{sessionId}/logs:
    get:
      summary: List searchable agent logs for one orchestrator session id
      parameters:
        - name: projectId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: sessionId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: q
          in: query
          schema: { type: string }
        - name: level
          in: query
          schema: { $ref: "#/components/schemas/AgentLogLevel" }
        - name: fieldPath
          in: query
          schema: { type: string }
        - name: fieldValue
          in: query
          schema: { type: string }
      responses:
        "200":
          description: Session-scoped agent logs
          content:
            application/json:
              schema:
                type: object
                properties:
                  project_id: { type: string, format: uuid }
                  orchestrator_session_id: { type: string }
                  logs:
                    type: array
                    items: { $ref: "#/components/schemas/AgentLogRow" }
  /cli/device/authorize:
    post:
      summary: Start CLI device authorization (RFC 8628-style)
      security: []
      description: |
        Anonymous initiate. Returns device_code + user_code. Store only hashes server-side.
        Browser verification_uri is /cli/authorize; verification_uri_complete may include
        user_code only (never device_code).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                requested_project_id: { type: string, format: uuid }
                cli_version: { type: string }
                cli_os: { type: string }
                device_label: { type: string }
      responses:
        "200":
          description: Device authorization started
          content:
            application/json:
              schema:
                type: object
                required:
                  [
                    device_code,
                    user_code,
                    verification_uri,
                    verification_uri_complete,
                    expires_in,
                    interval,
                  ]
                properties:
                  device_code: { type: string }
                  user_code: { type: string, example: "AB2C-DE3F" }
                  verification_uri: { type: string, format: uri }
                  verification_uri_complete: { type: string, format: uri }
                  expires_in: { type: integer, example: 600 }
                  interval: { type: integer, example: 5 }
        "429":
          description: Rate limited
  /cli/device/token:
    post:
      summary: Poll/exchange CLI device authorization for a personal API key
      security: []
      description: |
        Returns a 90-day personal vthu_ key once when approved. RFC-like errors:
        authorization_pending, slow_down, access_denied, expired_token, invalid_grant.
        Lost responses require restarting device login (key is not re-issued).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [grant_type, device_code]
              properties:
                grant_type:
                  type: string
                  enum: [urn:ietf:params:oauth:grant-type:device_code]
                device_code: { type: string }
      responses:
        "200":
          description: Personal user API key issued
          content:
            application/json:
              schema:
                type: object
                required: [access_token, token_type, expires_in, active_org_id]
                properties:
                  access_token: { type: string, description: "vthu_ plaintext (once)" }
                  token_type: { type: string, enum: [Bearer] }
                  expires_in: { type: integer, example: 7776000 }
                  active_org_id:
                    { type: string, format: uuid, description: "Org bound at approval" }
        "400":
          description: OAuth-style device grant error
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error:
                    type: string
                    enum:
                      [
                        authorization_pending,
                        slow_down,
                        access_denied,
                        expired_token,
                        invalid_grant,
                      ]
                  error_description: { type: string }
                  interval: { type: integer }
        "429":
          description: Rate limited
  /cli/device/authorization:
    get:
      summary: Look up a CLI device authorization by user_code (dashboard)
      description: Read-only. GET never approves or mutates status.
      parameters:
        - name: user_code
          in: query
          required: true
          schema: { type: string, example: "AB2C-DE3F" }
      responses:
        "200":
          description: Browser view of the authorization
        "404":
          description: Not found
  /cli/device/authorization/approve:
    post:
      summary: Approve a CLI device authorization (dashboard)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_code]
              properties:
                user_code: { type: string }
                org_id:
                  type: string
                  format: uuid
                  description: Optional when no requested_project_id; defaults to active org
      responses:
        "200":
          description: Approved
        "403":
          description: Approver cannot access requested project/org (non-enumerating)
  /cli/device/authorization/deny:
    post:
      summary: Deny a CLI device authorization (dashboard)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_code]
              properties:
                user_code: { type: string }
      responses:
        "200":
          description: Denied
