Make your chat speak. Call a speak() function when a reply finishes streaming — no extra dependencies needed for the browser-native path, and a thin backend route for higher-quality cloud voices.
Browser-native TTS
Section titled “Browser-native TTS”The Web Speech API is built into every modern browser. Call speak(answer) right before you set chat.loading = false:
import '@kitn.ai/ui/elements';import { createAssistantStream } from '@kitn.ai/ui/state';import { readOpenAIStream, toOpenAIMessages } from '@kitn.ai/ui/wire';
function speak(text) { if (!('speechSynthesis' in window)) return; const utter = new SpeechSynthesisUtterance(text); utter.lang = 'en-US'; speechSynthesis.cancel(); // stop any previous utterance speechSynthesis.speak(utter);}
const chat = document.getElementById('chat');
chat.addEventListener('kai-submit', async (e) => { const userText = e.detail.value.trim(); if (!userText) return;
const history = [ ...chat.messages, { id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: userText }] }, ]; chat.messages = history; chat.loading = true;
// createAssistantStream appends the in-flight assistant message and folds each // delta onto its parts, so nothing here hand-builds a placeholder. const stream = createAssistantStream((fn) => { chat.messages = fn(chat.messages); }); try { const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: toOpenAIMessages(history) }), }); // readOpenAIStream parses the SSE and resolves with the finished turn, so // turn.text is the whole reply with no accumulator of your own. A non-ok // response throws WireError before a chunk is read, so there is no res.ok // check to forget. const turn = await readOpenAIStream(res, stream); speak(turn.text); // speak the completed reply } finally { stream.done(); chat.loading = false; }});Cloud TTS
Section titled “Cloud TTS”For higher-quality, consistent voices (OpenAI, ElevenLabs, and similar), proxy the TTS call through your backend — never expose provider API keys to the client.
async function speakCloud(text) { const res = await fetch('/api/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, voice: 'alloy' }), }); if (!res.ok) return; const audio = new Audio(URL.createObjectURL(await res.blob())); audio.play();}Replace speak(turn.text) in the streaming handler above with speakCloud(turn.text).
Example /api/tts route (Node.js / Express)
Section titled “Example /api/tts route (Node.js / Express)”// POST /api/tts — proxies to OpenAI TTS, streams back audioimport OpenAI from 'openai';
const openai = new OpenAI(); // reads OPENAI_API_KEY from environment
app.post('/api/tts', async (req, res) => { const { text, voice = 'alloy' } = req.body; const mp3 = await openai.audio.speech.create({ model: 'tts-1', voice, input: text, }); res.setHeader('Content-Type', 'audio/mpeg'); mp3.body.pipe(res);});Stopping playback
Section titled “Stopping playback”For browser-native TTS, call speechSynthesis.cancel() at the start of each new reply (the code above already does this) or when the user sends a new message.
For cloud TTS, keep a reference to the Audio object and call .pause():
let currentAudio = null;
async function speakCloud(text) { if (currentAudio) { currentAudio.pause(); currentAudio = null; } const res = await fetch('/api/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, voice: 'alloy' }), }); if (!res.ok) return; currentAudio = new Audio(URL.createObjectURL(await res.blob())); currentAudio.play();}Related
Section titled “Related”- Getting Started — streaming replies with
kai-chat kai-chatcomponent reference — all props and events, includingloading