Build Your First Voice Agent using Vapi

View as Markdown

Overview

This guide demonstrates how to build a voice agent on Vapi powered end-to-end by Sarvam AI: listening with Saaras v3, thinking with Sarvam-30B, and speaking with Bulbul v3. Perfect for bringing natural Indian-language conversations to Vapi’s hosted calling infrastructure (web calls, phone numbers, and SIP).

Vapi doesn’t ship Sarvam as a built-in provider, but it exposes custom provider interfaces for exactly this purpose:

Pipeline stageVapi providerSarvam modelHow it connects
Transcriber (ears)custom-transcribersaaras:v3WebSocket bridge (part of the server below)
LLM (brain)custom-llmsarvam-30bOpenAI-compatible proxy route (part of the server below)
Voice (mouth)custom-voicebulbul:v3HTTP webhook (part of the server below)

All three run through one small Node.js server you’ll copy-paste below: a single process, a single deploy.

Configure via API, not the dashboard. Vapi’s dashboard dropdowns don’t list the custom-transcriber and custom-voice providers; they can only be set through the Vapi API. This guide creates the assistant with a single curl command in Step 6, and tests it with a small web page in Step 7 (the dashboard’s “Talk to Assistant” button has a known bug with custom-transcriber assistants).

What You’ll Build

A Vapi voice agent that can:

  • Listen to users speaking (in multiple Indian languages!)
  • Understand and process their requests
  • Respond back in natural-sounding voices

Quick Overview

  1. Get API keys (Vapi, Sarvam)
  2. Install packages: npm install express ws sarvamai dotenv
  3. Create .env file with your API keys
  4. Copy the bridge server (~150 lines of Node.js)
  5. Run: node server.js + expose with ngrok
  6. Create the assistant with one curl command
  7. Test from a browser test page

Quick Start

1. Prerequisites

  • Node.js 18 or higher
  • API keys from:
    • Sarvam AI (get API key from dashboard)
    • Vapi, for which you need both keys from the API Keys page:
      • Private key: for creating the assistant via API
      • Public key: for starting test calls from the browser
  • ngrok (free account) to expose your local server to Vapi’s cloud

Vapi keys are per-organization. If you have multiple Vapi orgs, copy both keys from the same org; a key from another org is rejected with 401 Invalid Key or 403 Key doesn't allow assistantId.

2. Create the Project

$mkdir vapi-sarvam-bridge
$cd vapi-sarvam-bridge
$npm init -y
$npm install express ws sarvamai dotenv

3. Create Environment File

Create a file named .env in your project folder:

SARVAM_API_KEY=YOUR_SARVAM_API_KEY
PORT=3001
# STT defaults
STT_LANGUAGE=unknown # auto-detect, or en-IN, hi-IN, ta-IN, ...
# TTS defaults
TTS_LANGUAGE=en-IN
TTS_SPEAKER=shubh

Placeholders you must replace are written as YOUR_* throughout this guide. Swap in your Sarvam API key here.

4. Write the Bridge Server

The server has three routes, one per pipeline stage:

  • /llm/chat/completions: forwards chat requests to Sarvam’s OpenAI-compatible API, adding your API key and disabling thinking mode (see the Note below).
  • /api/custom-transcriber: receives Vapi’s live call audio over WebSocket (2-channel PCM: caller + assistant), splits the channels, and streams each to a Saaras v3 session.
  • /api/synthesize: receives Vapi’s voice-request webhooks, synthesizes with Bulbul v3, and returns the raw PCM audio Vapi requires.

Create sarvamTranscriber.js:

1const { SarvamAIClient } = require("sarvamai");
2
3class SarvamTranscriber {
4 constructor({ channel, sampleRate, language, onTranscript }) {
5 this.channel = channel; // "customer" or "assistant"
6 this.sampleRate = sampleRate;
7 this.language = language;
8 this.onTranscript = onTranscript;
9 this.socket = null;
10 }
11
12 async connect() {
13 const client = new SarvamAIClient({
14 apiSubscriptionKey: process.env.SARVAM_API_KEY,
15 });
16
17 // Heads-up on naming: these keys mirror the WebSocket API's query
18 // parameters, so "language-code" is hyphenated (quoted) while the
19 // rest are snake_case. The SDK expects them as-is, so don't "fix" the names.
20 this.socket = await client.speechToTextStreaming.connect({
21 model: "saaras:v3",
22 mode: "transcribe",
23 "language-code": this.language,
24 sample_rate: this.sampleRate,
25 input_audio_codec: "pcm_s16le", // Vapi sends raw 16-bit PCM
26 high_vad_sensitivity: "true", // 0.5s silence boundary for snappier turns
27 });
28
29 this.socket.on("message", (message) => {
30 if (message.type === "data" && message.data.transcript) {
31 this.onTranscript(message.data.transcript, this.channel);
32 }
33 });
34
35 this.socket.on("error", (err) => {
36 console.error(`Sarvam STT error (${this.channel}):`, err);
37 });
38
39 await this.socket.waitForOpen();
40 console.log(`Sarvam STT connected for ${this.channel} channel`);
41 }
42
43 sendAudio(pcmBuffer) {
44 if (!this.socket) return;
45 try {
46 // encoding stays "audio/wav" even for raw PCM, since the codec was
47 // already declared via input_audio_codec at connection time
48 this.socket.transcribe({
49 audio: pcmBuffer.toString("base64"),
50 sample_rate: this.sampleRate,
51 encoding: "audio/wav",
52 });
53 } catch (err) {
54 console.error(`Sarvam STT send failed (${this.channel}):`, err);
55 }
56 }
57
58 close() {
59 this.socket?.close();
60 }
61}
62
63module.exports = SarvamTranscriber;

Create server.js:

1require("dotenv").config();
2const express = require("express");
3const http = require("http");
4const { WebSocketServer } = require("ws");
5const { SarvamAIClient } = require("sarvamai");
6const SarvamTranscriber = require("./sarvamTranscriber");
7
8const app = express();
9app.use(express.json({ limit: "5mb" }));
10
11const sarvam = new SarvamAIClient({
12 apiSubscriptionKey: process.env.SARVAM_API_KEY,
13});
14
15// ---------------------------------------------------------------
16// LLM proxy (custom-llm): forwards to Sarvam with reasoning off
17// ---------------------------------------------------------------
18
19// Sarvam enables thinking mode by default, which delays the first
20// spoken token and can consume the whole max_tokens budget. Vapi's
21// custom-llm config can't pass reasoning_effort, so inject it here.
22app.post("/llm/chat/completions", async (req, res) => {
23 try {
24 const body = { ...req.body, reasoning_effort: null };
25
26 const upstream = await fetch("https://api.sarvam.ai/v1/chat/completions", {
27 method: "POST",
28 headers: {
29 "api-subscription-key": process.env.SARVAM_API_KEY,
30 "Content-Type": "application/json",
31 },
32 body: JSON.stringify(body),
33 });
34
35 res.status(upstream.status);
36 res.setHeader(
37 "Content-Type",
38 upstream.headers.get("content-type") || "application/json"
39 );
40
41 // Stream SSE (or JSON) straight through
42 const { Readable } = require("stream");
43 Readable.fromWeb(upstream.body).pipe(res);
44 } catch (error) {
45 console.error("LLM proxy failed:", error);
46 if (!res.headersSent) res.status(500).json({ error: "LLM proxy failed" });
47 }
48});
49
50// ---------------------------------------------------------------
51// TTS endpoint (custom-voice): text in, raw PCM out
52// ---------------------------------------------------------------
53
54// Sarvam returns base64 WAV; Vapi needs headerless 16-bit mono PCM.
55// Locate the WAV "data" chunk and return everything after it.
56function wavToRawPcm(wavBuffer) {
57 const dataIndex = wavBuffer.indexOf("data");
58 if (dataIndex === -1) throw new Error("Invalid WAV: no data chunk");
59 return wavBuffer.subarray(dataIndex + 8); // skip "data" tag + 4-byte size
60}
61
62app.post("/api/synthesize", async (req, res) => {
63 try {
64 const { message } = req.body;
65 if (message?.type !== "voice-request" || !message.text) {
66 return res.status(400).json({ error: "Expected a voice-request with text" });
67 }
68
69 const response = await sarvam.textToSpeech.convert({
70 text: message.text,
71 model: "bulbul:v3",
72 target_language_code: process.env.TTS_LANGUAGE || "en-IN",
73 speaker: process.env.TTS_SPEAKER || "shubh",
74 speech_sample_rate: message.sampleRate, // must match Vapi's request
75 });
76
77 const wav = Buffer.from(response.audios.join(""), "base64");
78 const pcm = wavToRawPcm(wav);
79
80 res.setHeader("Content-Type", "application/octet-stream");
81 res.setHeader("Content-Length", pcm.length);
82 res.end(pcm);
83 } catch (error) {
84 console.error("TTS synthesis failed:", error);
85 if (!res.headersSent) {
86 res.status(500).json({ error: "TTS synthesis failed" });
87 }
88 }
89});
90
91// ---------------------------------------------------------------
92// STT endpoint (custom-transcriber): WebSocket audio in, JSON out
93// ---------------------------------------------------------------
94
95// Vapi streams interleaved stereo PCM: channel 0 = customer, 1 = assistant
96function splitStereo(buffer) {
97 const frames = Math.floor(buffer.length / 4); // 2 bytes x 2 channels
98 const customer = Buffer.alloc(frames * 2);
99 const assistant = Buffer.alloc(frames * 2);
100 for (let i = 0; i < frames; i++) {
101 buffer.copy(customer, i * 2, i * 4, i * 4 + 2);
102 buffer.copy(assistant, i * 2, i * 4 + 2, i * 4 + 4);
103 }
104 return { customer, assistant };
105}
106
107const server = http.createServer(app);
108const wss = new WebSocketServer({ server, path: "/api/custom-transcriber" });
109
110wss.on("connection", (ws) => {
111 console.log("Vapi connected to custom transcriber");
112 const transcribers = {};
113
114 const sendTranscript = (transcription, channel) => {
115 if (ws.readyState !== ws.OPEN) return;
116 console.log(`Transcript (${channel}): ${transcription}`);
117 ws.send(
118 JSON.stringify({
119 type: "transcriber-response",
120 transcription,
121 channel,
122 transcriptType: "final",
123 })
124 );
125 };
126
127 ws.on("message", async (data, isBinary) => {
128 if (!isBinary) {
129 let msg;
130 try {
131 msg = JSON.parse(data.toString());
132 } catch (err) {
133 console.error("Ignoring non-JSON control message from Vapi:", err);
134 return;
135 }
136 if (msg.type === "start") {
137 // One Sarvam session per audio channel
138 const language = process.env.STT_LANGUAGE || "unknown";
139 try {
140 for (const channel of ["customer", "assistant"]) {
141 transcribers[channel] = new SarvamTranscriber({
142 channel,
143 sampleRate: msg.sampleRate,
144 language,
145 onTranscript: sendTranscript,
146 });
147 await transcribers[channel].connect();
148 }
149 } catch (err) {
150 // Most common cause: invalid SARVAM_API_KEY. Close the socket
151 // so Vapi's fallback plan can take over instead of dead air.
152 console.error("Failed to start Sarvam STT sessions:", err);
153 ws.close(1011, "Transcriber startup failed");
154 }
155 }
156 return;
157 }
158
159 if (!transcribers.customer) return;
160 const { customer, assistant } = splitStereo(data);
161 transcribers.customer.sendAudio(customer);
162 transcribers.assistant.sendAudio(assistant);
163 });
164
165 ws.on("error", (err) => {
166 console.error("Transcriber WebSocket error:", err);
167 });
168
169 ws.on("close", () => {
170 console.log("Vapi disconnected");
171 Object.values(transcribers).forEach((t) => t.close());
172 });
173});
174
175const PORT = process.env.PORT || 3001;
176server.listen(PORT, () => {
177 console.log(`Sarvam bridge listening on port ${PORT}`);
178});

Why the LLM proxy instead of pointing Vapi straight at https://api.sarvam.ai/v1? The direct connection works (Sarvam’s Chat Completions API is OpenAI-compatible and accepts the Bearer token Vapi sends), but Sarvam models have thinking mode on by default, so reasoning tokens stream before the reply, delaying the agent’s speech; with a small max_tokens the entire budget can be consumed by reasoning, leaving the reply empty. The proxy injects reasoning_effort: null on every request, which Vapi has no other way to pass. It also authenticates server-side, so you don’t need to configure a Custom LLM credential in Vapi at all.

5. Run and Expose the Server

$node server.js

In a second terminal, expose it with ngrok:

$ngrok http 3001

Note the public URL (e.g. https://your-id.ngrok-free.app): you’ll use it in the next step.

6. Create Your Assistant

Replace YOUR_VAPI_PRIVATE_KEY and the three your-id.ngrok-free.app URLs (model, transcriber, and voice) with your ngrok hostname, then run:

$curl -X POST https://api.vapi.ai/assistant \
> -H "Authorization: Bearer YOUR_VAPI_PRIVATE_KEY" \
> -H "Content-Type: application/json" \
> -d '{
> "name": "Sarvam Voice Agent",
> "firstMessage": "Hello! How can I help you today?",
> "model": {
> "provider": "custom-llm",
> "url": "https://your-id.ngrok-free.app/llm",
> "model": "sarvam-30b",
> "messages": [
> {
> "role": "system",
> "content": "You are a helpful voice assistant. Be friendly, concise, and conversational. Keep replies short - they will be spoken aloud."
> }
> ]
> },
> "transcriber": {
> "provider": "custom-transcriber",
> "server": {
> "url": "wss://your-id.ngrok-free.app/api/custom-transcriber"
> }
> },
> "voice": {
> "provider": "custom-voice",
> "server": {
> "url": "https://your-id.ngrok-free.app/api/synthesize",
> "timeoutSeconds": 45
> }
> }
> }'

The response contains your assistant’s id: copy it for the next step.

Vapi treats the model url as an OpenAI baseURL and appends /chat/completions itself, which is why the value ends at /llm. The transcriber URL uses wss:// (WebSocket), the other two use https://.

7. Test Your Agent

Create test-call.html, a minimal page that starts a web call using Vapi’s Web SDK:

1<!DOCTYPE html>
2<html>
3<body>
4 <h1>Sarvam Voice Agent</h1>
5 <button id="start">Start Call</button>
6 <button id="stop">End Call</button>
7 <pre id="log"></pre>
8
9 <script type="module">
10 import Vapi from "https://esm.sh/@vapi-ai/web";
11
12 const vapi = new Vapi("YOUR_VAPI_PUBLIC_KEY");
13 const log = (msg) =>
14 (document.getElementById("log").textContent += msg + "\n");
15
16 document.getElementById("start").onclick = () =>
17 vapi.start("YOUR_ASSISTANT_ID");
18 document.getElementById("stop").onclick = () => vapi.stop();
19
20 vapi.on("call-start", () => log("Call started, speak now!"));
21 vapi.on("call-end", () => log("Call ended"));
22 vapi.on("message", (m) => {
23 if (m.type === "transcript") log(`${m.role}: ${m.transcript}`);
24 });
25 vapi.on("error", (e) => log("ERROR: " + JSON.stringify(e)));
26 </script>
27</body>
28</html>

Fill in your public key and the assistant id, serve it locally, and open it in your browser:

$python3 -m http.server 8080
$# open http://localhost:8080/test-call.html

Click Start Call, allow microphone access, and start speaking. You’ll see live transcripts on the page and in your server logs, and hear the agent reply in Bulbul’s voice.

That’s it! You’ve built a Vapi voice agent running entirely on Sarvam models!

Why a test page instead of the dashboard? Vapi’s dashboard has a known bug with custom-transcriber assistants: the “Talk to Assistant” button can fail with a 404 or a Cannot read properties of undefined (reading 'cost') error. The assistant itself is fine; calls made through the Web SDK, API, or a phone number work normally.


How a Call Flows

  1. Transcriber: Vapi opens one WebSocket per call and streams raw 16-bit PCM with the caller on channel 0 and the assistant on channel 1. The bridge splits the channels and runs a separate Saaras v3 streaming session for each, so both sides of the conversation are transcribed.
  2. LLM: Vapi sends standard OpenAI-style chat completion requests. The proxy adds your Sarvam key, disables thinking mode, and streams the response back token by token.
  3. Voice: for every reply (including the firstMessage), Vapi POSTs a voice-request with the text and a target sample rate. The bridge synthesizes with Bulbul v3 at that exact sample rate and returns headerless mono PCM.

Going to Production

  • Deploy the bridge server to any Node.js host and replace the ngrok URLs in your assistant (PATCH https://api.vapi.ai/assistant/:id). Host in an Indian region since Sarvam’s APIs are served from India, so proximity cuts round-trip latency on every STT chunk and TTS request.
  • Secure the endpoints: set server.secret in the transcriber/voice config and validate the x-vapi-secret header on every request, or use Vapi’s Custom Credentials with a credentialId.
  • Add a fallback voice so calls continue on a built-in Vapi voice if your TTS endpoint is ever unreachable:
1"voice": {
2 "provider": "custom-voice",
3 "server": { "url": "https://your-host.com/api/synthesize" },
4 "fallbackPlan": {
5 "voices": [{ "provider": "vapi", "voiceId": "Neha" }]
6 }
7}
  • Attach a phone number: in the Vapi dashboard, create or import a phone number and assign your assistant to it, and the same bridge serves phone calls with no changes.

Customization Examples

Example 1: Hindi Voice Agent

Set the bridge’s .env to Hindi:

STT_LANGUAGE=hi-IN
TTS_LANGUAGE=hi-IN
TTS_SPEAKER=simran

And make the LLM reply in Hindi via the assistant’s system message:

1{
2 "role": "system",
3 "content": "आप एक सहायक वॉयस असिस्टेंट हैं। हमेशा हिंदी में छोटे और स्पष्ट जवाब दें।"
4}

Example 2: Multilingual Agent (Auto-detect)

Keep STT_LANGUAGE=unknown: Saaras v3 auto-detects the spoken language. Then tell the LLM:

Reply in the same language the user speaks.

Bulbul v3 handles code-mixed text (e.g. Hinglish) out of the box; set TTS_LANGUAGE to your primary audience’s language.

Example 3: Speech-to-English Agent (Saaras)

Difference: Saaras v3 handles both transcription (same-language output) and translation (English output) via the mode parameter. Use mode="translate" when users speak Indian languages but you want to process and respond in English.

In sarvamTranscriber.js, change the connection:

1this.socket = await client.speechToTextStreaming.connect({
2 model: "saaras:v3",
3 mode: "translate", // Speech-to-English translation
4 sample_rate: this.sampleRate,
5 input_audio_codec: "pcm_s16le",
6 high_vad_sensitivity: "true",
7});

Note: Saaras v3 with mode="translate" automatically detects the source language (Hindi, Tamil, etc.) and translates spoken content directly to English text, so the rest of your pipeline stays English-only.


Available Options

Language Codes

LanguageCode
English (India)en-IN
Hindihi-IN
Bengalibn-IN
Tamilta-IN
Telugute-IN
Gujaratigu-IN
Kannadakn-IN
Malayalamml-IN
Marathimr-IN
Punjabipa-IN
Odiaod-IN
Auto-detect (STT only)unknown

Speaker Voices (Bulbul v3)

Male (23): Shubh (default), Aditya, Rahul, Rohan, Amit, Dev, Ratan, Varun, Manan, Sumit, Kabir, Aayan, Ashutosh, Advait, Anand, Tarun, Sunny, Mani, Gokul, Vijay, Mohit, Rehan, Soham

Female (14): Ritu, Priya, Neha, Pooja, Simran, Kavya, Ishita, Shreya, Roopa, Tanya, Shruti, Suhani, Kavitha, Rupali


Pro Tips

  • Use STT_LANGUAGE=unknown to automatically detect the language. Great for multilingual scenarios!
  • Sarvam’s models understand code-mixing: your agent can naturally handle Hinglish, Tanglish, and other mixed languages.
  • Use sarvam-30b for voice agents: it has lower time-to-first-token than sarvam-105b, which matters on a live call.
  • Keep LLM replies short: spoken responses feel best under a couple of sentences, so say so in the system prompt.
  • Vapi’s sampleRate in each voice-request maps directly to Bulbul’s speech_sample_rate: the bridge passes it through, so never hard-code a sample rate.

Troubleshooting

“Request failed with status code 404” or a cost error in the Vapi dashboard: Known dashboard bug with custom-transcriber assistants: the assistant works; test via the web page from Step 7 instead of the dashboard’s “Talk to Assistant” button.

401 Invalid Key / 403 Key doesn't allow assistantId: You’re mixing keys. Assistant creation needs the private key, browser calls need the public key, and both must come from the same Vapi organization. Verify with: curl -s -o /dev/null -w "%{http_code}" https://api.vapi.ai/assistant -H "Authorization: Bearer YOUR_VAPI_PRIVATE_KEY", which should print 200.

Agent connects but never speaks (empty LLM replies): You’re likely bypassing the LLM proxy. With thinking mode on (Sarvam’s default), reasoning can consume the entire max_tokens budget and return an empty reply: route the model URL through /llm as shown, which disables reasoning.

Garbled or distorted agent speech: The TTS response isn’t matching Vapi’s PCM spec: most commonly a WAV header left in the response or a sample-rate mismatch. Use the wavToRawPcm helper as-is and always pass message.sampleRate through.

No transcripts arriving: Confirm the transcriber URL starts with wss:// (not https://) and check the server logs for “Vapi connected to custom transcriber” and Sarvam auth errors (SARVAM_API_KEY missing or invalid).

ngrok URL stopped working: Free ngrok URLs change on every restart. Get the current URL from http://localhost:4040 and PATCH your assistant with the new endpoints.


Additional Resources


Need Help?


Happy Building!