Web SDK
@callagent/web-sdk — browser voice and chat (§60). Zero runtime dependencies; it ships inside a <script> tag on other people's pages, so every kilobyte is somebody else's Largest Contentful Paint.
| Bundle | Raw | gzip | brotli |
|---|---|---|---|
dist/index.js (ESM) | 56.59 KB | 14.75 KB | 12.62 KB |
dist/widget.global.js (IIFE) | 53.71 KB | 17.38 KB | 15.39 KB |
For the full generated type listing see SDK types.
Install
Not published yet
@callagent/web-sdk is private: true with no publishConfig, so it is not on npm: npm i @callagent/web-sdk fails with a 404. Today it is consumed from a checkout of this repository.
pnpm install
pnpm --filter @callagent/web-sdk buildThe import path below is what it will be once the package is published; it already works inside the workspace.
Start a call
import { CallAgent } from '@callagent/web-sdk';
const client = new CallAgent({
publicKey: 'ca_pk_…',
apiUrl: 'https://api.callagent.dev',
});start() needs an endpoint that does not exist yet
start() posts to POST /api/v1/client/calls to create the session. That route is not implemented in the control plane, so against a real deployment this call fails. The demo server below mocks it, which is how the SDK is exercised today.
const call = await client.start({
assistantId: '01J…',
variables: { plan: 'pro' },
metadata: { pageUrl: location.href },
});Events
call.on('transcript', (t) => …); // { speaker, text, final, at, confidence?, language? }
call.on('message', (m) => …); // assistant / user / tool messages
call.on('speech-start', (e) => …); // { speaker, at }
call.on('speech-end', (e) => …);
call.on('state', (s) => …); // { state, previous, at } — mirrors the server CallState
call.on('error', (e) => …); // CallAgentError with a stable `code`
call.on('connected', (c) => …);
call.on('ended', (e) => …);
call.on('reconnecting', (e) => …); // { attempt }
call.on('reconnected', (e) => …);on returns an unsubscribe function.
Control
call.mute();
call.unmute();
call.setMuted(true);
call.send('do you deliver to Cluj?'); // typed message, voice or chat
call.sendDtmf('123#');
call.setVolume(0.6);
await call.stop();Chat only
const chat = await client.startChat({ assistantId: '01J…' });Same protocol, same events, no microphone and no speaker.
Client-side tools (§32)
client.registerTool('getCartTotal', () => ({ total: 42.5, currency: 'EUR' }));
client.on('client-tool', async ({ name, arguments: args }) => {
if (name !== 'scrollTo') return undefined; // undefined = "not mine"
document.querySelector(String(args.selector))?.scrollIntoView();
return { scrolled: true };
});A named registration wins over a generic handler. A throwing handler sends the error back to the model instead of hanging the turn; an unregistered tool is reported.
Errors
CallAgentError.code is a closed union, so you branch on it rather than on message text:
invalid_public_key · private_key_rejected · auth_failed · token_expired
microphone_denied · microphone_unavailable · audio_unsupported
transport_failed · transport_closed · protocol_mismatch
call_start_failed · call_ended · client_tool_failed · not_connected · aborted · server_errorMicrophone permissions
getUserMedia failures are the largest source of "the widget doesn't work" tickets, and the browser's own errors mean nothing to a visitor. Each one is translated:
| Browser error | What the user is told |
|---|---|
NotAllowedError | "Microphone access was blocked. Click the padlock in the address bar…" |
NotFoundError | "No microphone was found. Plug one in or pick a different input device." |
NotReadableError | "The microphone is in use by another application." |
| insecure context | "Microphone capture requires HTTPS. Load this page over https:// (or localhost)." |
queryMicrophonePermission() is exported for pages that want to explain the prompt before triggering it.
Transports
WebRTC where RTCPeerConnection is present and constructible; WebSocket otherwise. Force one with transports: ['websocket'].
Under WebRTC the browser owns jitter buffering, packet-loss concealment and Opus encoding. The WebSocket path streams raw PCM16 mono in 20 ms binary frames with a { "event": "..." } JSON control channel alongside — the same wire format the gateway's WebRtcTransport already implements.
What is wired end to end
The SDK implements its side of the session protocol completely: all 14 server events its protocol declares (ready, format, answer, ice, state, transcript, message, speech, clear, mark, tool_call, error, ended, stop) have a handler, and all 10 client frames are typed.
The gap is on the gateway side, and it is what decides which of the events above you will actually observe today.
| Direction | Wired end to end | Declared, not yet carried |
|---|---|---|
| Gateway → browser | format · clear · mark · stop (+ answer/ice when an ICE agent supplies them) | ready · state · transcript · message · speech · tool_call · error · ended |
| Browser → gateway | start · dtmf · mark · stop · offer · ice | mute · text · tool_result · auth |
Read practically: audio and barge-in work. Transcripts, conversation messages, speech boundaries, state changes and server-driven tool calls are specified and handled but never arrive, so call.on('transcript', …) is correct code that will not fire yet.
Three consequences worth knowing before you build on them:
call.send()is inert. Thetextframe reaches the gateway's unhandled branch, so a typed chat message is dropped. This is the whole of chat mode.- Client-side tool replies are inert.
tool_resultis likewise unhandled — and sincetool_callnever arrives either, the round trip has no live half. mute()does not reach the server. It still stops the local microphone, which is what a user perceives, but the gateway is not told.
Unknown events are ignored on both sides, so none of this throws. One asymmetry does lose data silently: the gateway sends { "event": "dtmf", "digits": … }, which is not in the SDK's server-event set, so inbound DTMF is parsed as unknown and dropped.
client.on('client-tool') is a registration hook, not an observation event
It is declared on CallAgentEvents, and nothing ever emits it. Passing a listener registers a generic tool executor whose return value is sent back to the model — which is the useful behaviour, and the reason the API is shaped this way. But a listener added to watch tool calls go past will never be called. The event that is genuinely emitted is the per-call call.on('tool-call', …).
Interruption
When the gateway sends clear, playback stops within a few tens of milliseconds: every scheduled audio node is stopped at time zero and the queue is dropped. The gateway stops writing at the same instant and the socket is ordered, so nothing from the interrupted utterance arrives afterwards. Under WebRTC the remote element is muted for 150 ms to swallow the jitter buffer.
Reconnection
Exponential backoff with jitter (500 ms → 20 s, 8 attempts by default), configurable with reconnect. Each reconnect re-runs the handshake with a freshly-read token. Microphone audio is dropped while disconnected rather than queued — replaying seconds-old speech makes the assistant answer a stale question.
Browser support
Chrome, Edge, Firefox and Safari 14.1+. Capture uses an AudioWorklet where available and falls back to ScriptProcessorNode. A secure context (HTTPS or localhost) is required.
Running it locally
pnpm --filter @callagent/web-sdk build
pnpm --filter @callagent/web-sdk demo # http://localhost:4321The demo server includes a mock control plane and gateway speaking the same session protocol, so the SDK can be exercised before the real gateway exposes its socket. The token exchange it mocks matches the live POST /api/v1/client-tokens exactly, down to the data envelope and the snake_case fields.