@bolna/web-call · v3.0.0 · Source: github.com/bolna-ai/web-callIn beta and available for any account on request. Reach out on Slack or email support@bolna.dev to have it enabled.
Overview
The Web Call SDK connects a browser tab directly to a Bolna voice agent for a live, two-way conversation. You render your own UI, and the SDK handles everything else: asking for mic access, connecting the call, and cleaning up when it ends. You get a small state machine, six events, and a handful of methods. No telephony or real-time-audio background required.Install
<script>. No bundler needed. This exposes window.BolnaWebCall:
Set up your backend
The browser side of this SDK never sees your Bolna API key (thebn-… key). Anyone reading your page source could lift a key that’s exposed client-side and place calls on your account, so the SDK is built so that key never has a reason to be there.
Instead, the browser requests a short-lived, single-use call session from your own backend: a set of connection credentials that expire in roughly 120 seconds and are consumed the moment the first call uses them.
Your backend route is a thin proxy. It calls Bolna’s session-mint endpoint with your key server-side, then returns the JSON unchanged:
Node/Express
In
sessionUrl mode, the SDK POSTs { "user_data": {...} } to this route automatically whenever you set userData. Your route must forward it to Bolna’s mint endpoint, like above — if it doesn’t, userData is silently dropped and never reaches the agent.Why there’s no
apiKey option: the SDK deliberately can’t take a Bolna API key. A minted session expires in about 2 minutes, and its credential is consumed by the first call, so leaking one buys an attacker almost nothing.Quickstart
Once your backend exposes a session-minting route, the browser side comes down to three steps: create the call, listen for the events you care about, and start it from a click handler.Passing user data
Just like telephony’s/call endpoint, you can pass per-call userData that gets substituted into the agent’s prompt and welcome message — a {name} or {order_id} in your prompt becomes the caller’s actual name or order number for that call.
Set it once in the constructor to apply it to every call from that instance, or pass it to start() to set or override it for a single call:
userData is a JSON object, capped at 50 KB. A mint request over that limit is rejected with a 400. It’s stored on the call record and shown in call history, so don’t put secrets in it.Starter templates
Two complete, copy-pasteable starting points. Pick whichever matches your stack. Next.js (App Router). Works out of the box on Vercel. SetBOLNA_API_KEY and BOLNA_AGENT_ID as environment variables and deploy.
app/api/bolna-session/route.js
app/components/BolnaCallButton.jsx
usage
package.json needed. Run BOLNA_API_KEY=bn-… BOLNA_AGENT_ID=… node server.mjs, then open index.html.
server.mjs
index.html
API reference
new BolnaWebCall(options). Provide exactly one session source. The constructor throws if you pass zero or more than one.
Optional, on top of one of the three above:
Methods
Call state
A fresh
start() is allowed again once state reaches ended. Each call gets its own freshly-minted session.
Events. Subscribe with call.on(event, handler), and remove a handler with off or once for a one-time listener.
A handler that throws is caught internally and logged. It can’t take down call handling or other listeners.
cause holds the underlying error when one exists, for example a DOMException from a denied getUserMedia() call, or the raw error from a failed fetch(). It’s for logging and debugging: untyped (unknown), and not guaranteed to be present.
Session shape. If you use
getSession instead of sessionUrl, resolve to this shape. It’s exactly what Bolna’s mint endpoint returns, so most integrations never construct it by hand: your backend proxies the mint response as-is.
Using it with React
The SDK has no framework binding. Wrap it in a hook that creates one instance per component and tears it down on unmount:Behavior notes
- One call at a time per instance. A second
start()while a call is live rejects withalready_activeinstead of double-dialing. - Sessions are fetched inside
start(), per call, so the short credential TTL never has a chance to lapse. Nothing is written tolocalStorage. - Echo cancellation is on by default. Leave it on; headphones give the cleanest result.
- Closing or navigating the tab hangs up automatically (via
pagehide), so abandoned calls free their concurrency slot immediately instead of waiting for a server-side timeout. - All audio is encrypted automatically. Nothing to configure.
userDatapassed tostart(opts)takes precedence over the constructor’suserDatafor that call only; the instance-level value is unaffected and still applies to the nextstart().
Migrating from v1
The v1 library (bolna-webcall-library.js, raw WebSocket + 16kHz PCM) still works, and existing jsDelivr pins keep resolving, so nothing breaks if you don’t migrate. New integrations should start on the current version:
- Standard WebRTC (Opus + jitter buffer) instead of a raw audio WebSocket.
- Ephemeral, single-use sessions instead of handling a long-lived key in the client.
- A typed event emitter and explicit
CallStatemachine instead of ad hoc callbacks.
FAQ
Nothing happens when I call start(). What should I check?
Nothing happens when I call start(). What should I check?
Check the
error event first. start()’s promise rejects, so an unhandled rejection is a common reason it looks silent. Log e.code and e.message to see what actually failed.Why do I get autoplay_blocked?
Why do I get autoplay_blocked?
The browser only allows audio playback that originates from a user gesture. Make sure the element handler that calls
start() is the direct result of a click, not a promise callback, timer, or effect.Why do calls connect slowly on some networks?
Why do calls connect slowly on some networks?
Connection setup can be slow on networks that force every candidate through TURN over TCP/TLS. If you’re testing on a restrictive network, set
iceTransportPolicy: "relay" to confirm TURN itself works, then leave it unset ("all") in production so most calls use the faster path.Why am I seeing at_capacity during load testing?
Why am I seeing at_capacity during load testing?
This is your account’s concurrent web-call limit, not a per-browser limit. Check
error.scope: "customer" means your account’s cap, "global" means Bolna’s platform-wide cap.I set userData but the agent isn't using it. Why?
I set userData but the agent isn't using it. Why?
It depends on your session source. In
sessionUrl mode, check that your backend route actually forwards the user_data field from its request body to Bolna’s mint endpoint — the SDK sends it, but a proxy that ignores the body will drop it silently. In getSession/session mode, the SDK never sees your mint request at all, so you must add user_data to that POST yourself. Either way, there’s no error thrown — the call just connects without the variables substituted. See Passing user data.
