Build a Voice Agent using Vobiz

View as Markdown

Overview

This guide shows you how to build a real-time voice agent that answers phone calls, using Vobiz for telephony and Sarvam AI for everything else: speech-to-text, the LLM, and text-to-speech. Unlike the other integration guides in this section, this one doesn’t use Pipecat. Vobiz’s bidirectional media stream is a plain JSON-over-WebSocket protocol, so a small FastAPI server talking to Sarvam’s REST API directly is all you need.

What You’ll Build

A voice agent that can:

  • Answer inbound phone calls on a Vobiz number
  • Listen to callers speaking, in multiple Indian languages
  • Understand and process what they say, with barge-in support
  • Respond back in a natural-sounding voice, over the phone, sentence by sentence as the reply streams in

Quick Overview

  1. Get an API key (Sarvam). Vobiz needs no API credentials for this flow.
  2. Install packages: pip install fastapi httpx python-dotenv python-multipart "uvicorn[standard]"
  3. Create a .env file with your API key and public URL
  4. Write a FastAPI server that speaks Vobiz’s WebSocket media protocol and calls Sarvam directly
  5. Create a Vobiz application pointing at your server, and attach your number to it
  6. Call your Vobiz number

Quick Start

1. Prerequisites

  • Python 3.9 or higher
  • ngrok, to expose your local server during development
  • A Vobiz account with a provisioned voice number
  • A Sarvam AI API key from your dashboard

Vobiz doesn’t need an account SID, auth token, or any credentials in your agent code for this flow. It fetches your Answer URL as plain HTTP when a call comes in, and everything it needs to route the call, including the stream and call identifiers, arrives over the WebSocket itself, just like Exotel’s Voicebot Applet.

2. Install Dependencies

$pip install fastapi httpx python-dotenv python-multipart "uvicorn[standard]"

This guide talks to Sarvam over plain httpx calls rather than the sarvamai SDK or Pipecat, so the whole pipeline has one HTTP dependency and one authentication header (api-subscription-key). That also means audio format handling, including resampling, WAV wrapping, and container stripping, is your responsibility here, unlike the Pipecat-based guides where the transport and Sarvam services handle it for you.

3. Create Environment File

Create a file named .env in your project folder:

SARVAM_API_KEY=sk_xxxxxxxxxxxxxxxxxxxxxxxx
SARVAM_STT_MODEL=saaras:v3
SARVAM_LLM_MODEL=sarvam-105b
SARVAM_TTS_MODEL=bulbul:v3
PUBLIC_URL=https://your-tunnel.example.com
HTTP_PORT=8000
AGENT_LANGUAGE=en-IN
TTS_SPEAKER=anand
# Vobiz sends caller audio as mu-law at 8 kHz or Linear16 at 16 kHz.
STREAM_CONTENT_TYPE=audio/x-mulaw;rate=8000
OUTBOUND_SAMPLE_RATE=8000

Replace SARVAM_API_KEY with your real key from the Sarvam dashboard, and PUBLIC_URL with your ngrok URL once you have one (Step 5).

sarvam-30b has been deprecated. sarvam-105b is now the only supported chat model, and the /v1/chat/completions API rejects the model="sarvam-30b" value. If you have existing agents pinned to sarvam-30b, update them to sarvam-105b before it stops responding entirely.

sarvam-105b reasons before replying, and that reasoning is billed against max_tokens. Budget at least 1500 tokens per turn. Set it too low, and the API returns a valid response with empty content, which would otherwise strand the caller in silence.

4. Write the Sarvam Client

Create sarvam.py, a thin async wrapper over the three Sarvam endpoints the agent needs: speech-to-text, chat, and text-to-speech.

1import base64
2import io
3import json
4import os
5import wave
6from typing import AsyncIterator
7
8import httpx
9
10BASE_URL = "https://api.sarvam.ai"
11STT_MODEL = os.getenv("SARVAM_STT_MODEL", "saaras:v3")
12LLM_MODEL = os.getenv("SARVAM_LLM_MODEL", "sarvam-105b")
13TTS_MODEL = os.getenv("SARVAM_TTS_MODEL", "bulbul:v3")
14SAMPLE_WIDTH_BYTES = 2
15
16
17class SarvamError(RuntimeError):
18 pass
19
20
21def pcm_to_wav(pcm_audio: bytes, sample_rate: int) -> bytes:
22 """Wrap raw mono Linear16 samples in a WAV container for the STT upload."""
23 buffer = io.BytesIO()
24 with wave.open(buffer, "wb") as wav_file:
25 wav_file.setnchannels(1)
26 wav_file.setsampwidth(SAMPLE_WIDTH_BYTES)
27 wav_file.setframerate(sample_rate)
28 wav_file.writeframes(pcm_audio)
29 return buffer.getvalue()
30
31
32def raw_linear16(audio: bytes, expected_rate: int) -> bytes:
33 """Vobiz playAudio needs container-free samples; Sarvam TTS may return a WAV."""
34 if audio.startswith(b"RIFF"):
35 with wave.open(io.BytesIO(audio), "rb") as wav_file:
36 audio = wav_file.readframes(wav_file.getnframes())
37 return audio
38
39
40class SarvamClient:
41 def __init__(self, api_key: str) -> None:
42 self._headers = {"api-subscription-key": api_key}
43 self._http = httpx.AsyncClient(base_url=BASE_URL, timeout=30.0)
44
45 async def aclose(self) -> None:
46 await self._http.aclose()
47
48 async def transcribe(self, pcm_audio: bytes, sample_rate: int, language_code: str) -> str:
49 wav_audio = pcm_to_wav(pcm_audio, sample_rate)
50 response = await self._http.post(
51 "/speech-to-text",
52 headers=self._headers,
53 files={"file": ("caller.wav", wav_audio, "audio/wav")},
54 data={"model": STT_MODEL, "mode": "codemix", "language_code": language_code},
55 )
56 response.raise_for_status()
57 return (response.json().get("transcript") or "").strip()
58
59 async def stream_reply(
60 self, messages: list[dict[str, str]], max_tokens: int = 2000
61 ) -> AsyncIterator[str]:
62 """Yield only reply text. These models also stream a private
63 ``reasoning_content`` field that must never reach the caller's ear."""
64 payload = {
65 "model": LLM_MODEL,
66 "messages": messages,
67 "max_tokens": max_tokens,
68 "stream": True,
69 }
70 async with self._http.stream(
71 "POST", "/v1/chat/completions", headers=self._headers, json=payload
72 ) as response:
73 response.raise_for_status()
74 async for line in response.aiter_lines():
75 if not line.startswith("data:"):
76 continue
77 data = line[len("data:"):].strip()
78 if data in ("", "[DONE]"):
79 continue
80 for choice in json.loads(data).get("choices", []):
81 delta = (choice.get("delta") or {}).get("content")
82 if delta:
83 yield delta
84
85 async def synthesize(self, text: str, language_code: str, speaker: str, sample_rate: int) -> bytes:
86 response = await self._http.post(
87 "/text-to-speech",
88 headers=self._headers,
89 json={
90 "text": text,
91 "target_language_code": language_code,
92 "speaker": speaker,
93 "model": TTS_MODEL,
94 "speech_sample_rate": sample_rate,
95 "output_audio_codec": "linear16",
96 },
97 )
98 response.raise_for_status()
99 audios = response.json().get("audios") or []
100 if not audios:
101 raise SarvamError("Sarvam TTS returned no audio")
102 return raw_linear16(base64.b64decode("".join(audios)), sample_rate)

raw_linear16() strips a WAV header if Sarvam returns one. Vobiz’s playAudio message requires raw, container-free Linear16 samples: sending it a WAV file (with its 44-byte header) produces a burst of noise at the start of every utterance.

5. Write the Call Session

Create agent.py. This holds the per-call pipeline: a simple energy-based VAD (voice activity detector) over inbound audio, then speech-to-text, the chat model, and text-to-speech, streamed back to the caller sentence by sentence:

1import asyncio
2import audioop
3import base64
4import json
5import os
6import re
7from typing import Any
8
9from sarvam import SarvamClient
10
11OUTBOUND_SAMPLE_RATE = int(os.getenv("OUTBOUND_SAMPLE_RATE", "8000"))
12SAMPLE_WIDTH_BYTES = 2
13AGENT_LANGUAGE = os.getenv("AGENT_LANGUAGE", "en-IN")
14TTS_SPEAKER = os.getenv("TTS_SPEAKER", "anand")
15
16SYSTEM_PROMPT = (
17 "You are a Sarvam AI voice assistant speaking with a caller on a phone "
18 "line. You understand English and Indian languages, including code-mixed "
19 "speech. Keep every reply to one or two short spoken sentences. Never use "
20 "markdown, bullet points, emoji, or symbols, because your reply is read aloud."
21)
22GREETING = "Namaste! I am your Sarvam AI assistant. How can I help you today?"
23
24# Split streamed LLM text on sentence enders, including the Devanagari danda,
25# so the caller hears sentence one while the model is still writing sentence two.
26SENTENCE_END = re.compile(r"[.!?|।॥]+[\s\"')\]]*|\n+")
27MIN_TTS_CHARS = 24
28
29SILENCE_THRESHOLD = 250 # RMS energy that counts as speech
30END_OF_SPEECH_MS = 700 # trailing silence that ends an utterance
31MIN_SPEECH_MS = 200 # shorter utterances are treated as noise
32
33
34class CallSession:
35 def __init__(self, websocket: Any) -> None:
36 self.websocket = websocket
37 self.stream_id: str | None = None
38 self.input_sample_rate = 8000
39 self.input_encoding = "audio/x-mulaw"
40
41 self.sarvam = SarvamClient(os.getenv("SARVAM_API_KEY", ""))
42 self.conversation = [{"role": "system", "content": SYSTEM_PROMPT}]
43
44 self._audio = bytearray()
45 self._speech_ms = 0.0
46 self._silence_ms = 0.0
47 self._speech_active = False
48 self._playback_active = False
49 self._tasks: set[asyncio.Task] = set()
50
51 async def handle_message(self, raw_message: str) -> None:
52 data = json.loads(raw_message)
53 event = data.get("event")
54
55 if event == "start":
56 await self._handle_start(data)
57 elif event == "media":
58 await self._handle_media(data)
59 elif event in {"playedStream", "clearedAudio"}:
60 self._playback_active = False
61
62 async def _handle_start(self, data: dict) -> None:
63 start = data.get("start") or {}
64 self.stream_id = data.get("streamId") or start.get("streamId")
65
66 # Trust the negotiated format rather than assuming a rate: Vobiz sends
67 # mu-law at 8 kHz or Linear16 at 16 kHz depending on <Stream contentType>.
68 media_format = start.get("mediaFormat") or {}
69 self.input_sample_rate = int(media_format.get("sampleRate", 8000))
70 self.input_encoding = str(media_format.get("encoding", "audio/x-mulaw"))
71
72 self._spawn(self._speak(GREETING))
73
74 async def _handle_media(self, data: dict) -> None:
75 media = data.get("media") or {}
76 payload = media.get("payload")
77 if not payload:
78 return
79
80 raw = base64.b64decode(payload)
81 pcm = audioop.ulaw2lin(raw, 2) if self.input_encoding == "audio/x-mulaw" else raw
82 chunk_ms = len(pcm) * 1000 / (self.input_sample_rate * SAMPLE_WIDTH_BYTES)
83 rms = audioop.rms(pcm, SAMPLE_WIDTH_BYTES)
84
85 if rms >= SILENCE_THRESHOLD:
86 if self._playback_active:
87 await self._clear_audio() # barge-in
88 self._speech_active = True
89 self._speech_ms += chunk_ms
90 self._silence_ms = 0
91 self._audio.extend(pcm)
92 elif self._speech_active:
93 self._silence_ms += chunk_ms
94 self._audio.extend(pcm)
95
96 if self._speech_active and self._silence_ms >= END_OF_SPEECH_MS:
97 utterance, speech_ms = bytes(self._audio), self._speech_ms
98 self._audio.clear()
99 self._speech_ms = self._silence_ms = 0
100 self._speech_active = False
101 if speech_ms >= MIN_SPEECH_MS:
102 self._spawn(self._process_utterance(utterance))
103
104 async def _process_utterance(self, pcm_audio: bytes) -> None:
105 transcript = await self.sarvam.transcribe(pcm_audio, self.input_sample_rate, AGENT_LANGUAGE)
106 if not transcript:
107 return
108 self.conversation.append({"role": "user", "content": transcript})
109
110 pending = ""
111 async for delta in self.sarvam.stream_reply(self.conversation):
112 pending += delta
113 match = SENTENCE_END.search(pending)
114 if match and match.end() >= MIN_TTS_CHARS:
115 sentence, pending = pending[:match.end()].strip(), pending[match.end():]
116 if sentence:
117 await self._speak(sentence)
118 if pending.strip():
119 await self._speak(pending.strip())
120
121 async def _speak(self, text: str) -> None:
122 pcm = await self.sarvam.synthesize(text, AGENT_LANGUAGE, TTS_SPEAKER, OUTBOUND_SAMPLE_RATE)
123 await self._play(pcm)
124
125 async def _play(self, pcm_audio: bytes) -> None:
126 chunk_size = OUTBOUND_SAMPLE_RATE * SAMPLE_WIDTH_BYTES * 20 // 1000 # 20 ms chunks
127 self._playback_active = True
128 for offset in range(0, len(pcm_audio), chunk_size):
129 await self.websocket.send_text(json.dumps({
130 "event": "playAudio",
131 "streamId": self.stream_id,
132 "media": {
133 "contentType": "audio/x-l16",
134 "sampleRate": OUTBOUND_SAMPLE_RATE,
135 "payload": base64.b64encode(pcm_audio[offset:offset + chunk_size]).decode(),
136 },
137 }))
138 await self.websocket.send_text(json.dumps({"event": "checkpoint", "streamId": self.stream_id, "name": "tts"}))
139
140 async def _clear_audio(self) -> None:
141 await self.websocket.send_text(json.dumps({"event": "clearAudio", "streamId": self.stream_id}))
142 self._playback_active = False
143
144 def _spawn(self, coroutine) -> None:
145 task = asyncio.create_task(coroutine)
146 self._tasks.add(task)
147 task.add_done_callback(self._tasks.discard)
148
149 async def close(self) -> None:
150 for task in list(self._tasks):
151 task.cancel()
152 await self.sarvam.aclose()

The chat model’s streamed reply is split into sentences with SENTENCE_END and spoken as each one completes, rather than waiting for the full reply. This is what keeps turn latency down: the caller hears sentence one while the model is still generating sentence two.

Barge-in works by watching inbound RMS energy during playback: if the caller starts talking while the agent is still speaking, the session sends clearAudio and starts listening for a new utterance. This energy-based VAD can’t distinguish speech from background noise, so on noisy phone lines you may need to raise SILENCE_THRESHOLD or disable barge-in entirely.

6. Write the FastAPI Server

Create server.py, exposing the Answer URL Vobiz fetches when a call comes in, and the WebSocket it streams audio over:

1import html
2import os
3from pathlib import Path
4
5import uvicorn
6from dotenv import load_dotenv
7from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
8from fastapi.responses import Response
9
10from agent import CallSession
11
12load_dotenv(Path(__file__).resolve().parent / ".env")
13
14PUBLIC_URL = os.getenv("PUBLIC_URL", "").rstrip("/")
15STREAM_CONTENT_TYPE = os.getenv("STREAM_CONTENT_TYPE", "audio/x-mulaw;rate=8000")
16
17app = FastAPI()
18
19
20def websocket_url(public_url: str) -> str:
21 if public_url.startswith("https://"):
22 return "wss://" + public_url.removeprefix("https://") + "/ws"
23 return "ws://" + public_url.removeprefix("http://") + "/ws"
24
25
26@app.api_route("/answer", methods=["GET", "POST"])
27async def answer(request: Request) -> Response:
28 """The XML Vobiz executes once the call is answered."""
29 base_url = html.escape(PUBLIC_URL, quote=True)
30 ws_url = html.escape(websocket_url(PUBLIC_URL), quote=False)
31 xml = f"""<?xml version="1.0" encoding="UTF-8"?>
32<Response>
33 <Stream bidirectional="true"
34 audioTrack="inbound"
35 keepCallAlive="true"
36 contentType="{html.escape(STREAM_CONTENT_TYPE, quote=True)}"
37 statusCallbackUrl="{base_url}/stream-status"
38 statusCallbackMethod="POST">
39 {ws_url}
40 </Stream>
41 <Hangup/>
42</Response>"""
43 return Response(content=xml, media_type="application/xml")
44
45
46@app.websocket("/ws")
47async def websocket_endpoint(websocket: WebSocket) -> None:
48 await websocket.accept()
49 session = CallSession(websocket)
50 try:
51 async for message in websocket.iter_text():
52 await session.handle_message(message)
53 except WebSocketDisconnect:
54 pass
55 finally:
56 await session.close()
57
58
59@app.post("/stream-status")
60async def stream_status(request: Request) -> Response:
61 await request.form()
62 return Response(status_code=204)
63
64
65@app.post("/hangup")
66async def hangup(request: Request) -> Response:
67 await request.form()
68 return Response(status_code=204)
69
70
71if __name__ == "__main__":
72 uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("HTTP_PORT", "8000")))

<Record> and <Stream> are siblings under <Response>, not nested inside one another. If you add call recording later with <Record recordSession="true" .../>, place it as its own top-level element alongside <Stream>. Nesting <Stream> inside <Record>, or letting a Record callback return <Hangup/>, ends the call before the agent WebSocket ever starts.

7. Configure Vobiz to Reach Your Agent

Start ngrok:

$ngrok http 8000

ngrok prints a forwarding URL that looks like https://xxxx.ngrok-free.app. Put that exact URL, unchanged, into PUBLIC_URL in your .env file. The server derives the wss:// WebSocket URL from it automatically.

Free ngrok URLs change every time you restart ngrok. If your agent stops receiving calls, update PUBLIC_URL in .env to match the URL ngrok is currently printing, and restart server.py.

Create an application and point it at the server:

The Answer and Hangup URLs live on a Vobiz application, not directly on the number. You create the application first, then attach your number to it.

  1. In the Vobiz dashboard, go to Applications
  2. Create a new application
  3. Set the Answer URL to https://xxxx.ngrok-free.app/answer (method: POST)
  4. Set the Hangup URL to https://xxxx.ngrok-free.app/hangup (method: POST)
  5. Save the application
  6. Go to your number’s settings and attach it to the application you just created

Step 6 is easy to miss but required. Without a number attached to the application, calls have nowhere to route to, even if the application itself is configured correctly.

8. Run and Test Your Agent

$python server.py

Call your Vobiz number from any phone. Vobiz fetches /answer, opens the WebSocket at /ws, and your agent greets the caller.


Audio Formats

The two directions of a Vobiz call are independent and configured separately.

Vobiz → your agent, set by <Stream contentType>:

FormatUse
audio/x-mulaw;rate=80008 kHz inbound (default in this guide)
audio/x-l16;rate=1600016 kHz inbound

Your agent → Vobiz, set per playAudio message: Linear16 at 8000, 16000, or 24000 Hz. 24 kHz applies to playback only, not inbound audio.

playAudio payloads must be raw mono little-endian Linear16, Base64-encoded, with no WAV header. Declaring a sampleRate in the playAudio message does not resample the bytes you send. If you declare 24000 while the payload is actually 8 kHz audio, the caller hears distorted, sped-up “chipmunk” audio. raw_linear16() in Step 4 strips a WAV container if Sarvam happens to return one, but it doesn’t resample.

Read start.mediaFormat on the start event rather than assuming a rate, as this guide’s _handle_start does. Vobiz tells you the exact encoding and sample rate it negotiated for each call.


Customization Examples

Hindi Voice Agent

1AGENT_LANGUAGE = "hi-IN"
2TTS_SPEAKER = "simran" # or: priya, ishita, kavya, aditya, anand, rohan

Multilingual Agent (Auto-detect)

1AGENT_LANGUAGE = "unknown" # Sarvam auto-detects the caller's spoken language
2TTS_SPEAKER = "anand"

Use AGENT_LANGUAGE = "unknown" for support lines where callers might speak any of several languages. Sarvam detects the spoken language per utterance, so the same agent can handle a Hindi caller followed by a Tamil caller without any code changes.

Available 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-detectunknown

Speaker Voices (Bulbul v3)

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

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

Speakers are model-specific. A bulbul:v2 voice such as anushka is rejected by bulbul:v3. Check that the speaker you pick is valid for the SARVAM_TTS_MODEL you’re using.


Understanding the Call Flow

  1. Caller dials your Vobiz number. Vobiz fetches your Answer URL, and executes the returned <Stream> XML, opening a bidirectional WebSocket to your agent.
  2. start event: Vobiz sends the stream ID and negotiated audio format. Your agent speaks a greeting.
  3. media events: Caller audio arrives as base64-encoded chunks. An energy-based VAD buffers speech and detects when the caller has stopped talking.
  4. STT: The buffered utterance is sent to Sarvam’s Saaras model for transcription.
  5. LLM: The transcript is added to the conversation and streamed through a Sarvam chat model.
  6. TTS: Each completed sentence of the reply is synthesized with Sarvam’s Bulbul and sent back as playAudio events, so playback starts before the whole reply is ready.
  7. Barge-in: If the caller speaks while the agent is still playing audio, a clearAudio event stops playback and a new utterance begins.

Pro Tips

  • Use AGENT_LANGUAGE = "unknown" to auto-detect the caller’s language on multilingual support lines.
  • Sarvam’s models understand code-mixing, so your agent can naturally handle Hinglish, Tanglish, and other mixed languages common on real support calls.
  • Keep the system prompt explicit about reply length and formatting. Since the reply is read aloud, ask the model to avoid markdown, bullet points, and emoji.
  • If a chat completion ever returns empty content (the model spent its whole token budget reasoning), have the agent speak a fallback line instead of going silent.

Log every transcript and reply to a file or database for post-call analytics. It’s cheap and doesn’t slow down the live pipeline.


Troubleshooting

Call connects but there’s no audio. Confirm PUBLIC_URL in .env matches your current ngrok URL exactly, and that the Answer URL configured on your Vobiz application points at /answer on that same URL.

Call doesn’t reach your agent at all. Confirm your number is actually attached to the application, not just that the application has the right URLs. A number with no application attached has nowhere to route an incoming call.

Choppy or chipmunk-pitched audio. Check that OUTBOUND_SAMPLE_RATE and the sampleRate you declare in each playAudio message match the actual bytes you’re sending. Declaring a rate that doesn’t match the payload distorts pitch and speed without erroring.

Playback has a burst of noise at the start. Sarvam TTS occasionally returns a WAV-wrapped file instead of raw PCM. Make sure raw_linear16() runs on every synthesized chunk before it’s sent as playAudio.

API key errors. Make sure SARVAM_API_KEY is in your .env file and that the file sits next to server.py.

Empty or truncated replies. sarvam-105b spends tokens on internal reasoning before writing the reply. Raise max_tokens on the chat request; 1500 to 2000 is a safe starting point.

Barge-in doesn’t trigger, or triggers on background noise. The VAD here is a simple RMS energy threshold, not real speech detection. Raise SILENCE_THRESHOLD on noisy lines, or set it high enough to ignore ambient noise from the caller’s environment.


Additional Resources


Need Help?


Happy Building!