Skip to main content
Build a voice agent you can talk to in the browser, where one model listens, decides when you’ve finished, and writes the reply. It takes about ten minutes to run and about 450 lines of Python to read.

Full code on GitHub

A FastAPI server, a browser page with a live transcript and event log, and a terminal probe that streams a sample caller through it.

What you’ll build

  1. A Python server that holds one Dialog-RSN-1 connection per browser tab and forwards the caller’s audio to it.
  2. Streaming speech: Cartesia speaks each reply from its first few words, while Dialog-RSN-1 is still writing it.
  3. Barge-in: talk over the agent and it stops as soon as Dialog-RSN-1 confirms it’s you, and the model learns which words you heard.
  4. A browser page with a microphone picker, voice and turn-taking settings, a live transcript with per-turn latency, and a raw event log.

Why this shape

A typical browser voice agent chains three services on its server:
  1. A speech-to-text service transcribes the caller and sends an end-of-turn event when it thinks they’ve finished.
  2. The server appends the transcript to its own messages list and calls an LLM.
  3. It sends the reply to a TTS service and forwards the audio to the page.
That’s three hops before the caller hears anything, and a conversation history you manage yourself. Dialog-RSN-1 folds the first two steps into one. It takes the caller’s audio directly, decides when the turn is over, and streams the reply as text. It keeps the conversation history in the session. Your server does two things: pass audio in, and pass text to a speech engine.

Prerequisites

  1. Python 3.12 and uv.
  2. A Dialog-RSN-1 API key. Sign up or log in at studio.poly.ai and create one in the API Keys tab. The quickstart walks through it.
  3. A Cartesia API key. The free tier works.
  4. Chrome, Edge or Safari, and headphones if you’ll use the microphone.

Run it

Open http://127.0.0.1:3000. Click Start conversation and talk. Or click Play the sample caller: a recorded caller asks about a trip to Lisbon, then talks over the answer, so you see a barge-in without a microphone. To check the whole pipeline from a terminal, leave the server running and stream the same sample through it. The probe counts from audio_end_ms, where Dialog-RSN-1 marks the end of speech, about half a second after the caller’s last word:

Step 1: Open one socket and configure the session

Each browser tab gets its own Call, which opens a Dialog-RSN-1 WebSocket with the key in a header. The browser never sees the key.
config.DIALOG_REALTIME_URL defaults to wss://api.studio.poly.ai/v1/realtime. Set DIALOG_REALTIME_URL in .env to point somewhere else. Then it sends one session.update:
Three fields do the work:
  • output_modalities: ["text"]. This agent speaks through its own Cartesia stream, so it asks Dialog-RSN-1 for text. Sessions start with ["audio"], where the server speaks the reply itself, so set this or the caller hears two voices.
  • rate: 24000. The page records at 24 kHz, and Cartesia returns 24 kHz, so the page never resamples.
  • create_response: true. When Dialog-RSN-1 confirms the caller has finished, it starts the reply itself. You don’t send response.create.
The page waits for session.updated before it sends any audio, so Dialog-RSN-1 reads every frame at the right rate.

Step 2: Forward the caller’s audio

The page sends the microphone as binary PCM16, 100 ms per frame. The server wraps each frame in input_audio_buffer.append and passes it on:
There’s no buffering, transcript handling or turn logic on the server. Compare a chained pipeline, where the server watches for the end-of-turn event, reads the transcript, appends it to messages and starts an LLM call. Here, the next thing your server hears about is the reply.

Step 3: Stream the reply into speech

Dialog-RSN-1 sends the reply as response.output_text.delta events. Each delta goes straight to Cartesia:
cartesia_tts.py keeps one Cartesia WebSocket open for the whole call. Each response gets its own Cartesia context, named after the response_id. Text goes in with continue: true, so Cartesia keeps the rhythm of one sentence across many small pieces. Two details matter:
  • Send whole words. A delta can end mid-word. push() holds the text after the last space until the word is complete.
  • Lower max_buffer_delay_ms. Cartesia waits up to this long for more text before it speaks. Its default, 3 seconds, favours prosody. The demo sets 250 ms. Dialog-RSN-1 streams text faster than that in our runs, so the limit costs little.
Audio comes back as base64 PCM chunks, and the server sends each one to the page as a binary frame. The page’s player worklet plays them as they arrive.

Step 4: Stop talking when the caller does

When the caller starts speaking, Dialog-RSN-1 sends input_audio_buffer.speech_started. It has already checked that the sound is the caller and not noise, so the server acts on it at once:
Stopping takes two steps, because audio lives in two places:
Cancelling the Cartesia context stops new audio. Flushing the page’s queue stops audio that’s already been synthesized but not yet played. Skip the second step and the agent keeps talking for seconds after you’ve cut in.

Step 5: Tell Dialog-RSN-1 what the caller heard

Only the page knows what reached the speakers. Its player worklet counts the samples it has played and reports every 250 ms. The server turns those counts into Dialog-RSN-1 playback reports:
spoken_text comes from Cartesia’s word timestamps: every word whose audio had started by played_ms. These reports do two things:
  • They make the barge-in check follow the real speaker. Dialog-RSN-1 checks that an interruption comes from the caller for as long as it thinks the reply is audible. Without reports, it guesses that window from a typical speaking rate of 15 characters a second, counted from the reply’s first words. With them, the window lasts exactly as long as your speaker plays, so a cough or an “mm-hm” three seconds into a slow reply still gets the check.
  • They record where the caller cut in. Dialog-RSN-1 cuts the reply in its history back to spoken_text, so the model knows what the caller missed. In the sample, the agent had written its whole reply, but the caller heard only up to “The Alfama neighborhood”. The page strikes through the words the caller never heard.
They’re optional. Leave them out and Dialog-RSN-1 estimates both from the speaking rate: how long each reply plays, and on a barge-in, how much of it the caller heard.

Tune turn-taking in plain language

A chained pipeline tunes the end of the turn with numbers, such as a confidence threshold and a timeout. Dialog-RSN-1 has no timing knobs. The model reads each pause in context and gives a verdict: still going, or finished. The realtime schema’s silence_duration_ms and threshold are accepted and ignored, and idle_timeout_ms is rejected with unsupported_parameter. You can tell it about your callers instead. The page’s Turn-taking menu picks one extra line for the instructions:

What the latency looks like

The page and the probe time every turn from audio_end_ms on speech_stopped, the point in the caller’s audio where Dialog-RSN-1 places the end of speech. Over twenty-two turns against us-1 production, on three days: The audio_end_ms mark sits about 500 ms after the caller’s last word, so the caller waits about half a second longer than the table shows. First audio reaches them about 0.9 to 1.9 s after they stop talking. It’s first text plus Cartesia’s own time to first audio, which ran about 130 to 430 ms.

Troubleshooting

Where to go next

  • The streaming text-to-speech guide covers the same streaming and barge-in pattern with ElevenLabs, in a terminal.
  • The prompt guide covers turn-taking instructions in depth.
  • The API reference lists every event this demo uses, including the poly.output_audio.* playback reports.
Last modified on October 14, 2026