Field notes
Voice AIWebRTCPipecatInfrastructure

Our voice agent worked perfectly. On some phones. On some networks. Some of the time.

What broke when real clients talked to our AI intake agent, why we moved from WebSockets to WebRTC, and the relay server nobody warned us about.

Vaya team6 min read0 comments

At Vaya, a client talks to an AI intake agent before booking their first therapy session, so the therapist walks in with a real brief instead of just a name.

Our first version streamed audio over WebSockets. In testing it was fine. With real users it was a coin toss: one device worked, another didn’t. Home Wi-Fi was fine, mobile data wasn’t. The same person could have a smooth call one day and a broken one the next.

When we dug in, the pattern was clear. The WebSocket connection to our server kept dropping and stalling on real-world networks. A WebSocket runs over TCP, which delivers every byte in order. On a lossy mobile link, one lost packet holds up everything behind it until it’s resent. That’s the right behaviour for a file download and the wrong one for a live voice, where late audio is as bad as missing audio.

Even when the connection held, the design had deeper problems.

  • ~2 seconds of dead air after every answer. Each stage (transcribe → think → speak) waited for the previous one to fully finish. Two seconds sounds small until you’re on the other end of it, wondering if anyone heard you.
  • You couldn’t interrupt. We had to mute the mic while the agent spoke, or it would transcribe its own voice coming back through the speaker. So the client had to wait politely for the agent to finish every sentence.
  • Pauses ended your turn. We ended a turn on silence. Someone stopping to find the right word got cut off.
In a mental health intake, people pause because the question is hard. We were punishing hesitation.

We moved to Pipecat (open source) with WebRTC, the technology video calls run on. Four things changed.

  • Streaming end to end. Transcription, the LLM and the voice all stream, so the stages overlap instead of queueing. The agent starts speaking its first sentence while the rest is still being written.
  • Real interruption. Clients can cut in, and the agent stops and listens.
  • Echo cancellation, jitter buffering and packet-loss recovery, built in. WebRTC was designed for exactly this kind of network. On mobile data, that’s the difference between usable and not. Echo cancellation is also what let us un-mute the mic while the agent talks.
  • A turn-detection model that listens to how a sentence trails off, not just the silence after it. Pauses no longer end your turn.
BEFORE · EACH STAGE WAITSsilence — the client is waitingTranscribeThink (LLM)Speak (TTS)~1.9sAFTER · STREAMING END TO ENDTranscribeThink (LLM)Speak (TTS)…still writing~0.6sfirst sentence is already playing while the rest is generated0s0.5s1s1.5s2sclient stops talkingsynthesiseplaying
Time from the client finishing a sentence to hearing the agent. Stage widths are illustrative; the ~1.9s and ~0.6s end points are what we measured.

Before

~1.9s

Time to first audio

After

~0.6s

A WebSocket only dials out, which every network allows. WebRTC tries to build a direct two-way path between the two peers, and our hosting platform doesn’t accept inbound UDP at all. So no client could ever connect directly.

The fix is a TURN relay: a small server both sides dial out to, which forwards audio between them. Every single call goes through it.

1 · WEBSOCKET — ONLY DIALS OUThosting platformClient browserphone or laptopVoice serverWebSocket endpointTCP · outbound · allowed everywhereconnects — but stalls and drops on lossy mobile links2 · WEBRTC DIRECT — NEEDS A WAY INhosting platformClient browserphone or laptopPipecat serverWebRTC peerUDP · inbound to the serverno inbound UDP allowed — no client can ever connect3 · WEBRTC VIA TURN — BOTH SIDES DIAL OUThosting platformClient browserphone or laptopTURN relayself-hosted · coturnPipecat serverWebRTC peerdials outdials outaudioaudioPer-call credentialminted for one call · expires in 10 minclinical audio never touches a third-party relay
Both the browser and our server can only dial out, so they meet in the middle at a relay we run ourselves.

We could have rented one. We self-host it instead, because it carries clinical audio, and we didn’t want another third party hearing someone describe their mental health.

Trains, tunnels, flaky networks. WebRTC recovers well from packet loss, but not from losing the connection completely. So if the connection drops, the client reconnects to the same interview. The conversation is saved turn by turn, and the agent picks up where it left off instead of asking their name again.

THE CALLturn 1turn 2turn 3turn 4dropstrain · tunnelreconnectturn 5 →same interview,history loadedSaved interviewwritten turn by turn, as the conversation happensThe agent picks up where it left off, and doesn’t ask their name again.
Saving each turn as it happens means a dropped call is an interruption, not a restart.

The old agent ended interviews by outputting the word DONE, which we stripped before speaking. That worked because we had the whole reply in hand before any of it was spoken.

With streaming, words go to the voice as they’re generated. There’s no longer a moment where the full reply exists and can be cleaned up. The agent literally said “done” out loud to clients.

BEFORE · WHOLE REPLY, THEN CLEAN ITLLM“…that’s everything I need.”DONEVoice (TTS)speaks clean textstripped before speakingSTREAMING · EVERY WORD GOES STRAIGHT TO THE VOICELLMthat’severythingI need.DONEVoice (TTS)speaks each token“…I need. Done.”nothing left to strip — it’s already been saidFIX · END WITH A TOOL CALL ON A SEPARATE CHANNELLLM“…that’s everything I need.”Voice (TTS)end_interview()Close the sessionnever reaches the speaker
Streaming removes the gap where a control word could be filtered out. Control signals need their own channel.

Now it ends with a tool call that never reaches the speaker. The general lesson: once output is streamed, anything the model emits as text will be heard. Control signals belong in structured calls, not in the prose.

An LLM writes faster than a voice speaks. When a client interrupts, the agent has often generated a full question the client only heard half of, and the transcript counts it as asked. For an intake whose whole job is to give the therapist a complete brief, that’s a quiet gap.

GENERATED BY THE LLM vs HEARD BY THE CLIENTWhen you think about the last few weeks,what’s felt hardest to manage day to day?client cuts inheardnever heard, but the transcript said “asked”RECOVERY · IN PROGRESSFlag as unheardnot counted as askedAnswer them firstrespond to what they saidReturn naturallyat a turn boundaryRe-ask onceat most one retryAnsweredgoes into the briefAvoided twicebrief: “not discussed”we don’t push
What was generated isn’t what was heard. The recovery flow is in progress.

So we’re adding recovery:

  • Interrupted questions get flagged instead of being counted as asked.
  • The agent handles what the client said first, then returns to the question at a natural moment.
  • It re-asks at most once.
  • If someone avoids a question twice, we don’t push. The therapist just sees it wasn’t discussed.

Voice AI is mostly not about the model. It’s the network, the timing, and the half-second moments where a conversation quietly goes wrong.



Discussion

0 comments
Commenting on

Your name and comment are public; your email is never shown. Please don’t share anything about a client or patient.

No comments yet. Questions, pushback and war stories are all welcome.