@loop-voice-agent/web
v0.4.1
Published
Browser SDK for the Loop Voice Agent platform — start a voice call from a publishable key and an agent id.
Readme
@loop-voice-agent/web
Browser SDK for the Loop Voice Agent platform. Start a voice call from a publishable key and an agent id — the prompt, model and voice never reach the browser.
Temporary package name. This is published under
@loop-voice-agent/webwhile the product name is being decided. When it changes, a new package will be published and consumers update their import line; the API will not change as part of that rename.
Install
npm install @loop-voice-agent/webESM only. Modern bundlers (Vite, Next, Rollup, webpack 5) consume it directly.
Use
import { VoiceAgent } from "@loop-voice-agent/web";
const client = new VoiceAgent(import.meta.env.VITE_VOICE_PUBLIC_KEY, {
baseUrl: "https://api.example.com",
});
client.on("call-start", () => setStatus("live"));
client.on("call-end", ({ endedReason }) => setStatus(`ended: ${endedReason}`));
client.on("message", (m) => {
if (m.type === "transcript" && m.transcriptType === "final") append(m.role, m.transcript);
});
client.on("error", ({ code, message }) => showError(code, message));
await client.start(agentId, {
variables: { candidate_name: "Asha", role: "SDE" },
language: "hi",
channel: "audio",
metadata: { applicant_id: "A-123" },
});
// …later
client.setMuted(true);
client.stop();The agent is audible with no extra wiring — the SDK creates one hidden
<audio> element when the agent's track arrives and removes it when the call
ends.
Driving playback yourself
Pass autoPlayAudio: false and the SDK touches no DOM at all: nothing is
created, nothing is appended, nothing is played. Use it for a Web Audio graph,
a visualiser or per-agent volume control.
const client = new VoiceAgent(key, { baseUrl, autoPlayAudio: false });
client.on("call-start", () => {
audioEl.srcObject = client.getRemoteStream();
});Upgrading from 0.3.x — two changes to be aware of:
- Playback used to be the integrator's job. If your app already attaches
getRemoteStream()to an element of its own, either delete that code or setautoPlayAudio: false; leaving both in place plays the agent twice.getRemoteStream()now returns ONE stream the SDK owns, carrying every remote track. It used to return whichever stream the browser reported last, which on a video call was the avatar — video only, no audio in it.
0.4.1 reverts one part of 0.4.0: the encoder is no longer told to hold resolution at all costs. Measured on real calls that instruction cost the frame rate — camera 11.0 → 1.8 fps, shared screen 3.2 → 1.3 fps — and the pinned full-resolution encode starved the audio path as well. The bitrate ceiling stays raised; the trade-off goes back to the browser.
Credentials
Two key classes exist, and only one belongs in a browser:
| Key | Where it belongs | Authority |
| ------- | ---------------- | ----------------------------------------------- |
| vpk_… | Browser bundle | Start a call — and only from registered origins |
| vak_… | Your server only | Create agents, place PSTN calls, read history |
A publishable key is safe to ship because its authority is capped and it only works from origins registered against it. Register your app's origin on the key, or every call fails with a 403 — that specific failure means the origin is missing, not that the key is wrong.
Events
| Event | Payload | When |
| -------------- | --------------------------- | ----------------------------------------- |
| call-start | — | Media is flowing |
| call-end | { endedReason, callId? } | The call ended, cleanly or otherwise |
| speech-start | — | The agent started speaking |
| speech-end | — | The agent stopped speaking |
| message | TranscriptMessage \| … | A message from the server, usually a turn |
| error | { code, message, cause? } | The call could not start |
error and call-end are mutually exclusive for a single failure: a call that
never started emits error, and one that started and then ended emits
call-end. You never have to de-duplicate the two.
Classify an ending with the exported helper rather than by matching strings yourself:
import { isAbnormalEndedReason } from "@loop-voice-agent/web";
client.on("call-end", ({ endedReason }) => {
if (isAbnormalEndedReason(endedReason)) showRetry();
else showComplete();
});Screen sharing
Pass an already-captured stream — the SDK never calls getDisplayMedia
itself, because browsers only grant it from a user gesture and capturing inside
the SDK would raise a second picker mid-call:
const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
await client.start(agentId, { screenStream });Prefer passing it to start() when you have it up front: the track goes into the
initial offer and skips a renegotiation round trip while the user waits. When the
stream only arrives mid-call:
await client.startScreenShare(screenStream);Networking
start() performs both hops for you:
POST {baseUrl}/v1/calls/webwith the publishable key → session bundle- WebRTC offer →
POST {voiceWorkerUrl}/v1/calls/web/sessions→ SDP answer
Behind symmetric NAT you need ICE servers; without them only host candidates are
tried and the call fails with pipeline-error-connection-failed:
new VoiceAgent(key, {
baseUrl,
iceServers: [{ urls: "stun:stun.example.com:3478" }],
});API
new VoiceAgent(publicKey, options?)—baseUrl,iceServers,connectTimeoutMs,autoPlayAudio(defaulttrue),fetch,getUserMediastart(agentId, context?)—variables,language,channel,metadatastop()·setMuted(bool)·isMuted()·destroy()startScreenShare(stream)·isScreenSharing()getStatus()·getCallId()·getLocalStream()·getRemoteStream()on(event, handler)·once(...)·off(...)—onreturns an unsubscribecreateRemoteAudioSink(document?)— the element the SDK would have made, for callers who setautoPlayAudio: falsebut still want it
License
MIT
