How to Build a Browser-Based Voice Assistant With the AssemblyAI Voice Agent API

Wait 5 sec.

Real-time voice apps have a reputation for being painful to build. You’d normally need a speech-to-text service, an LLM, a text-to-speech engine, a WebSocket server to coordinate them, and some way to handle turn-taking so people aren’t talking over each other.AssemblyAI’s Voice Agent API handles all of that behind a single WebSocket endpoint. You stream audio in, you get spoken responses back. In this tutorial, we’ll build a browser-based voice assistant from scratch—a tiny Express server for authentication, and an HTML page that captures your mic, talks to the agent, and plays its responses. The whole thing is roughly 120 lines of code across two files.What we’re buildingA browser page where you click a button, talk to an AI voice assistant, and hear it respond in real time. The assistant can also call tools—we’ll wire up a simple weather lookup to demonstrate. No frameworks, no build step, no React. Just vanilla HTML and JavaScript.PrerequisitesYou need Node.js (v18+) and an AssemblyAI API key. If you don’t have one yet, sign up for free—the API key is on your dashboard.Step 1: The token serverBrowsers can’t set custom headers on WebSocket connections, so you can’t pass your API key directly. Instead, your server mints a short-lived token and the browser uses that to authenticate. This keeps your API key off the client entirely.Create a file called server.js:const express = require("express");&nbsp;const app = express();app.use(express.static("public"));&nbsp;app.get("/token", async (req, res) => {&nbsp; const response = await fetch(&nbsp; &nbsp; "https://agents.assemblyai.com/v1/token?expires_in_seconds=300",&nbsp; &nbsp; { headers: { Authorization: `Bearer ${process.env.ASSEMBLYAI_API_KEY}` } }&nbsp; );&nbsp; if (!response.ok) return res.status(500).send("Token generation failed");&nbsp; const { token } = await response.json();&nbsp; res.json({ token });});&nbsp;app.listen(3000, () => console.log("Running on http://localhost:3000"));That’s the entire backend. One endpoint, 15 lines. Each token is single-use and expires after 5 minutes, so even if someone intercepts one, the blast radius is minimal.Step 2: Capture mic audio in the browserCreate a public/index.html file. We’ll build it up section by section, starting with the audio capture. The Voice Agent API expects PCM16 mono audio at 24kHz, base64-encoded.Voice Assistant&nbsp; Voice Assistant&nbsp; Start Conversation&nbsp; Stop&nbsp; &nbsp;&nbsp; &nbsp; let ws, audioCtx, micStream, processor;&nbsp;&nbsp; document.getElementById("start").onclick = async () => {&nbsp; &nbsp; document.getElementById("start").disabled = true;&nbsp; &nbsp; document.getElementById("stop").disabled = false;&nbsp;&nbsp; &nbsp; // 1. Get a temporary token from our server&nbsp; &nbsp; const { token } = await fetch("/token").then(r => r.json());&nbsp;&nbsp; &nbsp; // 2. Open WebSocket to the Voice Agent API&nbsp; &nbsp; const wsUrl = new URL("wss://agents.assemblyai.com/v1/ws");&nbsp; &nbsp; wsUrl.searchParams.set("token", token);&nbsp; &nbsp; ws = new WebSocket(wsUrl);&nbsp;&nbsp; &nbsp; // 3. Configure the agent on connect&nbsp; &nbsp; ws.onopen = () => {&nbsp; &nbsp; &nbsp; ws.send(JSON.stringify({&nbsp; &nbsp; &nbsp; &nbsp; type: "session.update",&nbsp; &nbsp; &nbsp; &nbsp; session: {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; system_prompt: "You are a helpful voice assistant. " +&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "Keep responses under 2 sentences. " +&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "Use get_weather for weather questions.",&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; greeting: "Hi! Ask me anything, or try asking about the weather.",&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; output: { voice: "ivy" },&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; tools: [{&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; type: "function",&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; name: "get_weather",&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; description: "Get current weather for a city",&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; parameters: {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; type: "object",&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; properties: {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; location: { type: "string", description: "City name" }&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; },&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; required: ["location"]&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }]&nbsp; &nbsp; &nbsp; &nbsp; }&nbsp; &nbsp; &nbsp; }));&nbsp; &nbsp; };A few things to note: the system prompt tells the agent to keep it short (critical for voice UX—nobody wants to listen to a paragraph), and we’ve registered a get_weather tool right in the session config.Step 3: Handle events and stream audio both waysNow we need to handle the incoming events from the API and stream our mic audio out. Add this right after the ws.onopen handler:&nbsp; &nbsp; // 4. Handle incoming events&nbsp; &nbsp; const pendingTools = [];&nbsp;&nbsp; &nbsp; ws.onmessage = async (event) => {&nbsp; &nbsp; &nbsp; const msg = JSON.parse(event.data);&nbsp;&nbsp; &nbsp; &nbsp; switch (msg.type) {&nbsp; &nbsp; &nbsp; &nbsp; case "session.ready":&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; startMic();&nbsp; // Begin streaming audio once ready&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; break;&nbsp;&nbsp; &nbsp; &nbsp; &nbsp; case "reply.audio":&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; playAudio(msg.data);&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; break;&nbsp;&nbsp; &nbsp; &nbsp; &nbsp; case "transcript.user":&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; log("You: " + msg.text);&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; break;&nbsp;&nbsp; &nbsp; &nbsp; &nbsp; case "transcript.agent":&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; log("Agent: " + msg.text);&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; break;&nbsp;&nbsp; &nbsp; &nbsp; &nbsp; case "tool.call":&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; // Simulate a weather lookup&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; const result = msg.name === "get_weather"&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ? { temp: "72°F", conditions: "Sunny" }&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; : { error: "Unknown tool" };&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; pendingTools.push({&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; call_id: msg.call_id, result&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; });&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; break;&nbsp;&nbsp; &nbsp; &nbsp; &nbsp; case "reply.done":&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if (msg.status === "interrupted") {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; pendingTools.length = 0;&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; } else if (pendingTools.length > 0) {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; for (const tool of pendingTools) {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ws.send(JSON.stringify({&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; type: "tool.result",&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; call_id: tool.call_id,&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; result: JSON.stringify(tool.result)&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }));&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; pendingTools.length = 0;&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; break;&nbsp; &nbsp; &nbsp; }&nbsp; &nbsp; };The key pattern with tool calling: accumulate results during tool.call events, but don’t send them back until reply.done fires. The agent speaks a transition phrase while waiting, and sending results too early causes timing issues.Step 4: Mic input and audio playbackFinally, wire up the Web Audio API for both capturing mic input (resampled to 24kHz PCM16) and playing the agent’s audio responses. Note the closing }; on the second-to-last line—it closes the outer start.onclick handler. // 5. Mic capture — resample to 24kHz PCM16&nbsp; &nbsp; async function startMic() {&nbsp; &nbsp; &nbsp; audioCtx = new AudioContext({ sampleRate: 24000 });&nbsp; &nbsp; &nbsp; micStream = await navigator.mediaDevices.getUserMedia({&nbsp; &nbsp; &nbsp; &nbsp; audio: { sampleRate: 24000, channelCount: 1 }&nbsp; &nbsp; &nbsp; });&nbsp; &nbsp; &nbsp; const source = audioCtx.createMediaStreamSource(micStream);&nbsp; &nbsp; &nbsp; processor = audioCtx.createScriptProcessor(4096, 1, 1);&nbsp;&nbsp; &nbsp; &nbsp; processor.onaudioprocess = (e) => {&nbsp; &nbsp; &nbsp; &nbsp; if (ws.readyState !== WebSocket.OPEN) return;&nbsp; &nbsp; &nbsp; &nbsp; const float32 = e.inputBuffer.getChannelData(0);&nbsp; &nbsp; &nbsp; &nbsp; const pcm16 = new Int16Array(float32.length);&nbsp; &nbsp; &nbsp; &nbsp; for (let i = 0; i < float32.length; i++) {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; pcm16[i] = Math.max(-32768,&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Math.min(32767, Math.floor(float32[i] * 32768)));&nbsp; &nbsp; &nbsp; &nbsp; }&nbsp; &nbsp; &nbsp; &nbsp; const b64 = btoa(String.fromCharCode(&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ...new Uint8Array(pcm16.buffer)));&nbsp; &nbsp; &nbsp; &nbsp; ws.send(JSON.stringify({&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; type: "input.audio", audio: b64&nbsp; &nbsp; &nbsp; &nbsp; }));&nbsp; &nbsp; &nbsp; };&nbsp;&nbsp; &nbsp; &nbsp; source.connect(processor);&nbsp; &nbsp; &nbsp; processor.connect(audioCtx.destination);&nbsp; &nbsp; }\\ // 6. Play agent audio&nbsp; &nbsp; function playAudio(base64Data) {&nbsp; &nbsp; &nbsp; const bytes = atob(base64Data);&nbsp; &nbsp; &nbsp; const pcm16 = new Int16Array(bytes.length / 2);&nbsp; &nbsp; &nbsp; for (let i = 0; i < pcm16.length; i++) {&nbsp; &nbsp; &nbsp; &nbsp; pcm16[i] = bytes.charCodeAt(i * 2)&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; | (bytes.charCodeAt(i * 2 + 1) {&nbsp; &nbsp; &nbsp; if (ws) ws.close();&nbsp; &nbsp; &nbsp; if (micStream) micStream.getTracks().forEach(t => t.stop());&nbsp; &nbsp; &nbsp; if (processor) processor.disconnect();&nbsp; &nbsp; &nbsp; document.getElementById("start").disabled = false;&nbsp; &nbsp; &nbsp; document.getElementById("stop").disabled = true;&nbsp; &nbsp; };&nbsp; };&nbsp; Step 5: Run itInstall Express and start the server:npm install expressASSEMBLYAI_API_KEY=your_key_here node server.jsOpen http://localhost:3000, click "Start Conversation," and talk. You’ll hear the agent greet you and respond to your questions. Try asking "What’s the weather in Tokyo?" to see tool calling in action.Where to go from hereThis is a working voice assistant in two files and about 120 lines of meaningful code. No separate STT, LLM, or TTS services to manage. No orchestration layer. Just one WebSocket doing everything.Some next steps worth exploring: swap ivy for a multilingual voice like lucia (Spanish/English) or ren (Japanese/English). Add more tools—maybe one that queries your database or creates a support ticket. Adjust the vad_threshold for noisier environments. Or use session.resume to reconnect dropped sessions without losing context (sessions persist for 30 seconds after disconnection).The full API reference and more examples are in the Voice Agent API docs. If you build something cool with this, I’d love to see it.\