# @waylovoice/client > Browser SDK for voice calls with a Waylo voice agent. The web page names the agent; the SDK asks the site's own backend for a call session, joins the call, turns on the microphone, plays the agent's voice and reports state, captions, errors, devices and timings. Framework-free TypeScript; works with any front end. Rules an assistant must follow: - A Waylo API key (`sk_waylo_…`) never goes to the browser. The site's backend holds it and serves a session endpoint; the page only gives the SDK that endpoint's URL. - The session endpoint must be on the same origin as the page. - One `WayloVoice` instance is one call. Create a new one for every call, with its own listeners. - Call `start()` from a click handler, before any `await`, so the browser lets the agent's audio play. - The package is ESM with TypeScript types included. - Decide on `error.code`, never on `error.message`. ## Install ```sh pnpm add @waylovoice/client ``` The page code needs a bundler (Vite, esbuild, webpack…) to resolve the import. ## Backend: the session endpoint One route on the site's backend. It checks the site's own login, then calls the Waylo API with the API key and returns the response body unchanged. - The SDK sends `POST {sessionEndpoint}` with JSON `{ "agentId": string, "metadata"?: object }` and the site's cookies. - Answer `200` with the body of `POST {WAYLO_API_URL}/sessions/web` and `Cache-Control: no-store`; `401` when not logged in; `403` when the visitor may not use the agent; `429` when rate limited. Never redirect. - The Waylo request: `POST {WAYLO_API_URL}/sessions/web`, headers `Authorization: Bearer {WAYLO_API_KEY}` and `Content-Type: application/json`, body `{ "workspaceId", "agentId", "metadata"? }`. The key needs the `sessions:write` scope. ```js // Node 18+ with Express 4. Reads WAYLO_API_URL, WAYLO_API_KEY, WORKSPACE_ID, AGENT_ID and PORT from the environment. import express from 'express'; const app = express(); // The agent id is not secret; a page may also hard-code it. app.get('/api/agent', (request, response) => response.json({ agentId: process.env.AGENT_ID })); app.post('/api/waylo/session', express.json(), async (request, response) => { // Check your own login here; answer 401 when there is none. const { agentId, metadata } = request.body ?? {}; if (agentId !== process.env.AGENT_ID) return response.status(403).json({ error: 'access_denied' }); const upstream = await fetch(`${process.env.WAYLO_API_URL}/sessions/web`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.WAYLO_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ workspaceId: process.env.WORKSPACE_ID, agentId, metadata }), }).catch(() => undefined); // A Waylo 401/403 is about your key, not the visitor: log it and answer 502. if (!upstream?.ok) return response.status(upstream?.status === 429 ? 429 : 502).end(); response.set('Cache-Control', 'no-store').type('application/json'); response.status(200).send(await upstream.text()); }); app.use(express.static('public')); // the page and its bundled script app.listen(Number(process.env.PORT ?? 3000)); ``` ## Page Markup: a button `#talk`, a paragraph `#status`, a hidden button `#resume` and a list `#captions`. Bundle the script (e.g. `esbuild src/main.ts --bundle --format=esm --outfile=public/main.js`) and load it with ``. ```ts import { WayloVoice, WayloVoiceError, type WayloVoiceStage, type WayloVoiceState, } from '@waylovoice/client'; const AGENT_ID = 'your-agent-id'; // or load it from GET /api/agent before the click const talkButton = document.querySelector('#talk')!; const resumeButton = document.querySelector('#resume')!; const statusText = document.querySelector('#status')!; const captions = document.querySelector('#captions')!; let wayloVoice: WayloVoice | undefined; const STATUS: Record = { idle: '', connecting: 'Calling the agent…', joining: 'The agent is joining…', preparing: 'The agent is getting ready…', speaking: 'The agent is speaking', listening: "Go ahead, I'm listening", thinking: 'Thinking…', reconnecting: 'Reconnecting…', ended: 'Call ended', }; function isLive(state: WayloVoiceState): boolean { return state.connection !== 'idle' && !state.end; } function render(state: WayloVoiceState): void { talkButton.textContent = isLive(state) ? 'Hang up' : 'Talk'; resumeButton.hidden = state.playback !== 'blocked'; if (state.end?.status === 'failed') { statusText.textContent = `Call failed: ${state.end.error.code}`; } else if (state.end) { statusText.textContent = `Call ended (${state.end.reason ?? 'done'})`; } else { const weak = state.network === 'poor' ? ' · Your connection is weak, you may hear gaps' : ''; statusText.textContent = STATUS[state.stage] + weak; } } talkButton.addEventListener('click', () => { if (wayloVoice && isLive(wayloVoice.getState())) { void wayloVoice.stop(); return; } captions.replaceChildren(); wayloVoice = new WayloVoice({ sessionEndpoint: '/api/waylo/session' }); wayloVoice.on('state', render); wayloVoice.on('transcript', line => { let row = document.getElementById(line.id); if (!row) { row = document.createElement('li'); row.id = line.id; captions.append(row); } row.textContent = `${line.speaker}: ${line.text}`; }); wayloVoice.on('error', error => console.warn(error.code)); // A failed start() also ends with state.end; render() shows it, so the rejection needs no handling. wayloVoice.start({ agentId: AGENT_ID }).catch((error: unknown) => { if (error instanceof WayloVoiceError) console.warn(error.code); }); }); resumeButton.addEventListener('click', () => void wayloVoice?.resumeAudio()); ``` Lifecycle: a new instance's state is `connection: 'idle'` with no `end`. `start()` sets `connection: 'connecting'` before it returns, so a second click sees a live call. `stop()` never rejects; after it the last state has `end: { status: 'completed', reason: 'caller_hangup' }` (or `failed` with `CANCELLED` if the call was still starting). A failed `start()` rejects and ends with `state.end`; it does not also emit `error`. ## API `new WayloVoice(options)` — throws `RangeError` for invalid options. Safe to create on page load. | Option | Meaning | | --- | --- | | `sessionEndpoint` | Required. Same-origin URL of the backend session endpoint. | | `headers` | Optional. `() => Record` or a promise of one; extra headers for the session endpoint, e.g. `Authorization`. | | `agentTimeoutMs` | Optional. How long `start()` waits for the agent. Default 20000. | | Member | Does | | --- | --- | | `start({ agentId, metadata? })` | Joins the call; resolves once the agent is ready. Rejects with `WayloVoiceError`. Once per instance. | | `stop()` | Hangs up and releases everything. Safe at any time; during `start()` it makes `start()` reject with `CANCELLED`. | | `getState()` | The current state snapshot (frozen). | | `getMicrophoneLevel()` | The visitor's live mic level, 0 to 1; 0 unless `state.microphone` is `'on'`. Not in `state`: poll it with `requestAnimationFrame` to draw a meter. | | `on(event, listener)` | Adds a listener; returns a function that removes it. | | `off(event, listener)` | Removes a listener. | | `setMuted(muted)` | Mutes or unmutes the microphone; works before `start()`. | | `setVolume(volume)` | Agent volume, 0 to 1. | | `resumeAudio()` | Call from a click when `state.playback` is `'blocked'`. | | `setMicrophoneDevice(deviceId)` | Uses this microphone, before or during the call. | | `setSpeakerDevice(deviceId)` | Uses this speaker; rejects `SPEAKER_UNAVAILABLE` where the browser can't choose one. | | `WayloVoice.listDevices()` | Static. `{ microphones, speakers, canChooseSpeaker }`; each device is `{ deviceId, label }`. | ## Events | Event | Payload | | --- | --- | | `state` | The whole state snapshot, on every change. The last one has `end`; nothing is emitted after it. | | `transcript` | `{ id, speaker: 'agent' \| 'user', text, final }`. Sent again as the line grows: replace the row with the same `id`. | | `error` | A `WayloVoiceError` for a problem after `start()` resolved, or one with no promise to reject. | ## State | Field | Values | | --- | --- | | `stage` | `'idle'`, `'connecting'`, `'joining'`, `'preparing'`, `'speaking'`, `'listening'`, `'thinking'`, `'reconnecting'`, `'ended'`: which moment of the call it is. Use it for the status line (see Status line). | | `connection` | `'idle'`, `'connecting'`, `'connected'`, `'reconnecting'`, `'disconnected'`. `'reconnecting'` starts at once when the browser goes offline, after 8 s with no data, or within 2 s of LiveKit starting to reconnect, and lasts at least 1 s. After 45 s of it the call fails with `CONNECTION_LOST`. | | `agent` | `'none'`, `'joining'`, `'listening'`, `'thinking'`, `'speaking'` | | `microphone` | `'off'`, `'on'`, `'muted'` | | `network` | `'good'`, `'poor'`, `'lost'`: this browser's connection to the call; `'good'` outside a call. LiveKit's own `'poor'` or `'lost'` counts once it lasts 2 s. Also `'poor'` while `connection` is `'reconnecting'`, or after 3 s with no data arriving or 2 s with the browser's network path (ICE) `disconnected`, until data flows again for 1 s. Show a non-blocking banner while it is not `'good'`. | | `playback` | `'stopped'`, `'playing'`, `'blocked'` (show a button that calls `resumeAudio()`) | | `volume` | Last `setVolume()` value, default 1 | | `sessionId` | The Waylo session id, once known | | `microphoneDeviceId` | The chosen microphone, or the one in use once the call has it; unset or `'default'` means the system default | | `speakerDeviceId` | The chosen speaker, or the one in use once the call has it; unset or `'default'` means the system default | | `timings` | How long each step took (see Timings) | | `end` | Set once at the end: `{ status: 'completed', reason? }` or `{ status: 'failed', reason?, error }` | `end.reason` is one of `caller_hangup`, `caller_inactive`, `caller_goodbye`, `caller_not_joined`, `agent_end`, `busy`, `no_answer`, `rejected`, `cancelled`, `provider_error`, `provider_key_failed`, `worker_error`, `max_duration`, `timeout`. ## Status line The SDK shows no text; map `state.stage` to words. Recommended wording (`{agent}` is the agent's name, or "the agent"): | `stage` | Status text | | --- | --- | | `idle` | nothing, or the Talk button | | `connecting` | Calling {agent}… (session and room) | | `joining` | {agent} is joining… (in the call, waiting for the agent) | | `preparing` | {agent} is getting ready… (the agent is ready but hasn't spoken yet) | | `speaking` | {agent} is speaking | | `listening` | Go ahead, I'm listening | | `thinking` | Thinking… | | `reconnecting` | Reconnecting… | | `ended` | Call ended, or the failure in `state.end` | While `state.network` is `'poor'`, also show "Your connection is weak, you may hear gaps". ## Devices ```ts const devices = await WayloVoice.listDevices(); const microphone = devices.microphones[0]; if (microphone) await wayloVoice?.setMicrophoneDevice(microphone.deviceId); ``` - `listDevices()` never asks for permission. Until the visitor allows the microphone, lists are empty or unnamed: list again when `state.microphone` becomes `'on'` and on `navigator.mediaDevices` `devicechange`. - `listDevices({ requestPermission: true })` asks for the microphone first (call it from a click), closes it again and returns named devices; it rejects `MICROPHONE_DENIED` if the visitor blocks it. - `canChooseSpeaker` is false on Safari and every iOS browser; `speakers` is then empty. Hide the speaker picker whenever `speakers` is empty (Firefox before 140 lists none). - Setters work before `start()`; a chosen device that is gone by then falls back to the system default, and state shows the device in use. - Mid-call, a mic that can't be opened rejects with `MICROPHONE_UNAVAILABLE` (`MICROPHONE_IN_USE` when another app holds it, `MICROPHONE_DENIED` when the browser blocks it) and the call keeps a working mic where it can. A chosen mic or speaker stays until it is unplugged; the call then moves to the system default (`'default'`). On `'default'`, Chrome and other Chromium browsers follow system default changes; Firefox and Safari keep the open mic until it is unplugged. An unplugged muted mic stays `'muted'` and opens the system default on unmute. A mid-call `error` event `MICROPHONE_UNAVAILABLE` means no mic is left or none could be opened, muted or not; `microphone` is then `'off'`. ## Timings `state.timings` is unset before `start()`. Then it holds `startedAt` (`Date.now()` when `start()` was called, i.e. the click) and, in whole milliseconds after `start()`, each step as it is reached, with a `state` event each time: - `sessionReadyMs`: the session endpoint answered. - `connectedMs`: this browser joined the call. - `microphoneOpenMs`: the browser opened the microphone, permission prompt included. It opens while the call connects, so the time the visitor takes to allow it overlaps the earlier steps. - `microphoneReadyMs`: the microphone is live in the call, so the agent can hear the visitor. - `agentReadyMs`: the agent reported its first activity state; `start()` resolves once the microphone is ready too. - `agentSpeakingMs`: the agent starts speaking while `playback` is `'playing'`; its audio arrives a few hundred milliseconds later. If audio was blocked, it is set once `resumeAudio()` lets it play while the agent speaks, or at the agent's next turn. This is the visitor's wait to hear the agent. A step not reached is unset. Steps can be reached out of order: the agent can be ready before the microphone. `state.timings` is for logging and monitoring; don't show it to visitors, show the call status instead. ```ts wayloVoice?.on('state', state => { if (state.timings?.agentSpeakingMs !== undefined) console.info('agent speaking after', state.timings.agentSpeakingMs); }); ``` ## Errors `WayloVoiceError` has a fixed `code`. `start()` rejects with it; later problems arrive as `error` events and in `state.end.error`. | Code | What happened | What to tell the visitor | Try again? | | --- | --- | --- | --- | | `AUTH_REQUIRED` | The session endpoint answered 401 or redirected. | "Please sign in to talk." | After signing in | | `ACCESS_DENIED` | The session endpoint answered 403. | "You can't use this assistant." | No | | `LIMIT_REACHED` | The session endpoint answered 429. | "Too many calls right now. Try again in a minute." | Yes, later | | `INVALID_SESSION` | The session endpoint's 200 body is not a Waylo session; return it unchanged. | "Something went wrong. Please try again later." | No, fix the endpoint | | `SESSION_EXPIRED` | The session expired or was rejected. | "Please try again." | Yes, new call | | `CONNECTION_FAILED` | The endpoint failed or took over 15 s, the call could not connect, or the call server ended it with an error. | "Couldn't connect. Check your internet and try again." | Yes | | `CONNECTION_LOST` | The visitor's connection dropped mid-call and the call could not continue: reconnecting failed, took over 45 s, or found no agent within 5 s of reconnecting. | "Your connection dropped. Check your internet and start a new call." | Yes, new call | | `MICROPHONE_DENIED` | The visitor or the browser blocked the microphone. | "Allow the microphone in your browser, then try again." | After allowing it | | `MICROPHONE_IN_USE` | Another app or tab holds the microphone, or the system could not start it. | "Your microphone is busy. Close other apps using it, then try again." | Yes | | `MICROPHONE_UNAVAILABLE` | No usable microphone, the chosen one can't be opened, or the browser refused it for another reason. The original error is in `cause`. | "No working microphone found. Connect one, then try again." | Yes, after fixing it | | `SPEAKER_UNAVAILABLE` | The chosen speaker can't be used or the browser can't choose one; the current speaker keeps playing. | Nothing; hide or reset the speaker picker. | Not needed | | `AGENT_TIMEOUT` | The agent wasn't ready in time. | "The assistant isn't available right now. Try again." | Yes | | `AGENT_LEFT` | The agent left without ending the call. | "The call dropped. Start a new one." | Yes, new call | | `AGENT_ERROR` | The agent hit an error and ended the call; `state.end.reason` says why. | "Something went wrong. Try again." | Yes, new call | | `TRANSCRIPT_FAILED` | One caption line couldn't be read; the call goes on. | Nothing. | Not needed | | `ALREADY_STARTED` | `start()` was called twice on one instance; a code bug. | Nothing; create a new `WayloVoice` per call. | Not needed | | `CANCELLED` | `stop()` was called while starting. | Nothing. | Not needed | ## Common mistakes - Putting the API key, or a call to the Waylo API, in browser code. - Reusing a `WayloVoice` for a second call (`ALREADY_STARTED`). - Calling `start()` after an `await` in the click handler (audio may be blocked; handle `playback: 'blocked'`). - A session endpoint on another domain (the constructor throws), or one that changes the Waylo response. - Opening the page over plain http other than `localhost`: browsers then give no microphone. ## Optional - [Web SDK docs](https://conversational-wayloai.github.io/developer-docs/docs/web-sdk.md): full guide, endpoint contract, security notes. - [Full example](https://conversational-wayloai.github.io/developer-docs/docs/web-sdk/full-example.md): a working page and Node server.