> ## Documentation Index
> Fetch the complete documentation index at: https://docs.rime.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a voice agent (Express)

> Build a voice agent with Rime TTS on an Express server: one route proxies synthesis so your API key stays server-side while the browser records speech and plays back Rime audio.

This guide builds a working voice agent on Express: the browser captures speech, your Express server decides what to say, and Rime speaks the reply.

There is no Rime npm package to install — and you don't need one. Rime is a single HTTPS API; the route below is plain `fetch`, and Express is the only dependency.

```
Browser (mic + playback)  →  POST /api/tts (Express)  →  POST https://users.rime.ai/v1/rime-tts
```

## Prerequisites

* A Rime API key from the [API Tokens page](https://app.rime.ai/tokens), exported as `RIME_API_KEY`
* Node.js 20.11+ and `npm install express`

## 1. Server: Express app with a TTS route

Create `server.mjs`:

```javascript server.mjs theme={null}
import path from "node:path";
import express from "express";

const app = express();
app.use(express.json());

app.post("/api/tts", async (req, res) => {
  const { text, speaker = "astra", modelId = "coda" } = req.body ?? {};
  if (!text) return res.status(400).json({ error: "text is required" });

  const rimeRes = await fetch("https://users.rime.ai/v1/rime-tts", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.RIME_API_KEY}`,
      "Content-Type": "application/json",
      Accept: "audio/mpeg", // MP3; see the API reference for Opus, WAV, PCM, μ-law
    },
    body: JSON.stringify({ text, speaker, modelId }),
  });

  if (!rimeRes.ok) {
    return res
      .status(rimeRes.status)
      .json({ error: `Rime API error ${rimeRes.status}`, detail: await rimeRes.text() });
  }

  res.setHeader("Content-Type", "audio/mpeg");
  res.send(Buffer.from(await rimeRes.arrayBuffer()));
});

app.get("/", (_req, res) => {
  res.sendFile(path.join(import.meta.dirname, "index.html"));
});

app.listen(3000, () => console.log("Rime voice agent on http://localhost:3000"));
```

Verify it works before touching the frontend:

```bash theme={null}
RIME_API_KEY=your_key node server.mjs &
curl -X POST http://localhost:3000/api/tts \
  -H 'Content-Type: application/json' \
  -d '{"text": "Hello from my Express voice agent."}' \
  --output test.mp3
```

`test.mp3` should be playable audio. A 401 means `RIME_API_KEY` isn't visible to the server process.

## 2. Client: mic in, Rime audio out

Create `index.html` next to `server.mjs`. It uses the browser's built-in `SpeechRecognition` for input (Chrome/Edge/Safari), a stub `respond()` function as the agent brain, and your `/api/tts` route for the voice:

```html index.html theme={null}
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Rime voice agent</title>
    <style>
      main { max-width: 480px; margin: 4rem auto; font-family: sans-serif; }
    </style>
  </head>
  <body>
    <main>
      <h1>Rime voice agent</h1>
      <button id="speak">🎤 Speak</button>
      <ul id="messages"></ul>
      <audio id="player" hidden></audio>
    </main>
    <script>
      // Replace this stub with a call to your LLM of choice.
      function respond(userText) {
        return `You said: ${userText}. This voice is Rime's Coda model.`;
      }

      async function speak(text) {
        const res = await fetch("/api/tts", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ text }),
        });
        if (!res.ok) throw new Error(`TTS failed: ${res.status}`);
        const player = document.getElementById("player");
        player.src = URL.createObjectURL(await res.blob());
        await player.play();
      }

      function log(line) {
        const li = document.createElement("li");
        li.textContent = line;
        document.getElementById("messages").appendChild(li);
      }

      document.getElementById("speak").addEventListener("click", () => {
        const SR = window.SpeechRecognition ?? window.webkitSpeechRecognition;
        if (!SR) return alert("SpeechRecognition not supported in this browser.");
        const rec = new SR();
        rec.onresult = async (e) => {
          const userText = e.results[0][0].transcript;
          const reply = respond(userText);
          log(`You: ${userText}`);
          log(`Agent: ${reply}`);
          await speak(reply);
        };
        rec.start();
      });
    </script>
  </body>
</html>
```

Open `http://localhost:3000`, click **Speak**, say something, and the agent answers in Rime's `astra` voice.

## Want streaming?

For incremental synthesis — audio starts playing while later sentences are still generating — attach a WebSocket bridge to the same HTTP server: browser WS ↔ Express server ↔ `wss://users-ws.rime.ai/ws3` with header auth. Rime's WebSocket endpoints authenticate with an `Authorization` header, which browser WebSockets cannot send, so the bridge always lives server-side. The complete bridge implementation (using the `ws` package with `new WebSocketServer({ server })`) is in the [Next.js guide](/docs/voice-agent-nextjs#3-optional-stream-over-websockets) — it works unchanged with Express's underlying HTTP server via `const server = app.listen(3000)`. The `/ws3` message schema is in the [Coda WebSocket reference](/api-reference/coda/websockets-json).

## Next steps

<CardGroup cols={2}>
  <Card title="WebSocket API overview" icon="plug" href="/docs/websockets">
    Endpoints, word-level timestamps, context IDs, and interruption handling.
  </Card>

  <Card title="Voices" icon="waveform-lines" href="/docs/voices">
    Swap `astra` for any Coda voice across six languages.
  </Card>

  <Card title="Streaming formats" icon="bolt" href="/docs/streaming">
    Choose between Opus, MP3, WAV, PCM, and μ-law for your latency budget.
  </Card>

  <Card title="Plain Node (no framework)" icon="terminal" href="/docs/voice-agent-node">
    The same agent with zero dependencies — just node:http and built-in fetch.
  </Card>
</CardGroup>
