# 38 — Voice & Personality (Gemini Live) Status: implemented as the legacy narration/configuration layer in 2.0.0; the full-duplex and provider-neutral replacement is documented in [49-live-voice](49-live-voice.md). Voice identifiers and provider defaults are now discovered or explicitly configured; this document's historical examples must not be treated as a runtime catalogue. ## Problem Every bot/chat already has an inheritance chain for policy, permission mode, and model route (`Workspace → Bot → Chat`, docs/design/34). There was no equivalent for *how a bot sounds* — no per-bot spoken voice, no persona/style override, and no way to hear the difference before committing to it. Desktop had no audio capability at all. ## Design One mechanism serves both consumers (chat-bot replies and desktop narration) rather than two parallel integrations: ### `VoiceConfig` — a new tier alongside `route`/`permission_mode` ```rust pub struct VoiceConfig { pub voice_name: Option, // Gemini prebuilt voice, e.g. "Kore" pub persona: Option, // style directive -> systemInstruction } ``` Lives as `voice: Option` on both `Bot` and `AllowlistEntry` (`crates/vak-server/src/gateway.rs`), resolved by `GatewayState::resolve_voice` exactly the way `effective_route_override` resolves `route`: the chat's own override wins; otherwise, if `inherit_bot_policy` hasn't broken the chain, its bound bot's voice; otherwise no voice (the selected provider supplies its configured default). No new "break inheritance" flag — `inherit_bot_policy` already governs policy and route together, and voice rides the same switch, since all three describe "this chat's relationship to its bot." Wire format matches the established `route`/`permission_mode` idiom: absent/`null` = inherit, an object = override, an explicit `null` on PATCH clears back to inherit (`deserialize_present`, `crates/vak-server/src/admin.rs`, `crates/vak-server/src/gateway.rs`). ### `crates/vak-llm/src/google_live.rs` — Live API session wrapper A from-scratch `BidiGenerateContent` WebSocket client (Gemini's Live API has no SSE/REST form), mirroring `google.rs`'s conventions: pure `build_live_config` builder, `x-goog-api-key`-style auth (query-string `key=`, per the wire protocol), the same status→`LlmError` mapping shape, and `tokio::select!` cancellation racing the caller's `CancellationToken`. Two protections a cancellation token alone doesn't provide, since nothing upstream ever triggers it on a stall: - **`LIVE_SESSION_TIMEOUT` (25s)** wraps the whole call. Without it, a socket that stalls without erroring or closing blocks the request task forever — the concurrent-request version of that is a resource-exhaustion path on an authenticated endpoint, not just a UX gap. - **`MAX_SPEAK_TEXT_CHARS` (2,000)** rejects oversized input before any network call. This is a paid, per-call external API; an unbounded `text` field lets any bearer-holder run up billing with one request. Output is always 24kHz/16-bit/mono PCM, hand-wrapped into a 44-byte WAV container (no extra crate needed for a canonical header) so nothing downstream has to know the wire format. Every call produces a `WorkReceipt` (`WorkPurpose::VoiceSynthesis`, `crates/vak-llm/src/work.rs`) via the same `record`/`classify_error`/ `settle_cancelled` path text dispatches use — voice gets the same auditability as every other provider call, per AGENTS.md's "every provider dispatch produces a receipt." ### `POST /voice/speak` One endpoint, three callers, behind the existing global `require_bearer` middleware (no new auth code): - **Gateway reply path** calls it in-process to attach a voice-note when a chat/bot resolves to a non-null voice. - **Desktop narration** calls it over the same HTTP+bearer connection the webview already uses for everything else (`crates/vak-client-ui/src/ api.ts`) — the desktop app needed no Gemini client of its own. - **Admin console Preview button** calls it with an in-progress, unsaved config so an operator can hear a voice/persona before committing. Resolution order: request's explicit `voice_override` > the named chat's/bot's resolved voice > built-in default (`"Kore"`, no persona). ### Admin console (`crates/vak-admin-ui`) `VoiceConfigEditor` follows the exact `ChannelPermissionPicker`/ `ChannelAccessEditor` idiom already established for route/permission-mode overrides (`.inherit-toggle` checkbox revealing controls via ``, busy-signal + toast + refetch on save, no optimistic updates) — it reads as one more row in the same inheritance panel, not a bolted-on feature. Slotted into both the per-bot editor and the per-chat `ChannelAccessEditor`. ### Desktop (`crates/vak-desktop`) No native audio crate — the webview plays a `Blob` returned from `/voice/speak` through a single shared `