A page that holds a spoken conversation: microphone in, model audio out, both transcripts on screen, interruptible mid-sentence, and able to survive a dropped socket without losing the conversation. Everything here is plain browser JavaScript and one server route. No framework, no SDK — there is no SDK method for /v1/realtime, and @eesi/realtime is an internal package rather than something to install.

Before you start

Create one under Developer → API keys in the console. It never reaches the browser: the page below asks your own server for a short-lived ticket instead. See Authentication.
GET /v1/audio/models and take an id whose kind is realtime. The examples use nur-realtime-v1.
GET /v1/voices gives you ev_… ids for built-in and cloned voices. Omit the field and the model uses its default. Note that The retired omni realtime id ignored a cloned voice rather than refusing it.

1. The ticket route

A browser cannot put an Authorization header on a WebSocket handshake, and a key in a URL is a key in your access logs. Your server authenticates the visitor however it already does, then mints a credential that dies on first use.
Tickets are single-use and live 30 seconds. Mint one per connection, and a fresh one for every reconnect — never cache one.

2. Capture the microphone at the wire rate

The wire is 16 kHz mono PCM16, base64, in small chunks paced in real time. The pacing is not a detail: end-of-turn detection reads the arrival timing of frames, so audio delivered faster than it was spoken produces bad endpointing. Live microphone capture paces itself, which is why this is easy in a browser and fiddly from a file.
Ask for a 16 kHz AudioContext and then check what you got. A context that quietly opened at the device rate ships 48 kHz bytes to a server that pins 16 kHz; it resamples them as though they were its own rate, everything lands three times too fast, and it presents as a bad model. Resample when the rates differ — the four lines below are the whole fix.

3. Play the reply, and be able to stop it instantly

Reply audio is 16 kHz PCM16 in response.output_audio.delta. Schedule each chunk at a running playhead so consecutive chunks stitch sample-accurately, and keep every scheduled node so barge-in can stop all of them at once.

4. The session

One socket, one session.update, then a loop over events.
Do not set audio.input.format. The format field is a discriminated union whose PCM member admits only rate: 24000, so a tagged member at 16000 is rejected and the entire session.update — voice and instructions included — is dropped while the socket stays healthy. An untagged {"rate": 16000} validates as mu-law instead and decodes your PCM as noise. Omit it. The default is the 16 kHz the wire wants.
??= short-circuits. On the second connect() state.stopMicrophone is already a function, so startMicrophone is not called again and the one surviving worklet callback is the one from the first connection. Send through the captured socket and that callback keeps writing to a socket that is now CLOSED: every frame hits the readyState guard, the reconnected session receives zero input_audio_buffer.append frames for the rest of the page’s life, and nothing on either end reports an error. The reader hears a model that never answers again. Reading state.socket costs one word and is the whole fix; restarting capture per connection works too, at the price of a fresh getUserMedia on every redial.

Handling events

Four rules do most of the work, and three of them are places a naive client goes wrong.
This server emits the canonical event names. The legacy OpenAI spellings without output_response.audio.delta, response.audio_transcript.done — never arrive here, but folding both onto the canonical name at your decoder’s edge costs nothing and keeps the client working against either. The corpus that pins these rules is spec/realtime/v1/.

5. Surviving a drop

A deploy, a screen lock, a train tunnel. The gateway checkpoints your session at every turn boundary and hands you a session_id and a resume_token in the eesi.session greeting; reconnect with both and the conversation continues with its context replayed into a fresh pipeline. At most the turn in flight is lost. The close code decides what to do, and two of them mean opposite things:
1008 is a limit or a rejection and redialling it repeats it. 1011 is the backend being down and retrying changes nothing until it is back. Rendering both as “all sessions are busy, try again” is a real bug: it tells users an outage is normal load and sends them into a retry loop that cannot succeed.
A resume that misses — expired, ended, or not yours — is not a refusal. You get a fresh session and a new handle in the greeting, because a client that treated it as fatal would abandon a conversation the person is still trying to have.

6. Start it from a click

The autoplay policy needs a user gesture before any audio plays, and getUserMedia needs one for the permission prompt.
Close the socket when you are done. A live session bills per connected minute whether or not anybody is talking, and it holds a pipeline slot in a finite pool.

What it costs

$0.001 per connected minute, metered from the moment the upstream pipeline comes up. The balance and the free-tier day are re-checked once a minute while the call is up, which is why the eesi.session limit frame exists. See Pricing and limits. Recording is on unless the deployment or the workspace turned it off. The gateway sends an eesi.recording_disclosure event once after session.created carrying the wording to show a person before they speak — showing it is the deployer’s duty, not the platform’s, and your agent should be built to show it. See Compliance.

Next

Realtime events

Every event, and the rules this page implements.

Turn-taking and barge-in

How the server decides you have stopped, and what still goes wrong.

Connections that survive

Tickets, keepalives, close codes and resumption in full.

Duet

Two of these sessions, cross-piped, to test the persona.