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 running handler.py in 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 # optional — spoken to the caller if both dials fail (see "When the dial fails") failureMessage: "We're having trouble connecting you. Please call back shortly." variables: SUPPORT_TIER: gold CRM_API_KEY: "{{ Secrets.organization.CRM_API_KEY }}" # resolved at session start 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 brooke 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.

Relay mode additionally exposes one TTS provider the agent runtime does not: spec.tts.provider: gemini, Google's Generative Language API (default model gemini-2.5-flash-preview-tts, default voice Kore). It is distinct from google_chirp, which is Google Cloud Text-to-Speech Chirp 3 HD. The Gemini arm honors model and voice only — spec.tts.language and spec.tts.speed are silently ignored — and the Gemini API does not stream TTS, so the full utterance is synthesized before any audio arrives. See Text to Speech for the models it accepts.


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); instead it signs the handshake, below.

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.

Verifying connections are from VoiceRun#

The relay signs every WebSocket handshake with VoiceRun's Ed25519 signing key, so your server can prove — before accepting the upgrade — that the connection came from VoiceRun and not from someone who discovered your URL. The upgrade request carries four extra headers:

x-voicerun-timestamp:  1754871080         unix seconds the dial was signed
x-voicerun-session-id: 9b8a...            matches the setup message's sessionId
x-voicerun-key-id:     aApy8wih           identifies which VoiceRun key signed
x-voicerun-signature:  v1=<base64 Ed25519 signature>

The signature covers the string v1:{timestamp}:{host}:{sessionId}, where host is the lowercased hostname of your spec.relay.url — no port, no scheme. Binding the hostname means a handshake captured from one endpoint cannot be replayed against another; the timestamp bounds replay in time.

To verify, reject the upgrade with 401 unless all of the following hold:

  1. The three headers above are present (x-voicerun-key-id is informational — pin the key itself, never trust a key sent in-band).
  2. x-voicerun-timestamp is within 300 seconds of your clock.
  3. The Ed25519 signature verifies against VoiceRun's published public key over v1:{timestamp}:{host}:{sessionId} rebuilt with the hostname you are serving.

With the cryptography package, verification is ~15 lines:

import base64, time from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey VOICERUN_PUBLIC_KEY = Ed25519PublicKey.from_public_bytes( base64.b64decode("isRlhmYt8RCG7qYdQ8jffDXMKyZGCIB0wLrQlyQ3xps=")) # production def is_from_voicerun(headers, host: str) -> bool: ts = headers.get("x-voicerun-timestamp", "") session_id = headers.get("x-voicerun-session-id", "") sig = headers.get("x-voicerun-signature", "") if not (ts.isdigit() and session_id and sig.startswith("v1=")): return False if abs(time.time() - int(ts)) > 300: return False payload = f"v1:{ts}:{host.lower()}:{session_id}".encode() try: VOICERUN_PUBLIC_KEY.verify(base64.b64decode(sig[3:]), payload) return True except Exception: return False

Use your configured public hostname for host — the one in spec.relay.url — rather than blindly trusting the request's Host header if a proxy or tunnel in front of your server rewrites it. After the socket opens, the setup message's sessionId should match the x-voicerun-session-id the signature attested.

This is VoiceRun's current signing public key — it is not secret. The key id is the first 8 characters of the key, matching the x-voicerun-key-id handshake header:

EnvironmentPublic key (base64)Key id
Production (api.voicerun.com)isRlhmYt8RCG7qYdQ8jffDXMKyZGCIB0wLrQlyQ3xps=isRlhmYt

If the key ever rotates, this table will list old and new keys side by side during the transition — select by x-voicerun-key-id. Signature verification is strongly recommended, but connections are accepted either way — verification runs on your side, so you can adopt it without coordinating a change with VoiceRun.

Additional access-control patterns#

Defense in depth on top of signature verification, or a stopgap if you have not adopted it yet:

  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. Reference an organization secret from the Deployment's spec.variables (MY_TOKEN: "{{ Secrets.organization.MY_TOKEN }}"); it arrives resolved in the setup message's variables map. 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 the patterns above rather than replace them.

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. Test a new release with a real call or vr simulate — a typo in the URL will not surface any earlier.

When the dial fails#

If the primary connect fails, the failover URL is tried once. If both fail, the relay speaks a short apology to the caller before hanging up, and the session records a session_failed event (brain_dial_failed) plus an error event that shows in the session's event feed in the dashboard and debugger — the same place handler errors appear for coderunner agents.

The spoken message is authorable per deployment via spec.relay.failureMessage:

failureMessageCaller hears
omitted"Sorry, we're unable to connect your call right now. Please try again later."
any textyour text
"" (empty string)nothing — the session ends silently

The message is synthesized with the deployment's configured TTS provider and voice — TTS runs on the relay, so it works even though your brain never connected.


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": { "...": "pipeline config and call metadata" }, "variables": { "SUPPORT_TIER": "gold", "CRM_API_KEY": "sk-live-..." } }

variables is the Deployment's spec.variables with {{ Secrets.organization.NAME }} placeholders resolved to their secret values, plus every organization secret the release references, keyed by name — the relay-mode equivalent of context.variables in coderunner handlers. On a name collision the secret value wins. The field is always present ({} when the deployment defines none), and secret values travel only over this WebSocket — another reason the relay URL should be wss://.

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")) crm_key = msg.get("variables", {}).get("CRM_API_KEY") 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