Skip to content

The browser ​

The same assistant that answers a phone number can answer a web page. The browser gets a public key, exchanges it for a short-lived token, and opens a media session with the gateway — audio and barge-in work exactly as they do over the phone, because it is the same pipeline behind the same transport abstraction.

webrtc / websocket→stt→endpoint→llm→tts→playback

Two ways in ​

The widget — no build step, one script tag. See Widget.

html
<script src="/widget.js"
        data-public-key="ca_pk_…"
        data-assistant-id="01JQ…"
        data-mode="voice"></script>

The SDK — when you want the conversation in your own interface. See Web SDK.

ts
import { CallAgent } from '@callagent/web-sdk';

const client = new CallAgent({ publicKey: 'ca_pk_…', apiUrl: 'https://api.example.com' });
const call = await client.start({ assistantId: '01JQ…' });

call.on('transcript', (event) => console.log(event.speaker, event.text, event.final));
call.on('state', ({ state }) => render(state));

The SDK ships zero runtime dependencies — the widget is a drop-in on other people's marketing pages, and a validation library would be a large fraction of the byte budget. The call states and message roles it does share with the control plane are asserted against @callagent/contracts by a test, so a divergence fails the build rather than being accepted silently at run time.

Keys and tokens ​

A browser never holds a private key. The SDK refuses one on sight:

ts
new CallAgent({ publicKey: 'sk_…' });
// CallAgentError: private_key_rejected

The exchange is POST /api/v1/client-tokens, authenticated with the public key and checked against the key's allowed origins — a valid key presented from a site it does not name is refused. The returned JWT is bound to one organization, one assistant, one session and an expiry, and the SDK refreshes it 60 seconds before it dies rather than waiting for a 401.

If you would rather mint tokens on your own backend, pass { token } instead of { publicKey } and the SDK never calls the exchange. Full detail on Authentication.

Client-side tools ​

A client_side tool executes in the visitor's browser, which is what lets the assistant act on the page it is embedded in — read the basket, prefill a form, scroll to a section.

ts
client.registerTool('getCartTotal', async () => ({ total: 42.5, currency: 'EUR' }));

// Or one handler for many tools; return undefined to decline.
client.on('client-tool', async ({ name, arguments: args }) => {
  if (name !== 'scrollTo') return undefined;
  document.querySelector(String(args.selector))?.scrollIntoView();
  return { scrolled: true };
});

A handler that throws sends the error back to the model rather than hanging the turn, and an unregistered tool is reported rather than silently dropped. See Tools.

Status ​

The session protocol is partly landed

The wire format is a WebSocket carrying raw PCM16 frames as binary messages plus a small JSON control channel, and the SDK speaks exactly what the gateway's WebRTC transport already implements rather than a second invented format.

Implemented on the gateway today: start, dtmf, mark, stop, offer/answer/ice inbound; format, clear, mark, stop outbound; binary PCM both ways. Audio and barge-in work.

Still to land: the conversation events — ready, state, transcript, message, speech, tool_call, error, ended outbound, and mute, text, tool_result, auth inbound. Unknown events are ignored on both sides by design, so the SDK runs against the current gateway for audio and lights the rest up as it arrives.

Separately, POST /api/v1/client/calls is not published by the control plane. The token exchange is live; starting a browser call through the API is not. Until it lands, develop against the mock gateway bundled with the SDK: pnpm --filter @callagent/web-sdk demo.

Tracked on Implementation status.

Next ​

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