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:
- The three headers above are present (
x-voicerun-key-idis informational — pin the key itself, never trust a key sent in-band). x-voicerun-timestampis within 300 seconds of your clock.- 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:
| Environment | Public 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:
- 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. Reference an organization secret from the Deployment'sspec.variables(MY_TOKEN: "{{ Secrets.organization.MY_TOKEN }}"); it arrives resolved in thesetupmessage'svariablesmap. 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:
failureMessage | Caller hears |
|---|---|
| omitted | "Sorry, we're unable to connect your call right now. Please try again later." |
| any text | your 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.
