How it works
You open a WebSocket towss://api.opper.ai/v3/realtime, send a session.start frame that picks the model and configures the agent, then stream microphone audio in and play assistant audio out as it arrives. Opper resolves the provider from the model id, handles the upstream handshake (including provider quirks like xAI’s client-secret exchange), meters usage as you go, and gives you one event vocabulary so your client code stays the same no matter which model you target.
Just trying it out?
The Realtime quickstart is the fastest way to a working session. It has one runnable code block per environment (server or browser), and you’ll be live in under five minutes. For a production-shaped example with browser UI, microphone capture, audio playback, tool calls, and provider switching, see the brainstorm-time cookbook.Authentication
There are two patterns, depending on whether your client runs server-side or in a browser.Server-side: bearer token
Server-to-server clients (Node, Python, Go, anything that runs in your backend) connect directly with a project-scoped runtime API key in theAuthorization header. The quickstart uses this pattern.
opmak-…) are rejected. Only project-scoped runtime keys can open realtime sessions.
Browser: ephemeral tickets
Browsers can’t set anAuthorization header on the native WebSocket constructor. To open a realtime session directly from a browser, your backend mints a single-use ticket and the browser redeems it on the upgrade request.
Step 1: server-side mint. Your backend hits POST /v3/realtime-sessions with its normal API key. The response carries a short-lived client_secret and an expires_at timestamp.
Sec-WebSocket-Protocol subprotocol header. Prefer this form. Credentials in the URL query string end up in access logs, browser history, and Referer headers, while the subprotocol header is request-only and stays out of those places.
WebSocket constructor argument):
- Single-use. Replays return
401. - Default TTL 60 seconds, max 5 minutes.
Pre-binding for security
Any field you populate inconfig at mint time is locked for that ticket. The browser can’t override it on session.start, and it can’t change it later with session.update either. This is the main safety guarantee. A leaked ticket can only open the session your backend authorized. It can’t pivot to a more expensive model or a different system prompt, and it can’t unlock the field by updating it after start.
At minimum, bind model. Tighter setups also bind instructions, tools, and voice. Fields left zero stay open for the browser to set in session.start.
To force a boolean off (for example, to stop the browser from enabling output transcription), list the field in locked_fields. Without it, a zero value in config doesn’t lock anything, so the browser could still flip the bool on.
A ticket with locked_fields but no config is also valid: the listed fields are force-set to their zero value regardless of what the browser sends. Unknown field names are rejected at mint time with 400 Bad Request.
Preflight rejections
Before the WebSocket upgrade, Opper checks balance, plan, and concurrency caps. Failures come back as HTTP status codes rather than opaque WS closes, so you can react to them:Session lifecycle
A session goes through four phases. Most of the time you only care about the middle two. 1. Connect. WebSocket upgrade. A successful response is101 Switching Protocols. After this the connection is open, but the agent isn’t running yet.
2. Configure (session.start). Send this once as the first message after upgrade. The config selects the model, voice, system instructions, tool list, VAD parameters, and optional transcription toggles. Opper validates the config against the resolved model’s capabilities and rejects anything unsupported before dialing the upstream. The same validation runs on every later session.update, so unsupported modalities, voice, or reasoning_effort values return an error mid-stream.
3. Interact. Stream audio in (audio.append + audio.commit if not using server VAD), send text turns (text.input), receive audio.delta / text.delta frames, handle tool.call events, return tool.result.
4. Close. Either side can close the WebSocket. On a client-initiated close, Opper performs a final billing flush and tears down the upstream connection. On a server-initiated close (caps, idle timeout, balance exhaustion), you’ll see a closing event (session.terminating) with a structured reason before the connection ends.
Configuring the session
Everything goes inconfig on the first session.start frame:
Event vocabulary
All events are JSON over text WebSocket frames.Client → server
Server → client
Termination reasons on
session.terminating.error.code: session_timeout, idle_timeout, balance_exhausted, project_spend_cap_hit, org_spend_cap_hit, billing_not_supported.
Live transcription (speech-to-text)
The same WebSocket also powers transcription-only models — audio in, streaming text out, no audio response. This is the low-latency, EU-hosted path for live captions or dictation when you don’t need the model to talk back. Pick a transcription realtime model and request text output:audio.append exactly as you would for a voice session. The transcript arrives incrementally on text.delta and is finalized on transcript.committed at each segment boundary:
audio.delta — these models don’t speak — and voice, tools, and the audio-output fields don’t apply. Requesting modalities: ["audio"] returns an error. Diarization isn’t available on the realtime path; for speaker labels use batch transcription with diarize: true.
mistral/voxtral-mini-transcribe-realtime-2602 is EU-hosted (GDPR-resident) and billed per minute of input audio.
Tool calls
Tools work the same way as in the Calls API: declare them inconfig.tools with a JSON Schema for parameters. When the model decides to call one, you get a tool.call event. Reply with tool.result and the model continues the conversation. Opper handles the provider wire format internally, so your handler is the same across upstreams.
Per-provider notes
The protocol is the same across providers, but each upstream has quirks worth knowing.- OpenAI
- xAI
- Gemini
- Mistral (transcription)
Models:
openai/gpt-realtime-2, openai/gpt-realtime, openai/gpt-realtime.Voices: alloy, ash, ballad, coral, echo, sage, shimmer, verse, marin, cedar (latest models).Sample rate: 24 kHz symmetric (in/out).Reasoning effort: gpt-realtime-2 accepts reasoning_effort and bills reasoning tokens. Other models ignore it.Transcription: input transcription runs in a parallel pipeline upstream, so the user transcript can arrive after the assistant has started responding. Enabling input_transcription adds a separate per-minute bill line (input_transcription_ms) at the rate of the transcription model.Transcription is set-at-start. input_transcription and input_transcription_model cannot be toggled via session.update on OpenAI. Mid-session changes return an error. Request transcription at session.start or not at all. xAI and Gemini fold transcription into their per-minute audio rate and don’t have this restriction.Session limits
Billing
Realtime sessions bill incrementally as the conversation runs. Per-model rates are published onGET /v3/models. Look for audio_input, audio_output, audio_input_per_minute, audio_output_per_minute, and input_transcription_per_minute on the relevant model.
Tracing
Each session emits generation records visible in the Trace explorer, filterable bymodel or session_id.
See also
- Realtime protocol reference: event vocabulary quick-ref for the API reference section.
- brainstorm-time cookbook: full working voice app with browser UI, tools, and provider switching.
- Models: the full list of supported realtime model ids.