Self-hosted LLM protocol

View as Markdown

When Self-Hosted mode is on in its LLM variant (config.type: "llm"), Dial drives every call by opening a WebSocket to a server you host and exchanging the messages documented in this section. Your server is the brain of the call: Dial streams it the live transcript and asks for the agent’s next turn; your server streams back what the agent should say. (For the raw-audio variant, see the Self-hosted audio protocol.)

These pages are the complete contract — you can build a conformant server from them alone. The SDKs ship this protocol as ready-made schemas (zod for Node, pydantic for Python) plus a signature‑verification helper, so you don’t have to hand‑write the message types.

Connection

Dial opens one WebSocket per call to your configured URL with the call id appended to the path:

wss://<your-host>/<your-path>/<call_id>

call_id is Dial’s own call identifier. It is stable for the life of the call, so you can key per‑call state on the path and resume cleanly if the socket reconnects (see Resumability). It is the only call identifier you ever receive — Dial’s underlying voice infrastructure is never exposed.

All frames are JSON text, discriminated by a type field.

Authentication

Every connection carries a signature header so you can confirm it is genuinely from Dial:

X-Dial-Signature: t=<unix_seconds>,v1=<hex_signature>

hex_signature is HMAC-SHA256(secret, "<t>.<call_id>") as a hex digest, where secret is the signing secret Dial generates when you first set your Self-Hosted URL (copy it from the dashboard or GET /api/v1/self-hosted/secret). Verify it on connect and reject stale timestamps (e.g. older than a few minutes) and any mismatch — use a constant‑time comparison. The SDK helpers do this for you: verifyDialSignature(secret, header, callId) (Node) / verify_dial_signature(secret, header, call_id) (Python).

Message types

Every frame is a JSON object with a type field that identifies it. Field names use snake_case (e.g. call_id, response_id, content_complete).

From Dial → your server:

From your server → Dial:

Both ways:

Resumability

The connection path is keyed by the stable call_id. The ping_pong keepalive lets either side detect a dropped link quickly; when that happens Dial reconnects to the same <your-url>/<call_id> and sends a fresh call_connected. So you can restore session state keyed by that id and continue. Dial replays nothing on reconnect, but every response_required carries the full transcript, so the latest one is enough to resume the conversation.

Not in this protocol (today)

To keep the surface small, the current protocol intentionally omits:

  • Tools / function calling — the self‑hosted agent runs without tools. Use your own logic inside your server.
  • Per‑word timing — transcripts carry role + content only, no word‑level timestamps.
  • Call metadata / runtime configuration — beyond call_connected, there is no metadata channel.

Transport reconnection is handled by Dial — on a dropped link it reconnects and re-sends call_connected, so the only keepalive you implement is echoing ping_pong.