Server-owned realtime sessions
Authenticated voice and text relays with server-held provider credentials.
Server-owned realtime sessions
defineRealtimeSession mounts a voice/text relay on the existing authenticated
WebSocket gateway surface. The browser connects to your app, not the model
provider. Both the permanent API key and the SDK-minted session credential stay
on your server.
This is a dedicated *.ws.ts gateway route, not the physical RPC subscription
socket. It inherits gateway origin checks, subject authentication and
credential expiry. Configure the web/reverse proxy to forward this upgrade path
to the API, just like any other gateway route.
Custom SDK models must implement both doCreateClientSecret and getWebSocketConfig. These methods are optional in the SDK because other transports exist. The relay closes unsupported sessions with code 4502 before minting credentials or opening a provider socket.
Declare the server-owned session
// gateways/voice.ws.ts — server-only
import { defineRealtimeSession, resolveRealtimeModel } from '@voltro/ai'
import { Effect } from 'effect'
export default defineRealtimeSession({
path: '/gateways/voice',
model: resolveRealtimeModel({ name: 'gateway', model: 'openai/gpt-realtime' }),
authorize: (subject, model) => Effect.succeed(
subject.type === 'user'
&& subject.scopes?.includes('ai:voice') === true
&& model.modelId === 'openai/gpt-realtime',
),
sessionConfig: {
instructions: 'Answer briefly.',
outputModalities: ['audio', 'text'],
},
limits: {
maxMessageBytes: 256 * 1024,
maxPendingBytes: 1024 * 1024,
maxPendingMessages: 32,
setupTimeoutMs: 15_000,
maxDurationMs: 10 * 60_000,
},
})The SDK reads AI_GATEWAY_API_KEY on the server. Direct OpenAI is also supported
by resolveRealtimeModel({ name: 'openai', model: 'gpt-realtime' }), using
OPENAI_API_KEY. Model availability remains provider-dependent; the example
is not a catalog availability guarantee. The mock and anthropic
ProviderConfig branches refuse realtime resolution. You can supply an installed
SDK RealtimeModelV4 implementation directly as model instead.
authorize is mandatory and runs with the resolved subject and fixed model
identity before minting a credential or opening a provider socket. Authentication
alone does not authorize model use or spending. Supply your own tenant/model/
budget policy. The relay does not reserve an AI budget or persist usage receipts.
Session configuration is server-owned; the browser cannot select a model,
override instructions, set provider options or supply credentials.
Tools are unsupported. Declaring sessionConfig.tools fails at definition
time. Client-supplied function results and provider function-call events are
refused. This primitive does not execute tools or bypass a tool authorization
policy.
Browser consumption
import { RealtimeServerEvent, type RealtimeClientEvent } from '@voltro/ai/events'
import { Either, Schema } from 'effect'
const decode = Schema.decodeUnknownEither(RealtimeServerEvent)
const url = new URL('/gateways/voice', location.href)
url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const socket = new WebSocket(url)
let ready = false
socket.onmessage = ({ data }) => {
const result = decode(JSON.parse(data))
if (Either.isLeft(result)) return
const event = result.right
if (event.type === 'ready') ready = true
if (event.type === 'text-delta') console.log(event.delta)
// Feed audio-delta to a player matching the server's outputAudioFormat.
}
socket.onclose = () => { ready = false }
function send(event: RealtimeClientEvent) {
if (!ready || socket.readyState !== WebSocket.OPEN) return false
socket.send(JSON.stringify(event))
return true
}
// Called by a user action, after ready; never replayed automatically.
function ask(text: string) {
send({ type: 'conversation-item-create', item: { type: 'text-message', role: 'user', text } })
send({ type: 'response-create' })
}
// On unmount: socket.close(). A later connection starts a NEW session.Use your normal same-origin authentication cookie. This example never receives a provider URL, key or ephemeral token. It does not implement a microphone encoder, audio player, bearer-token refresh or a reconnection policy.
The browser must wait for ready. It means the server has serialized and
written the session configuration before accepting input on the ordered
provider connection; it is not a provider acknowledgement. Provider
session-created / session-updated timing varies. Early audio is refused,
not silently dropped or held indefinitely.
Client events are limited to audio append/commit/clear, user text messages,
response create/cancel and conversation-item truncation. Audio is base64 and
must match the server-declared input format. Unknown fields, configuration
overrides and malformed frames close the connection. The browser-safe
RealtimeClientEvent and RealtimeServerEvent schemas/types live at
@voltro/ai/events.
Provider events pass through the installed SDK codec and a second schema validation. Only normalized voice/text events are forwarded. Raw payloads, arbitrary conversation-item objects, custom events and provider error details are not exposed. Provider failures use fixed safe close reasons.
Bounds and lifetime
The values above are the defaults. Byte and message limits cover each relay queue including its in-flight event. The provider socket also enforces a native maximum inbound payload; the app gateway's configurable inbound limit is checked at dispatch time, not a guarantee against native frame allocation. Provider socket output and app-gateway output are bounded. A completed send means a local transport write, not browser receipt.
Setup timeout covers authorization, credential minting, provider connection and initial configuration. Disconnect, credential expiry, provider closure, provider credential expiry or maximum session duration tears down both sides. The relay's Effect scope cancels workers and pending work. The SDK credential mint API has no AbortSignal: cancellation stops waiting and a late result cannot open a socket, but an already-running mint HTTP request may finish.
Close codes used by the relay: 4400 invalid/early input, 4401 session
expiry, 4403 policy refusal, 4408 setup timeout, 4409 relay buffer
overflow, 4501 provider disconnect and 4502 provider/session failure.
Gateway authentication expiry retains its existing 4001 contract; transport
limits may close before the relay sees a frame.
There is no automatic retry, reconnect or replay. Audio and response requests
may incur costs; a reconnect creates a new session and must be an explicit app
decision. A disconnect does not prove the provider cancelled already accepted
generation or billing. Use response-cancel for a live response, then close
the socket when the component/session ends.