Relay Mode (Bring Your Own Agent)

Relay mode lets you run your agent's brain on your own infrastructure while VoiceRun handles telephony, speech-to-text, text-to-speech, turn taking, recording, and observability. Instead of deploying handler.py to the VoiceRun sandbox, your Deployment declares mode: relay and a WebSocket URL you host. When a call starts, VoiceRun's relay dials your server and bridges the live call to it over a simple JSON protocol.

apiVersion: voicerun/v1 kind: Deployment metadata: name: my-agent-deployment spec: mode: relay relay: url: wss://brain.example.com/ws/agent failover: url: wss://brain-standby.example.com/ws/agent stt: model: nova-3 language: en tts: provider: elevenlabs model: eleven_flash_v2_5 voice: 21m00Tcm4TlvDq8ikWAM # provider-native voice ID — see note

No handler.py is required — vr validate skips the handler check for relay-mode manifests. spec.stt, spec.tts, and spec.turnTaking behave exactly as in coderunner mode, with one exception: spec.tts.voice must be a provider-native voice ID (an ElevenLabs voice ID, a Cartesia voice UUID, …). Relay mode forwards the value verbatim to the TTS provider — platform voice names like lyric are not resolved and the provider will reject them. Failover is connect-time only: if the primary URL refuses the initial connection, the failover URL is tried once; established connections are not re-dialed.


Connection model#

Your server is the WebSocket server; the relay is the client. On each new session the relay connects to spec.relay.url and immediately sends a setup message. All messages are JSON with a type discriminator, camelCase fields.

VoiceRun relay                         Your server
     |  ---- WebSocket connect ---->        |
     |  ---- setup ---------------->        |   session metadata
     |  ---- turnStart ------------>        |   caller started speaking
     |  ---- prompt (interim) ----->        |   live STT
     |  ---- prompt (final) ------->        |
     |  ---- turnEnd -------------->        |   caller finished — respond now
     |  <---- text (last: false) ---        |   stream your reply
     |  <---- text (last: true) ----        |   relay synthesizes TTS
     |  ---- interrupt ------------>        |   caller barged in mid-playback
     |  <---- end -------------------        |   hang up
     |  ---- stop ----------------->        |   session tearing down

Audio never touches your server — the relay runs STT/TTS against the telephony leg and only text and control events cross the wire.


Transport, security & local development#

URL scheme and TLS#

spec.relay.url accepts both wss:// and ws:// — the scheme is not validated. Always use wss:// outside of throwaway experiments: the relay dials your server from VoiceRun's cloud, so even a "local development" session crosses the public Internet, and ws:// sends every transcript in plaintext.

For wss:// URLs the relay verifies the certificate against the standard public root store. Your certificate must chain to a publicly trusted CA — self-signed certificates and private CAs are rejected, and there is no option to relax verification. The relay does not present a client certificate (no mTLS).

Reachability and local development#

Your server must be reachable from the public Internet — the relay is the WebSocket client and dials out when a session starts. For local development, expose your dev server with a tunnel such as ngrok or Cloudflare Tunnel; both give you a publicly trusted wss:// URL that forwards to localhost:

ngrok http 8088 # Forwarding: https://<random>.ngrok-free.app -> http://localhost:8088

Then set spec.relay.url: wss://<random>.ngrok-free.app and cut a release. Free ngrok URLs change on every restart — each new URL needs a new release.

Authenticating incoming connections#

The relay does not currently send any authentication during the WebSocket handshake — no token, signature, or custom header beyond the standard WebSocket upgrade headers. Until a dedicated mechanism exists, combine two patterns to keep strangers off your endpoint:

  1. Unguessable URL. Put a long random token in the URL path or query string (wss://brain.example.com/ws/agent?token=...). The relay connects to the URL verbatim, query string included. The full URL — token and all — is stored in the release manifest, so rotate it by cutting a new release.
  2. Shared secret in setup. Set a secret value in the Deployment's spec.variables; it arrives in the setup message's customParameters. Validate it on the first message and close the socket if it is missing or wrong. This check happens after the handshake, so use it to reinforce pattern 1 rather than replace it.

When the URL is checked#

Never before a live session. vr validate and vr release do not parse spec.relay.url or probe it for reachability or TLS — the rendered manifest is snapshotted into the release verbatim, and the first thing that ever touches the URL is the dial itself when a session starts. If the primary connect fails, the failover URL is tried once; if both fail, the session ends with a session_failed event (brain_dial_failed). Test a new release with a real call or vr simulate — a typo in the URL will not surface any earlier.


Messages your server receives#

setup#

Sent immediately after connect.

{ "type": "setup", "sessionId": "9b8a...", "callSid": "CRabc...", "direction": "inbound", "from": "+15555550100", "to": "+15555550101", "sampleRate": 16000, "inputSampleRate": 16000, "outputSampleRate": 16000, "customParameters": { "...": "Deployment variables and call metadata" } }

prompt#

A speech-to-text result. last: false is an interim hypothesis (the text may change); last: true is the final transcript for the segment.

{ "type": "prompt", "voicePrompt": "What are your business hours?", "lang": "en-US", "last": true }

turnStart / turnEnd#

Turn-taking signals, decoupled from transcripts. turnStart fires as soon as voice activity is detected — use it to warm an LLM call or cancel in-flight playback speculatively. turnEnd means the caller finished and your agent should respond; it can arrive before the final prompt.

{ "type": "turnStart" } { "type": "turnEnd", "reason": "provider_eot", "confidence": 0.93 }

reason is one of provider_eot (the STT provider's endpointing), smart_turn (VoiceRun's Smart Turn model), or timeout.

interrupt#

The caller spoke while your reply was playing. The relay has already stopped playback; utteranceUntilInterrupt is the portion of your text that was spoken.

{ "type": "interrupt", "utteranceUntilInterrupt": "...what we can do is...", "durationUntilInterruptMs": 1240 }

dtmf#

A key pressed by the caller (from the telephony provider's DTMF events).

{ "type": "dtmf", "digit": "5" }

error / stop#

error reports protocol or transport problems. stop is the last message before teardown — flush state now; the socket closes shortly after. reason is one of client_end, disconnected, shutdown, timeout.

{ "type": "error", "code": 64107, "message": "invalid message: ..." } { "type": "stop", "reason": "client_end" }

Messages your server sends#

text#

Stream your reply as tokens. The relay accumulates them and synthesizes speech when last: true arrives. Sending a new turn aborts any in-flight synthesis. Optional voice and lang override the deployment's TTS voice/language for this turn.

{ "type": "text", "token": "Sure, ", "last": false } { "type": "text", "token": "let me look that up.", "last": true }

language#

Switch STT and/or TTS language mid-session.

{ "type": "language", "ttsLanguage": "es-MX", "transcriptionLanguage": "es-MX" }

end#

Hang up. handoffData is recorded on the session.

{ "type": "end", "handoffData": { "reason": "transferred" } }

A minimal brain#

Reply to every final transcript — about 30 lines with any WebSocket server library:

import asyncio, json import websockets async def handle(ws): async for raw in ws: msg = json.loads(raw) if msg["type"] == "setup": print("call from", msg.get("from")) elif msg["type"] == "prompt" and msg["last"]: reply = f"You said: {msg['voicePrompt']}" await ws.send(json.dumps( {"type": "text", "token": reply, "last": True})) elif msg["type"] == "stop": return async def main(): async with websockets.serve(handle, "0.0.0.0", 8088): await asyncio.Future() asyncio.run(main())

For lower latency, respond on turnEnd instead of waiting for the final prompt, and start your LLM call on turnStart.

relaywebsocketbyoabring-your-own-agentwire-protocol