openapi: 3.1.0
info:
  title: Media Server
  version: 0.1.0
  description: |
    The process beside FreeSWITCH on the VoIP host. It terminates the media streams
    `mod_audio_stream` forks over WebSocket, drives call routing over the FreeSWITCH
    Event Socket, and bridges live call audio to Jamb's realtime AI backends.

    Almost none of this is an HTTP API in the ordinary sense. The process listens on
    one port with host networking, and everything on it is private to the host and the
    VPC except one route:

    - `/audio` and `/answering-service` are WebSocket endpoints. Their client is
      `mod_audio_stream` on the same host, and the contract is the sequence of frames
      exchanged after the upgrade, described on each operation and in the
      `AudioStream*` schemas.
    - `/health` and `/metrics` are for the container healthcheck and Prometheus.
    - `POST /v1/calls/notify-ack` is the only route reachable from the internet, via the
      `calls.jamb.ai` nginx vhost, which proxies exactly that path and 404s everything
      else. It is where a phone acknowledges the caller-labeling push sent before a
      relay call is bridged.

    ## Caller labeling, in one paragraph

    A relay call rings the callee from a Jamb number, so their phone shows an unknown
    number. When `ENABLE_CALL_NOTIFY` is on and the callee's ACI is in
    `NOTIFY_ACI_ALLOWLIST`, the media server pushes the real caller's details to their
    devices through the notification server, with a signed callback URL, and waits up to
    `NOTIFY_TIMEOUT_MS` (default 5 s) for one device to POST back. Either outcome ends the
    wait. The call is bridged regardless: a timeout, a down notification server, or a
    forged ack all transfer the call exactly as a successful label does. The apps hold one
    provider's numbers at a time, so no push is sent for a route whose `provider` is not
    its `defaultProvider`; that call transfers at once, unlabeled.

    ## The app leg, in one paragraph

    An inbound relay call whose recipient's ACI is in `APP_LEG_ACI_ALLOWLIST` (empty by
    default, which is nobody; `*` is everyone) is not bridged to a carrier gateway. It is
    bridged to the caller's relay agent, by an INVITE to the SIP handler on
    `127.0.0.1:APP_LEG_SIP_PORT` (default 5090). Such a call does not touch this surface:
    neither `/audio` stream is opened for it (the AI agent in the group call handles AI),
    and no caller-labeling push is sent, so no ack arrives for it.
servers:
  - url: http://127.0.0.1:8080
    description: >-
      On the VoIP host (host networking, PORT=8080). The process binds every interface,
      not just loopback, so the same port answers on the VM's VPC address — that is how
      Prometheus scrapes /metrics. What keeps it off the internet is the GCP firewall,
      which denies ingress by default, not the process. The one route that is published,
      POST /v1/calls/notify-ack, declares its own server.
tags:
  - name: Operations
    description: Healthcheck and metrics. Not published; reachable on the host and over the VPC.
  - name: Media streams
    description: >-
      WebSocket endpoints for `mod_audio_stream`. Documented as GET operations because
      OpenAPI has no WebSocket verb; the real contract is the frame protocol after the
      upgrade.
  - name: Call notification
    description: The signed ack a phone sends after a caller-labeling push.

paths:
  /health:
    get:
      tags: [Operations]
      operationId: getHealth
      security: []
      summary: Liveness probe
      description: Returns 200 while the process is up. The container healthcheck. Not published through nginx.
      responses:
        "200":
          description: The server is up.
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok:
                    type: boolean
                    const: true
              example:
                ok: true

  /metrics:
    get:
      tags: [Operations]
      operationId: getMetrics
      security: []
      summary: Prometheus metrics
      description: |
        prom-client metrics in the Prometheus text exposition format. Not published
        through nginx. Alongside the default Node process metrics, the families this
        service owns are:

        - `jamb_ws_*` — socket lifecycle, buffered bytes, drops and reconnects for both
          the `mod_audio_stream` sockets and the backend sockets.
        - `jamb_call_*` — call attempts, duration, route-not-found, number-released,
          caller-blocked, number-pool-exhausted, route-failed, setup abandoned, hangup
          reports to the activity ledger by outcome.
        - `jamb_call_notify_*` — caller-labeling pushes by outcome, time from push to
          outcome, the ringback the wait actually added, send failures by reason, and
          acks that arrived after the call was bridged.
        - `jamb_answering_*` and `jamb_receptionist_*` — answering-service setup,
          function calls, screening and message-taking.
        - `jamb_transcript_*` and `jamb_transcription_*` — Redis pub/sub delivery and
          transcription latency.
        - `jamb_http_client_request_seconds` — outbound HTTP by client, route and status.
      responses:
        "200":
          description: Metrics in Prometheus text format.
          content:
            text/plain:
              schema:
                type: string
              example: |
                # HELP jamb_call_notify_total Caller-labeling pushes, by what the device came back with
                # TYPE jamb_call_notify_total counter
                jamb_call_notify_total{outcome="knownContact"} 12
                jamb_call_notify_total{outcome="unknownContact"} 3
                jamb_call_notify_total{outcome="noResponse"} 1

  /v1/calls/notify-ack:
    post:
      tags: [Call notification]
      operationId: ackCallNotification
      summary: Acknowledge a caller-labeling push
      description: |
        A device that received an `incomingCall` push POSTs here to say it is ready for
        the call. The push's `callback` field carries this URL **complete with its query
        string**; the client must use it verbatim and never construct one.

        The URL is the whole credential. `sig` is an HMAC-SHA256, base64url-encoded, over
        the call id, the recipient's ACI and `exp`, keyed with a secret only this process
        holds. Nothing is looked up to validate it beyond the pending call the body names:
        there is no session, and a body naming a different call than the URL was signed
        for fails verification rather than resolving the wrong call.

        Both outcomes end the wait and bridge the call. `unknownContact` means the device
        is ready and merely could not name the caller — an answer, not a failure.

        The response says only whether the request was understood. An ack that arrives
        after the call was already bridged, or for a call id this process has no record
        of, is answered 204 exactly like an accepted one: arriving late is ordinary
        timing, not an error the device can act on, and telling the cases apart would let
        anyone probe which calls are in flight. The only rejections are a malformed
        request and a signature that does not verify.
      servers:
        - url: https://calls.jamb.ai
          description: The only server this route is meant to be reached on.
      security:
        - callbackSignature: []
      parameters:
        - name: exp
          in: query
          required: true
          description: >-
            Signature expiry, Unix seconds. Set by the server when it built the URL,
            `NOTIFY_CALLBACK_TTL_MS` (default 5 min) after the push — far longer than the
            wait on purpose. An ack arriving minutes late still verifies, and is recorded
            with how late it was: that a push was delivered at all, however late, is a
            different fact from never delivered. Sending one long after the call is
            harmless and still worth doing.
          schema:
            type: integer
            format: int64
            example: 1788000005
        - name: sig
          in: query
          required: true
          description: >-
            base64url HMAC-SHA256 over `callId`, the recipient's ACI and `exp`. Covered by
            the `callbackSignature` security scheme.
          schema:
            type: string
            example: 9m1TQvY7Xw8bG5jK2pR4sLnE6cHdF0aZ3uVqW8yT1oM
      requestBody:
        required: true
        description: >-
          At most 1 KiB. nginx caps the body at the same size, and the process re-checks it
          for a request that did not come through nginx.
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/NotifyAck"
            examples:
              knownContact:
                summary: The device matched the caller to a contact
                value:
                  callId: 2f8a1c4e-9b3d-4a71-8e2f-0c1d2e3f4a5b
                  outcome: knownContact
              unknownContact:
                summary: The device is ready but could not name the caller
                value:
                  callId: 2f8a1c4e-9b3d-4a71-8e2f-0c1d2e3f4a5b
                  outcome: unknownContact
      responses:
        "204":
          description: >-
            Understood. The wait was ended, or the call had already been bridged, or the
            call id is not one this process is holding — the three are deliberately
            indistinguishable to the client.
        "400":
          description: >-
            The body is not JSON, is not exactly `{ callId, outcome }`, or names an
            `outcome` the server does not recognise. An unrecognised outcome is never
            coerced to a no-response.
        "403":
          description: >-
            `sig` is missing, expired, or does not cover this `callId`, the ACI the push
            was sent to, and `exp`. The wait, if one is open, is left open: an ack that
            cannot be verified must not spend a call's one chance at a real one.
        "405":
          description: Not a POST. Answered by nginx; the process itself answers 404.
        "413":
          description: The body exceeds the 1 KiB cap.
        "429":
          description: >-
            Per-address flood guard at nginx (20 requests/s, burst 40). A rate limit, not
            authentication; the signature is what authenticates.

  /audio:
    get:
      tags: [Media streams]
      operationId: connectBridgeStream
      security: []
      summary: Relay-call media stream (WebSocket)
      description: |
        Opened by `mod_audio_stream` for a relay (bridged) call — once for the caller leg
        and once for the callee leg. The request is a standard WebSocket upgrade; the
        query string is ignored, and a pathname the server does not serve is closed with
        `1008 Invalid path`.

        **Text frames** are JSON. The first one on a socket identifies the leg, and no
        text frame after it is acted on:

        - `AudioStreamInit` — the caller leg. Creates the per-call session that owns the
          realtime AI connection, the recordings and transcript publishing. Binary frames
          that arrive before it are dropped.
        - `AudioStreamPaired` — the callee leg, naming the caller channel it belongs to.
          If no session exists for that peer the server stops the media bug at the
          source and closes with `1008`.

        **Binary frames** are raw L16 PCM: signed 16-bit little-endian, 16 kHz, mono, one
        20 ms frame (640 bytes) at a time. The caller leg drives the mixer clock; each
        caller frame pulls a matching callee frame from a short buffer, silence-padded on
        underrun and oldest-dropped on overflow.

        **Server → client** the only message is `StreamAudio`: AI playback as base64 PCM.
        `mod_audio_stream` writes it to a file and fires a `mod_audio_stream::play` event,
        which the server turns into the `uuid_broadcast` the caller actually hears.

        **Close codes** the server sends: `1001` on shutdown, so the module knows this end
        is closing; `1007` when the first text frame does not parse; `1008` for an unknown
        path or a callee leg with no session; `1011` when the realtime session cannot be
        established.
      parameters:
        - $ref: "#/components/parameters/UpgradeHeader"
        - $ref: "#/components/parameters/ConnectionHeader"
      responses:
        "101":
          description: >-
            Switching Protocols. From here the socket carries the frames described above.
        "400":
          description: Not a WebSocket upgrade.

  /answering-service:
    get:
      tags: [Media streams]
      operationId: connectAnsweringStream
      security: []
      summary: Answering-service media stream (WebSocket)
      description: |
        Opened by `mod_audio_stream` for a call whose dialed number has an answering
        service. Same upgrade and framing as `/audio`, but mono caller audio only, and the
        first text frame is `AudioStreamAnsweringService`. The server creates a
        conversation and a call on the Jamb API, connects the answering-service backend,
        and only then starts forwarding binary frames; audio received before that is
        dropped.

        The backend can call functions back at the server (redirect the call, take a
        voicemail, end the call). A redirect transfers the caller into a bridge, so a call
        that started here can continue as a relay call on `/audio`. Taking a message
        publishes it on the relay agent's Redis channel for the consumer to deliver, and
        then hangs the caller up: that message is the only artifact such a call produces,
        because it never bridges and so has no recording or transcript document.

        **Server → client**: `StreamAudio`, as on `/audio`.

        **Close codes**: `1007` when the first text frame does not parse; `1011` when the
        backend cannot be reached or is lost mid-call, since a caller on a silent line is
        worse than a dropped one; `1001` on shutdown.
      parameters:
        - $ref: "#/components/parameters/UpgradeHeader"
        - $ref: "#/components/parameters/ConnectionHeader"
      responses:
        "101":
          description: Switching Protocols.
        "400":
          description: Not a WebSocket upgrade.

components:
  securitySchemes:
    callbackSignature:
      type: apiKey
      in: query
      name: sig
      description: >-
        Not a key the client holds: the server mints it into the callback URL it pushes,
        and the client presents the URL unchanged. base64url(HMAC-SHA256(secret,
        callId + "\n" + aci + "\n" + exp)). It authenticates the call, not the handset —
        any device holding the URL is indistinguishable from the one it was sent to.

  parameters:
    UpgradeHeader:
      name: Upgrade
      in: header
      required: true
      schema:
        type: string
        const: websocket
    ConnectionHeader:
      name: Connection
      in: header
      required: true
      schema:
        type: string
        const: Upgrade

  schemas:
    NotifyAck:
      type: object
      additionalProperties: false
      required: [callId, outcome]
      properties:
        callId:
          type: string
          minLength: 1
          maxLength: 128
          description: The `callId` from the push. The FreeSWITCH channel uuid of the call.
          example: 2f8a1c4e-9b3d-4a71-8e2f-0c1d2e3f4a5b
        outcome:
          type: string
          enum: [knownContact, unknownContact]
          description: >-
            `knownContact` — the device matched the caller to a contact and labeled the
            call. `unknownContact` — the device is ready for the call but had no match.
            Both end the wait.

    IncomingCallPush:
      type: object
      description: >-
        For reference: the data-only push the server sends through the notification
        server before it waits on this endpoint. Every value is a string because FCM's
        data map is `map<string, string>` on the wire. Not a request or response of this
        service.
      required: [type, callId, callerIdNumber, virtualNumber, callback]
      properties:
        type:
          type: string
          const: incomingCall
        callId:
          type: string
          description: Echoed back in the ack body.
        callerIdNumber:
          type: string
          description: The caller's real number, E.164, for the device to match against its contacts.
          example: "+16175551234"
        callerIdName:
          type: string
          description: As the carrier gave it, when it gave one.
        virtualNumber:
          type: string
          description: >-
            The number that is about to ring the device — the one to label. E.164, as the
            handset will show it; the same string as `callback` in the call's
            `ExternalCallMessage`, not the gateway's dial form.
          example: "+16175559876"
        callback:
          type: string
          format: uri
          description: The complete, signed URL to POST the ack to.
          example: https://calls.jamb.ai/v1/calls/notify-ack?exp=1788000005&sig=9m1TQvY7Xw8bG5jK2pR4sLnE6cHdF0aZ3uVqW8yT1oM

    CallConnectedPush:
      type: object
      description: >-
        For reference: the data-only push the server sends through the notification
        server when the callee of a relay call answers, once to each person on the call
        (`CONNECTED_NOTIFY_ACI_ALLOWLIST`). The device renders it as a visible
        notification. Nothing acks it. Not sent for a self-call, an app-leg call, or a
        call nobody answers. Collapse key `jamb-call-connected:<callId>`. Every value
        is a string, as for `IncomingCallPush`. Not a request or response of this service.
      required: [type, callId, direction, otherPartyNumber]
      properties:
        type:
          type: string
          const: callConnected
        callId:
          type: string
          description: The caller's channel uuid; the same id the call's `incomingCall` carried.
        direction:
          type: string
          enum: [incoming, outgoing]
          description: From the recipient's side.
        otherPartyNumber:
          type: string
          description: The other party's real number, E.164, for the device to match against its contacts.
          example: "+16175551234"
        groupId:
          type: string
          description: The pair's group id (base64url), when the route has one. Absent otherwise.

    AudioStreamInit:
      type: object
      description: First text frame on a caller-leg `/audio` socket.
      required: [type, uuid, init]
      properties:
        type:
          type: string
          const: init
        uuid:
          type: string
          description: The FreeSWITCH channel uuid of the caller leg.
        init:
          $ref: "#/components/schemas/ExternalRoute"

    AudioStreamPaired:
      type: object
      description: First text frame on a callee-leg `/audio` socket.
      required: [type, uuid, paired]
      properties:
        type:
          type: string
          const: paired
        uuid:
          type: string
          description: The FreeSWITCH channel uuid of the callee leg.
        paired:
          type: object
          required: [uuidPeer]
          properties:
            uuidPeer:
              type: string
              description: The caller leg's uuid — the session this feed attaches to.

    AudioStreamAnsweringService:
      type: object
      description: First text frame on an `/answering-service` socket.
      required: [type, uuid, answeringService]
      properties:
        type:
          type: string
          const: answeringService
        uuid:
          type: string
          description: The FreeSWITCH channel uuid of the caller.
        answeringService:
          type: object
          required: [callerIdNumber, callerDestinationNumber, instructionId]
          properties:
            callerIdName:
              type: string
            callerIdNumber:
              type: string
              description: E.164.
            callerDestinationNumber:
              type: string
              description: E.164. The number that has the answering service.
            instructionId:
              type: string
              description: The AI instruction the service runs under.
            ownerId:
              type: string
              description: The service owner's ACI.
            serviceId:
              type: string
              description: >-
                The admin API's service id, absent when it holds no matching row. Labels
                the call's pub/sub so post-call artifacts can be routed.

    ExternalRoute:
      type: object
      description: >-
        How the admin API resolved the dialed number, as the media server stamped it onto
        the channel. Passed through `mod_audio_stream` unchanged.
      required: [kind, aiAgentAci, callerAci, callerPhoneNumber, recipientAci, recipientPhoneNumber, callerIdNumber, jambNumber, provider]
      properties:
        kind:
          type: string
          enum: [inbound, outbound, jamb, agent]
          description: >-
            Which way the call was resolved. `agent` is a caller who dialed their own number:
            it is answered and talks to their own AI agent (`aiAgentAci`), with no second leg.
        aiAgentAci:
          type: string
          description: The relay agent that joins the call.
        groupId:
          type: [string, "null"]
          description: >-
            The Signal group the two parties share, when one exists — the conversation the
            AI runs under. Null on their first contact.
        groupName:
          type: [string, "null"]
          description: >-
            That group's name, passed to the AI server as the conversation's `group_name`.
            An empty string is a group known to have no name (a pair group's included) and
            clears the AI server's. Absent or null is unknown — no group, an unreadable one,
            or an API server that does not send it — and the AI server keeps the name it has.
            Free text: the init frame carries it with JSON `\u` escapes for quotes and
            backslashes, so FreeSWITCH passes it through unchanged.
        callerAci:
          type: string
        callerPhoneNumber:
          type: string
        recipientAci:
          type: string
        recipientPhoneNumber:
          type: string
        numberToDial:
          type: [string, "null"]
          description: Overrides `recipientPhoneNumber` as the bridge destination when set.
        callerIdNumber:
          type: string
          description: The number presented to the callee.
        jambNumber:
          type: string
          description: The Jamb number the caller dialed.
        provider:
          type: [string, "null"]
          enum: [telnyx, carrierx, null]
          description: The outbound gateway. Null means `telnyx`.
        defaultProvider:
          type: [string, "null"]
          description: >-
            The provider whose numbers the apps hold. A route on any other provider sends
            no caller-labeling push. Only compared with `provider`, never dialled, so any
            string is accepted. Absent or null is an API server that does not send it, and
            the push goes out as before.

    StreamAudio:
      type: object
      description: >-
        Server → `mod_audio_stream`. AI playback for the caller. Sent once per AI turn:
        chunks from the backend are coalesced until it has been quiet for 300 ms, because
        every message costs the module a temp file and a `uuid_broadcast`.
      required: [type, data]
      properties:
        type:
          type: string
          const: streamAudio
        data:
          type: object
          required: [audioDataType, sampleRate, audioData]
          properties:
            audioDataType:
              type: string
              const: raw
            sampleRate:
              type: integer
              const: 16000
            audioData:
              type: string
              contentEncoding: base64
              description: L16 PCM, signed 16-bit little-endian, mono.
