Web & React SDK
Run agent voice calls in the browser with your own UI. @telenow/client is
framework-agnostic; @telenow/react adds a hook on top of it. Both handle the
microphone, echo/noise suppression, jitter-buffered playback, barge-in,
auto-reconnect, transcripts, and latency telemetry — you render buttons and
text.
npm i @telenow/client # vanilla / any framework
npm i @telenow/react # + React hook (peer-depends on @telenow/client)
Before you start
- An agent — create one in the dashboard (Agents → New agent) and copy its agent ID.
- A way to authorize the call (pick one):
- Backend-minted session — recommended. Your server calls
createWeb/init_web_callwith an org API key and returns{ sessionId, websocketUrl }to the browser. The key never leaves your server. - Public slug — publish the agent (Publish tab),
pass
publicSlug. No backend needed; rate-limited, optional access code. - Client token — short-lived agent-scoped token passed as
token.
- Backend-minted session — recommended. Your server calls
- A secure context — browsers expose the mic only on
https://(orlocalhost), andstart()must be triggered by a user gesture (click) or autoplay policies will block audio.
Quickstart — vanilla JS (TelenowCall)
import { TelenowCall } from '@telenow/client';
// Recommended: fetch a session minted by YOUR backend (no credential here).
const session = await fetch('/voice/session', { method: 'POST' }).then((r) => r.json());
const call = new TelenowCall({
session, // or publicSlug: 'my-agent' / token: '…'
baseUrl: 'https://api.telenow.ai', // used only when the SDK does its own init
onState: (s) => render(s), // idle|connecting|live|reconnecting|ended|error
onTranscript: (t) => addLine(t.role, t.text, t.isFinal),
onLevel: (dbfs) => meter(dbfs), // mic level per 20 ms frame — drive a VU meter
onError: (msg) => showError(msg),
});
startButton.addEventListener('click', () => call.start()); // mic permission → live
During the call:
call.setMuted(true); // mic on/off
call.sendText('Use my work email'); // typed turn → spoken reply
call.sendText('What are your hours?', { chat: true }); // typed turn → text-only reply
call.sendContext('Cart: 1× blue kurta (M)', { key: 'cart' }); // a note, never a turn — see below
call.sendActivity(); // the user is still here, just busy
call.stop(); // hang up locally
call.sessionId; // hand to your backend for transfer/end (server SDK)
When the agent ends the call (or your backend calls calls.end()), the
SDK receives the end event, tears down audio, and fires onState('ended') —
you never touch protocol events.
Quickstart — React (useVoiceCall)
import { useVoiceCall } from '@telenow/react';
function CallPanel({ session }) {
const { state, transcript, muted, error, start, stop, mute, sendText } = useVoiceCall({
session, // or publicSlug / token — same three options
});
return (
<div>
<button onClick={state === 'live' ? stop : start} disabled={state === 'connecting'}>
{state === 'live' ? 'End call' : state === 'connecting' ? 'Connecting…' : 'Start call'}
</button>
{state === 'live' && <button onClick={() => mute(!muted)}>{muted ? 'Unmute' : 'Mute'}</button>}
{state === 'reconnecting' && <span>Reconnecting…</span>}
{error && <p role="alert">{error}</p>}
<ul>
{transcript.map((t, i) => (
<li key={i} style={{ opacity: t.isFinal ? 1 : 0.6 }}>
<b>{t.role}:</b> {t.text}
</li>
))}
</ul>
</div>
);
}
The hook tears the call down on unmount. It returns
{ state, transcript, muted, error, start, stop, mute, sendText, sendContext, sendActivity }.
Options reference
| Option | Type | Default | What it does |
|---|---|---|---|
session | { sessionId, websocketUrl } | — | Pre-minted session from your backend. Skips init entirely. |
publicSlug | string | — | Published-agent slug (no auth). |
token | string | — | Ephemeral client token (Authorization: Bearer). |
baseUrl | string | same-origin | API origin for init. |
variables | Record<string,string> | — | Context variables. Required ones must be present or init fails with 400. With a minted session, pass them server-side instead — tamper-proof. |
audio.encoding | 'mulaw' | 'pcm16' | 'mulaw' | Uplink wire format — keep the default, it's what the platform decodes. |
audio.echoCancellation | boolean | true | Browser AEC — keep on for two-way audio. |
audio.noiseSuppression | boolean | true | Browser noise suppression. |
audio.autoGainControl | boolean | true | On by default (SDK ≥0.1.5) — without AGC, typical laptop mics sit below the barge-in detection gate, so callers can't interrupt the agent mid-speech. Set false only for pre-levelled/broadcast inputs. |
audio.deviceId | string | system default | Pick a specific microphone. |
reconnect | { maxAttempts?, baseDelayMs?, maxDelayMs?, jitter? } | 6 / 500 / 10000 / 0.3 | Exponential backoff after network drops. |
autoActivity | boolean | false | Tell the agent the user is still here while they type or click, so it doesn't ask "are you still there?" mid-form. Paced by the agent's own check-in delay: one ping per window, none for an idle user. |
TelenowCall additionally accepts callbacks (onState, onTranscript,
onLevel, onError); the React hook returns the same data as state instead.
Notes and activity from your page
Your page knows things the agent can't hear — what the user is looking at, what is in their cart, how far they got in a form. Send them as notes:
await call.sendContext('Viewing: blue kurta, ₹1,299, sizes M and L', { key: 'page' });
- A note is never a turn: the agent doesn't answer it. It uses the note from its next reply, so when the user asks "is this available in L?" it knows what "this" is.
keyreplaces an earlier note from your page with the same key — send page or cart state under one key. It never replaces a note your backend sent.respond: 'when_idle'asks the agent to tell the user: it speaks up once the line is free, never over them (call.sendContext('Payment confirmed', { respond: 'when_idle' })). In chat mode it works like'none': the agent's next reply tells the user.- The agent must accept notes from the caller's app: "Notes from the caller's app" in the
builder (Call handling), or
sessionConfig.liveContext.acceptClientNotes: truethrough the agents API. It is off by default. The agent is always told these notes come from the user's own app and prove nothing. sendContextresolves with'next_turn','speaking_now'(when_idleinto a free line) or'held'(the agent is on hold, or a transfer is under way), and rejects with aTelenowContextErrorwhosereasonis, from the server,disabled,too_large(withmaxChars),rate_limited,empty,engine_unsupported,no_agent,not_liveorinvalid_respond; from the SDK,not_connected,connection_lost,call_endedorunsupported_transport.maxCharscounts Unicode code points: trim withArray.from(text).slice(0, maxChars).join(''). (The SDK repairs half an emoji left by a plainslicebefore sending, so the reply still arrives — but the emoji is lost.)- Notes and activity travel over the WebSocket transport; on a WebRTC call they reject with
unsupported_transportfor now.
When the user goes quiet because they are busy on the page, call.sendActivity() tells the
agent they are still there; it resolves with how long until the agent would speak up
unprompted, or null when nothing is armed right now (a hold, a wait being acknowledged). Or
set autoActivity: true and the SDK does it while the user types or clicks: one ping per
check-in window, timed to land before the window runs out, and none for a user who has stopped.
After a null, it waits until the conversation moves on before pinging again. Everything about
notes and activity: Live context notes.
Notes from your backend use the server SDK instead (calls.sendContext) — they work on
phone calls too and need no agent setting. See Sessions & calls.
Use as a softphone (manual telephony)
The same session option also powers a human softphone for click-to-call
in a CRM. When your backend mints a
manual call
(tn.calls.createManual({ from, to }) — no AI in the loop), it returns the same
{ sessionId, websocketUrl }. Pass it to TelenowCall({ session }) (or
useVoiceCall) and the browser becomes the rep's microphone + speaker,
bridged to the customer over the carrier. Controls are identical
(setMuted, stop, sessionId for transfer/end); there are no agent
transcripts, since a person is talking rather than the AI.
What happens under the hood
- Uplink —
getUserMedia→ AudioWorklet (ScriptProcessor fallback) → resample → G.711 μ-law 8 kHz → 20 ms base64 frames over the WebSocket. - Downlink — agent audio (up to 24 kHz PCM) scheduled through an adaptive jitter buffer (depth self-tunes 60–400 ms) with fade-in concealment — no clicks on bad Wi-Fi.
- Barge-in — when the caller talks over the agent, queued audio is flushed instantly.
- Reconnect — drops re-dial with backoff and re-attach to the same
session; UI sees
reconnecting→live. - Latency — the SDK answers server pings, so analytics show the real web⇄server round-trip for your calls.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
getUserMedia is unavailable | Not https:// (or localhost), or mic permission blocked. |
start() rejects 401/403 | Bad/expired token, or the agent's API access is off (Publish tab). |
| 400 naming a variable | A required context variable is missing. |
| Connects, no audio | start() wasn't called from a user gesture (autoplay policy). |
| Agent hears itself | echoCancellation was disabled — leave it on. |
Stuck reconnecting → ended | Network down or session expired; start a new call. |
Going lower-level
All building blocks are exported (CaptureEngine, PlaybackEngine,
ReconnectingSocket, the jitter buffer, μ-law/PCM codecs) and the raw
WebSocket protocol is documented in
Programmatic web calls and the
Web-call API. Your agent's Publish tab shows these
exact snippets with your agent id pre-filled.