Voice mode
How the chat ↔ voice channel handoff works, what the widget does on each transition, and how to handle edge cases.
A single Buddy Assist conversation lives on one chat_session row for its entire lifetime. Voice is a transient attachment to that session: it occupies the session while active and drains its transcript back into the chat history when it ends, so the thread stays continuous across mode switches.
Channel state machine
┌─────────────────────────────────────┐
│ chat_session │
│ │
─────► current_channel = CHAT ◄──────────┤
│ │ │
│ POST /voice/connect │ transcript drain
│ │ │ (bot end_conversation
│ ▼ │ OR POST /voice/end)
│ current_channel = VOICE │
│ │ │
│ POST /voice/end ─────────────► │
│ │
│ current_channel = ENDED │
│ (when POST /end is called) │
└─────────────────────────────────────┘ CHAT → VOICE and VOICE → CHAT transitions are protected by a per-session Redis lock so concurrent requests can’t race on the same session.
Lifecycle: step by step
1. Chat is active
When the widget is open the SDK is connected to:
POST /widget/session/{id}/message (SSE) Each user message triggers a streaming assistant reply (text tokens + optional generative-UI blocks).
2. User taps the voice button — POST /voice/connect
The widget calls:
POST /agent/voice/breeze-buddy/widget/session/{id}/voice/connect
Authorization: Bearer <widget_token> The server:
- Acquires the session lock.
- Verifies
current_channel == CHAT(rejects with409if already on voice). - Creates (or reuses) a
lead_call_trackerrow to carry the Daily session. - Flips
current_channel → VOICE. - Calls Daily to create a room and mints a participant token.
- Returns
{ room_url, daily_token, lead_id, ttl_seconds }.
The widget then joins the Daily room using the SDK’s WebRTC transport. Chat message input is disabled while current_channel == VOICE.
Voice lead reuse
3. Voice conversation runs
The Daily room TTL is 1 hour (ttl_seconds: 3600). The widget token outlives this (24 h TTL), so you can reconnect voice multiple times within the same session.
Recording is intentionally disabled for widget voice sessions. A future schema change would be needed to record per-attempt audio separately.
4. Voice ends — two paths
Path A — bot disconnects first (normal flow)
When the agent calls end_conversation (e.g. after a farewell node), the bot’s pipeline tears down, drains the voice transcript into chat_message rows, and flips current_channel back to CHAT. The widget detects the Daily session closing and re-enables the chat composer.
Path B — user ends voice from the widget
The widget calls:
POST /agent/voice/breeze-buddy/widget/session/{id}/voice/end
Authorization: Bearer <widget_token> The server signals the bot to wind down via daily_completion_function and returns immediately (status: "end_requested"). The bot then drains the transcript asynchronously. The widget re-enables chat without waiting for the drain to complete.
Idempotent end
{ status: "not_active" } without error.
Call it defensively on page unload without worrying about double-fires.5. Session ends — POST /end
POST /agent/voice/breeze-buddy/widget/session/{id}/end
Authorization: Bearer <widget_token> If voice is still live, the server calls /voice/end internally first, then marks the chat_session as ENDED. The voice_lead_id stays on the session row for audit and analytics.
Page reload — session resume
The widget persists { sessionId, widgetToken, expiresAt } in localStorage keyed by (baseUrl, publicWidgetKey, shopUrl). On the next page load it attempts:
GET /agent/voice/breeze-buddy/widget/session/{id}
Authorization: Bearer <widget_token> If the session is still ACTIVE and the token hasn’t expired, the widget rehydrates the message history and resumes exactly where it left off — including any partially-drained voice transcript.
If the stored token has expired, or the GET returns 404/410, the widget silently creates a fresh session.
Handling voice-live-conflict
If a chat message arrives while current_channel == VOICE, the server returns 409. The SDK maps this to a SessionConflictError, which the widget surfaces as a breeze-buddy-assist:error event with code: "voice_live_conflict".
Listen for it to show a toast:
document.querySelector('breeze-buddy-assist').addEventListener(
'breeze-buddy-assist:error',
(e) => {
if (e.detail.code === 'voice_live_conflict') {
showToast('Please end your voice call before typing.');
}
}
); Rate limits for voice
widget_config.max_voice_sessions_per_ip_hour (default 10) caps how many /voice/connect calls a single IP can make per hour. Exceeding it returns 429. If your widget sits behind a NAT that shares one IP across many users, raise this limit when creating/updating the widget config.