Skip to navigation

Telephony: Bandwidth

Answer Bandwidth phone calls with Pulse STT, Electron, and Lightning TTS over Bandwidth Programmable Voice media streaming.
View as Markdown

Bandwidth is a Tier 1 carrier and CPaaS provider. Its Programmable Voice API can stream a live call’s audio to your server over a WebSocket. This guide builds a small FastAPI bridge that takes that audio, transcribes it with Pulse, writes a reply with Electron, and speaks it back with Lightning v3.1, so callers on a Bandwidth number talk to a voice agent you control end to end.

Bandwidth delivers call audio as 8 kHz G.711 mu-law. Pulse accepts mu-law at 8 kHz and Lightning can return mu-law at 8 kHz, so audio passes straight through the bridge with no resampling or transcoding.

This guide uses Programmable Voice with media streaming, so you write and host the bridge yourself and choose every model and prompt. If you would rather have Smallest AI host the agent and connect your Bandwidth numbers over SIP, see SIP Trunking.

How it works

loop [Every 20 ms] Dials your Bandwidth number POST /bxml (call initiated, Basic Auth) BXML StartStream (bidirectional) + StopStream wait Opens media WebSocket /stream/{token} media event (base64 mu-law) raw mu-law bytes speech_ended, then final transcript chat completion reply text text (output_format=ulaw, 8000 Hz) mu-law audio chunks playAudio events (audio/pcmu) Agent speaks Caller Bandwidth Your bridge (FastAPI) Pulse STT Electron Lightning TTS
  1. A caller dials your Bandwidth number. Bandwidth sends a call initiated webhook to /bxml, authenticated with HTTP Basic Auth.
  2. The bridge replies with BXML: <StartStream mode="bidirectional"> opens a two-way media WebSocket, and <StopStream wait="true"/> keeps the call up for as long as the stream runs.
  3. Bandwidth connects to /stream/{token}. The token is single-use and expires after 30 seconds, so the bridge only trusts call IDs that came from the authenticated webhook, never from the unauthenticated WebSocket start event.
  4. Caller audio is forwarded to the Pulse STT WebSocket as raw mu-law bytes. With vad_events=true, Pulse reports when the caller starts and stops speaking.
  5. When the caller stops speaking, the bridge sends finalize, collects the final transcript, asks Electron for a reply, and streams it through Lightning TTS back to Bandwidth as playAudio events.
  6. If the caller talks over the agent, the bridge cancels the reply and sends Bandwidth a clear event, which drops any audio still queued for playback (barge-in).

Audio formats

LegFormatSetting
Bandwidth to bridgeG.711 mu-law, 8 kHz, base64, 20 ms framesFixed by Bandwidth
Bridge to Pulse STTG.711 mu-law, 8 kHz, raw bytesencoding=mulaw, sample_rate=8000 (audio formats)
Lightning TTS to bridgeG.711 mu-law, 8 kHzoutput_format: "ulaw", sample_rate: 8000 (streaming)
Bridge to BandwidthG.711 mu-law, base64playAudio with contentType: "audio/pcmu"

Declare the rate you actually send. Pulse does not detect the sample rate. If you send 8 kHz audio but declare sample_rate=16000, the transcript comes back wrong with no error.

Prerequisites

  • Python 3.10+
  • A Smallest AI API key from the console
  • A Bandwidth account with Programmable Voice, at least one phone number, and access to the Bandwidth App
  • A public HTTPS host for the bridge. For local testing, ngrok works: ngrok http 8000

Setup

1. Configure Bandwidth

In the Bandwidth App:

  1. Create a Voice Application. Under Call initiated, choose POST and set the callback URL to https://<your-public-host>/bxml.
  2. Tick Use a callback username and password and enter a username and password. You will put the same values in .env.
  3. Link the application to a Voice Configuration Package, and assign your phone number to that package.

On the Bandwidth Build self-serve tier, a Voice Application and Voice Configuration Package are created when you activate your account. Update the callback URL and credentials on the existing application instead of creating a new one.

2. Create the project

mkdir bandwidth-smallest && cd bandwidth-smallest
python3 -m venv .venv && source .venv/bin/activate
pip install fastapi uvicorn "websockets>=13" openai python-dotenv

Create a .env file:

SMALLEST_API_KEY=your-smallest-api-key
PUBLIC_HOST=abcd-1234.ngrok-free.app # host only, no https://
BW_CALLBACK_USERNAME=choose-a-username
BW_CALLBACK_PASSWORD=choose-a-strong-password
SMALLEST_VOICE_ID=meher # optional
SMALLEST_LANGUAGE=en # optional, used for both STT and TTS

3. Add the bridge

Save this as server.py:

server.py
"""Bandwidth Programmable Voice <-> Smallest AI voice bridge.
Bandwidth streams the caller's audio (8 kHz mu-law) over a WebSocket.
Pulse STT transcribes it, Electron writes the reply, and Lightning TTS
speaks it back in 8 kHz mu-law, so no audio is resampled anywhere.
"""
import asyncio
import base64
import json
import os
import secrets
import time
from contextlib import suppress
from urllib.parse import urlencode
import websockets
from dotenv import load_dotenv
from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import Response
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from openai import AsyncOpenAI
load_dotenv()
SMALLEST_API_KEY = os.environ["SMALLEST_API_KEY"]
PUBLIC_HOST = os.environ["PUBLIC_HOST"] # e.g. abcd-1234.ngrok-free.app (no scheme)
CALLBACK_USER = os.environ["BW_CALLBACK_USERNAME"]
CALLBACK_PASS = os.environ["BW_CALLBACK_PASSWORD"]
VOICE_ID = os.getenv("SMALLEST_VOICE_ID", "meher")
LANGUAGE = os.getenv("SMALLEST_LANGUAGE", "en")
STT_URL = "wss://api.smallest.ai/waves/v1/stt/live?" + urlencode({
"model": "pulse",
"language": LANGUAGE,
"encoding": "mulaw", # Bandwidth sends G.711 mu-law ...
"sample_rate": "8000", # ... at 8 kHz
"vad_events": "true", # speech_started / speech_ended for barge-in and turn-taking
})
TTS_URL = "wss://api.smallest.ai/waves/v1/tts/live"
AUTH = {"Authorization": f"Bearer {SMALLEST_API_KEY}"}
SYSTEM_PROMPT = (
"You are a friendly phone assistant. Reply in one or two short spoken "
"sentences. No markdown, lists, or emoji."
)
GREETING = "Hi, thanks for calling. How can I help you today?"
app = FastAPI()
security = HTTPBasic()
llm = AsyncOpenAI(base_url="https://api.smallest.ai/waves/v1", api_key=SMALLEST_API_KEY)
# One-time tokens that tie a media WebSocket back to the webhook that created it.
# The WebSocket's own `start` event is unauthenticated, so call IDs come from here.
TOKEN_TTL_S = 30
pending: dict[str, tuple[dict, float]] = {}
def check_callback_auth(creds: HTTPBasicCredentials = Depends(security)) -> None:
ok_user = secrets.compare_digest(creds.username, CALLBACK_USER)
ok_pass = secrets.compare_digest(creds.password, CALLBACK_PASS)
if not (ok_user and ok_pass):
raise HTTPException(status_code=401)
@app.post("/bxml", dependencies=[Depends(check_callback_auth)])
async def bxml(request: Request) -> Response:
"""Bandwidth's "call initiated" (inbound) or "answer" (outbound) webhook."""
event = await request.json()
token = secrets.token_urlsafe(24)
pending[token] = (
{"callId": event.get("callId"), "accountId": event.get("accountId"), "from": event.get("from")},
time.monotonic() + TOKEN_TTL_S,
)
# StartStream opens the bidirectional media WebSocket.
# StopStream wait="true" holds the call open until the stream ends.
body = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<StartStream name="smallest" mode="bidirectional" tracks="inbound" destination="wss://{PUBLIC_HOST}/stream/{token}" />
<StopStream name="smallest" wait="true" />
</Response>"""
return Response(content=body, media_type="application/xml")
def redeem(token: str) -> dict | None:
entry = pending.pop(token, None)
if entry is None or entry[1] < time.monotonic():
return None
return entry[0]
class Call:
"""State for one live call."""
def __init__(self, bw: WebSocket, call: dict):
self.bw = bw
self.call = call
self.history = [{"role": "system", "content": SYSTEM_PROMPT}]
self.turn_text: list[str] = []
self.turn_ended = False
self.speaking: asyncio.Task | None = None
# ---- outbound audio -------------------------------------------------
async def play(self, mulaw: bytes) -> None:
await self.bw.send_text(json.dumps({
"eventType": "playAudio",
"media": {"contentType": "audio/pcmu", "payload": base64.b64encode(mulaw).decode()},
}))
async def interrupt(self) -> None:
"""Caller started talking: stop generating and drop buffered audio."""
if self.speaking and not self.speaking.done():
self.speaking.cancel()
await self.bw.send_text(json.dumps({"eventType": "clear"}))
async def speak(self, text: str) -> None:
async with websockets.connect(TTS_URL, additional_headers=AUTH) as tts:
await tts.send(json.dumps({
"text": text,
"voice_id": VOICE_ID,
"model": "lightning_v3.1",
"language": LANGUAGE,
"sample_rate": 8000,
"output_format": "ulaw", # matches Bandwidth's audio/pcmu, no conversion
}))
async for raw in tts:
msg = json.loads(raw)
if msg.get("status") == "chunk":
await self.play(base64.b64decode(msg["data"]["audio"]))
elif msg.get("status") in ("complete", "error"):
break
# ---- conversation ---------------------------------------------------
async def reply(self, user_text: str) -> None:
self.history.append({"role": "user", "content": user_text})
completion = await llm.chat.completions.create(model="electron", messages=self.history)
answer = completion.choices[0].message.content.strip()
self.history.append({"role": "assistant", "content": answer})
await self.speak(answer)
def start_speaking(self, coro) -> None:
self.speaking = asyncio.create_task(coro)
async def on_transcript(self, msg: dict, stt) -> None:
kind = msg.get("type")
if kind == "speech_started":
await self.interrupt()
self.turn_ended = False
elif kind == "speech_ended":
# Caller paused: force the final transcript for this turn.
self.turn_ended = True
await stt.send(json.dumps({"type": "finalize"}))
elif kind == "transcription" and msg.get("is_final"):
if msg.get("transcript", "").strip():
self.turn_text.append(msg["transcript"].strip())
if self.turn_ended and self.turn_text:
text, self.turn_text, self.turn_ended = " ".join(self.turn_text), [], False
self.start_speaking(self.reply(text))
@app.websocket("/stream/{token}")
async def stream(ws: WebSocket, token: str) -> None:
call_info = redeem(token)
if call_info is None:
await ws.close(code=4401)
return
await ws.accept()
call = Call(ws, call_info)
async with websockets.connect(STT_URL, additional_headers=AUTH) as stt:
async def bandwidth_to_pulse() -> None:
while True:
msg = json.loads(await ws.receive_text())
event = msg.get("eventType")
if event == "start":
call.start_speaking(call.speak(GREETING))
elif event == "media":
await stt.send(base64.b64decode(msg["payload"])) # raw mu-law bytes
elif event == "stop":
return
async def pulse_to_call() -> None:
async for raw in stt:
await call.on_transcript(json.loads(raw), stt)
tasks = [asyncio.create_task(bandwidth_to_pulse()), asyncio.create_task(pulse_to_call())]
try:
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
except WebSocketDisconnect:
pass
finally:
for t in tasks + [call.speaking]:
if t and not t.done():
t.cancel()
with suppress(asyncio.CancelledError, Exception):
await t

4. Run it

uvicorn server:app --host 0.0.0.0 --port 8000

Call your Bandwidth number. The agent greets you, and you can talk to it. Interrupt it mid-sentence and it stops and listens.

Outbound calls

To have the agent place a call, create the call with the Bandwidth Voice API and point answerUrl at the same /bxml endpoint. When the person answers, Bandwidth requests BXML and the call joins the same bridge.

Bandwidth API calls use OAuth 2.0 client credentials. Create an API credential (client ID and secret) in the Bandwidth App, then:

outbound.py
import base64
import os
import httpx
ACCOUNT_ID = os.environ["BW_ACCOUNT_ID"]
APPLICATION_ID = os.environ["BW_APPLICATION_ID"]
CLIENT_ID = os.environ["BW_CLIENT_ID"]
CLIENT_SECRET = os.environ["BW_CLIENT_SECRET"]
PUBLIC_HOST = os.environ["PUBLIC_HOST"]
basic = base64.b64encode(f"{CLIENT_ID}:{CLIENT_SECRET}".encode()).decode()
token = httpx.post(
"https://api.bandwidth.com/api/v1/oauth2/token",
headers={"Authorization": f"Basic {basic}"},
data={"grant_type": "client_credentials"},
).json()["access_token"] # valid for 1 hour; refresh before it expires
call = httpx.post(
f"https://voice.bandwidth.com/api/v2/accounts/{ACCOUNT_ID}/calls",
headers={"Authorization": f"Bearer {token}"},
json={
"from": "+15551230000", # a number on your Bandwidth account
"to": "+15559870000",
"applicationId": APPLICATION_ID,
"answerUrl": f"https://{PUBLIC_HOST}/bxml",
"username": os.environ["BW_CALLBACK_USERNAME"],
"password": os.environ["BW_CALLBACK_PASSWORD"],
},
)
call.raise_for_status()
print(call.json()["callId"])

The username and password fields make Bandwidth send Basic Auth on the answer webhook, so /bxml accepts it.

Going to production

  • Stream the reply. The example waits for Electron’s full answer before speaking. Use streaming chat completions and send sentences to Lightning as they arrive, with a shared context_id so prosody carries across fragments (Continuations).
  • Reuse the TTS connection. Open one Lightning WebSocket per call instead of one per reply to save a connection handshake on every turn.
  • Pick your languages. Set SMALLEST_LANGUAGE to a code Pulse and your chosen voice both support. For English-only agents, Pulse 2.0 adds built-in end-of-turn detection.
  • Run close to your callers. Lightning and Pulse serve from India and US West. Host the bridge in the same region as your inference pool to keep round trips short.
  • Store tokens outside memory. The in-memory token map works for a single process. Use Redis or similar if you run several bridge replicas.
  • Hang up cleanly. To end a call from the agent, send {"state": "completed"} to POST https://voice.bandwidth.com/api/v2/accounts/{accountId}/calls/{callId} with your Bearer token. The accountId and callId are stored on each Call from the webhook.

Troubleshooting

SymptomLikely causeFix
Call connects, then drops immediatelyBandwidth could not reach /bxml, or got a 401Check the callback URL, and that the callback username and password match .env
/stream closes with code 4401Token expired or reusedBandwidth must connect within 30 seconds of the webhook. Check PUBLIC_HOST matches your public host exactly
Agent never hears the callerMedia WebSocket not reaching the bridgeConfirm your host accepts wss:// upgrades (ngrok does by default)
Transcripts are nonsenseWrong encoding or rateKeep encoding=mulaw and sample_rate=8000 on the Pulse URL
Agent audio is noise or too fastTTS format mismatchKeep output_format: "ulaw" and sample_rate: 8000, and send contentType: "audio/pcmu"
Agent talks over the callerVAD events offKeep vad_events=true on the Pulse URL so speech_started triggers clear

Reference