> ## Documentation Index > Fetch the complete documentation index at: https://openrouter.ai/docs/llms.txt > Use this file to discover all available pages before exploring further. # Add a Push-to-Talk Voice Button to Your App > Build a hold-to-talk button that records a question, transcribes it with Scribe v2, answers it with a chat model, and speaks the reply with Eleven v4 Turbo Use this guide when you want users to hold a button, ask your app a question out loud, and hear the answer spoken back. ## What you're building The finished button in Chrome. Hold it, ask, release, and the answer plays back in about ten seconds; the wait while it thinks is shortened in this clip. The follow-up says only “it”, and the agent knows it means the sky. > [accordion] Transcript > > **You:** Why is the sky blue? > > **Agent:** The sky looks blue because sunlight is made of many colors, and when it passes through the atmosphere, air molecules scatter the short blue wavelengths much more than the longer red ones. That scattered blue light reaches our eyes from every direction, so the whole sky glows blue. This effect is called Rayleigh scattering. > > **You:** Why does it turn orange at sunset? > > **Agent:** At sunset, sunlight has to travel through much more atmosphere before reaching your eyes, because the sun sits low on the horizon. Along that long path, most of the blue light gets scattered away, leaving the reds and oranges to dominate what you see. The button records the microphone while it is held down. When it is released, the browser posts the recording to one route on your server, which makes three OpenRouter calls in a row: `elevenlabs/scribe-v2` turns the speech into text, a chat model writes a short spoken answer, and `elevenlabs/eleven-v4-turbo` reads it out. The route returns the transcript, the answer, and the MP3, and the browser plays it. The API key stays on the server, and the browser keeps the conversation so follow-up questions work. By the end, you will have: 1. A `/api/talk` route that takes a recording and returns a spoken answer. 2. A hold-to-talk button that records, sends, and plays the reply. 3. A conversation that remembers earlier turns. > [!TIP] > > Building with a coding agent? Paste the URL of this page into Claude Code, Codex, or Cursor and ask it to add this push-to-talk button to your app. ## Before you start You need: * An OpenRouter API key available as `OPENROUTER_API_KEY` * [Bun](https://bun.sh) to run the server * A browser with a microphone This guide uses `elevenlabs/scribe-v2` for transcription, `anthropic/claude-haiku-5.5` for answers, and `elevenlabs/eleven-v4-turbo` for speech. Any chat model in the [catalog](https://openrouter.ai/models) works for the middle step. [Speech-to-Text](https://openrouter.ai/docs/guides/overview/multimodal/stt.md) and [Text-to-Speech](https://openrouter.ai/docs/guides/overview/multimodal/tts.md) list the other audio models and every request parameter. See the [Scribe v2](https://openrouter.ai/elevenlabs/scribe-v2) and [Eleven v4 Turbo](https://openrouter.ai/elevenlabs/eleven-v4-turbo) model pages for pricing. ## Step 1: Add the server route Create `server.ts`. `handleTalk` reads the recording and the conversation so far from a form post, then transcribes, answers, and speaks. The `instructions` ask for short plain sentences, because everything the model writes is read aloud, and for one opening audio tag such as `[warmly]`, which Eleven v4 Turbo performs instead of saying. Browsers record WebM, except Safari, which records MP4 audio, so the route picks the `format` from the file's type. If any of the three calls fails, the route returns the OpenRouter error with a 502 so the browser can show why, and a malformed `history` field starts a fresh conversation instead of failing the turn. ```ts expandable lines title="server.ts" theme={null} // The server half of a push-to-talk button. One route takes a recording, // and returns what was heard, the answer, and the answer as speech. // Run with: bun server.ts import page from "./index.html"; const apiKey = process.env.OPENROUTER_API_KEY; if (!apiKey) { throw new Error("Set OPENROUTER_API_KEY first."); } const API = "https://openrouter.ai/api/v1"; const headers = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }; type Message = { role: "user" | "assistant"; content: string }; const SYSTEM_PROMPT = "You are a friendly voice assistant. Everything you write is read aloud by a " + "text-to-speech model, so answer in two or three short spoken sentences with " + "no markdown, lists, numbers in digits, or emoji. Begin your reply with one " + "audio tag in square brackets that sets the tone, such as [warmly] or [curious]."; async function call(path: string, body: unknown) { const response = await fetch(`${API}${path}`, { method: "POST", headers, body: JSON.stringify(body), }); if (!response.ok) { throw new Error(await response.text()); } return response; } // Speech to text with Scribe v2 async function transcribe(audio: File) { const data = Buffer.from(await audio.arrayBuffer()).toString("base64"); // Chrome and Firefox record WebM; Safari records MP4 audio. const format = audio.type.includes("mp4") ? "m4a" : "webm"; const response = await call("/audio/transcriptions", { model: "elevenlabs/scribe-v2", input_audio: { data, format }, language: "en", }); const result = await response.json(); return result.text as string; } // The answer, written to be spoken async function think(history: Message[]) { const response = await call("/responses", { model: "anthropic/claude-haiku-5.5", instructions: SYSTEM_PROMPT, input: history, max_output_tokens: 300, }); const result = await response.json(); if (result.status !== "completed") { throw new Error( result.error?.message ?? result.incomplete_details?.reason ?? `Response ended as ${result.status}`, ); } const message = [...result.output].reverse().find((item: { type: string }) => item.type === "message"); const text = message.content.find((part: { type: string }) => part.type === "output_text").text as string; return text.trim(); } // Text to speech with Eleven v4 Turbo async function speak(text: string) { const response = await call("/audio/speech", { model: "elevenlabs/eleven-v4-turbo", input: text, voice: "george", response_format: "mp3", }); return Buffer.from(await response.arrayBuffer()).toString("base64"); } export async function handleTalk(request: Request) { const form = await request.formData(); const audio = form.get("audio") as File; let history: Message[] = []; try { history = JSON.parse((form.get("history") as string) ?? "[]") as Message[]; } catch { // A malformed history starts a fresh conversation instead of failing the turn. } try { const question = await transcribe(audio); const answer = await think([...history, { role: "user", content: question }]); const speech = await speak(answer); return Response.json({ question, answer, speech }); } catch (error) { // Pass the OpenRouter error back so the browser can show why the turn failed. return Response.json({ error: (error as Error).message }, { status: 502 }); } } Bun.serve({ port: 3000, routes: { "/": page, "/api/talk": { POST: handleTalk }, }, }); console.log("Open http://localhost:3000"); ``` Start it: ```bash lines theme={null} bun server.ts ``` Then, in a second terminal, post any short voice memo to the route, here `question-1.m4a`, to check the server half on its own: ```bash lines theme={null} curl -s -F "audio=@question-1.m4a;type=audio/mp4" -F 'history=[]' \ http://localhost:3000/api/talk | jq '{question, answer, speech: .speech[:24]}' ``` ```json lines theme={null} { "question": "Why is the sky blue?", "answer": "[warmly] The sky looks blue because sunlight is made of many colors, and when it hits the air, the tiny molecules scatter the short blue wavelengths much more than the longer red ones. That scattered blue light reaches our eyes from every direction overhead, so the whole sky appears blue.", "speech": "SUQzBAAAAAAAI1RTU0UAAAAP" } ``` `speech` is the spoken answer as a base64 MP3. ## Step 2: Add the button The page is one button and a list for the conversation. Put the three files next to `server.ts`; Bun bundles `index.html` and the files it links when the server starts. ```html lines title="index.html" theme={null}