Skip to content

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.

BundleRawgzipbrotli
dist/index.js (ESM)56.59 KB14.75 KB12.62 KB
dist/widget.global.js (IIFE)53.71 KB17.38 KB15.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.

bash
pnpm install
pnpm --filter @callagent/web-sdk build

The import path below is what it will be once the package is published; it already works inside the workspace.

Start a call ​

ts
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.

ts
const call = await client.start({
  assistantId: '01J…',
  variables: { plan: 'pro' },
  metadata: { pageUrl: location.href },
});

Events ​

ts
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 ​

ts
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 ​

ts
const chat = await client.startChat({ assistantId: '01J…' });

Same protocol, same events, no microphone and no speaker.

Client-side tools (§32) ​

ts
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_error

Microphone 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 errorWhat 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.

DirectionWired end to endDeclared, not yet carried
Gateway → browserformat · clear · mark · stop (+ answer/ice when an ICE agent supplies them)ready · state · transcript · message · speech · tool_call · error · ended
Browser → gatewaystart · dtmf · mark · stop · offer · icemute · 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. The text frame 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_result is likewise unhandled — and since tool_call never 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 ​

bash
pnpm --filter @callagent/web-sdk build
pnpm --filter @callagent/web-sdk demo    # http://localhost:4321

The 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.

Documented against the code. Anything not yet implemented is labelled, never described as working.