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:
- 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. - Shared secret in
setup. Set a secret value in the Deployment'sspec.variables; it arrives in thesetupmessage'scustomParameters. 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.
