Programmatic web calls
Guide: Programmatic web calls
This recipe runs an agent voice conversation in your own browser UI: your backend mints a session with an API key, hands the browser a short‑lived WebSocket URL, and the browser streams audio both ways. If you don't need a custom UI, the embeddable widget does all of this for you. If you only need text, the Chat API is far simpler (plain REST, no socket).
Shortcut: the Web & React SDK implements everything below (audio pipeline, reconnect, barge-in) — pass it the minted session and you're done. This guide documents the raw protocol for anyone building the pipeline by hand.
Anonymous variant: to run this for the public widget without an API key, replace step 1 with
POST /api/public/widget/{slug}/session— see the widget API. Everything from the WebSocket on is identical.
Architecture
Browser ──(1) ask for session──▶ Your backend ──(2) init-web-call (x-api-key)──▶ Telenow
Browser ◀─(3) websocketUrl────── Your backend ◀──── { sessionId, websocketUrl } ──
Browser ──(4) WebSocket audio ⇄ Telenow ────────────────────────────────────────
The API key never reaches the browser — only the short‑lived websocketUrl (which carries a scoped token) does.
1. Mint a session on your server
// server.js (Node)
app.post('/voice/session', async (req, res) => {
const name = req.body?.name ?? 'there';
const r = await fetch('https://api.telenow.ai/api/sessions/init-web-call', {
method: 'POST',
headers: {
'x-api-key': process.env.TELENOW_API_KEY, // server-side only
'Content-Type': 'application/json',
},
body: JSON.stringify({
agentId: process.env.AGENT_ID,
variables: { name }, // fills {name} in the prompt/opener
firstResponse: `Hi ${name}! How can I help?`, // optional: a personalized opener for THIS session
}),
});
const { data } = await r.json();
// Forward only what the browser needs.
res.json({ sessionId: data.sessionId, websocketUrl: data.websocketUrl });
});
firstResponseoverrides the agent's saved opening line for this session only — the agent speaks it first, before the visitor says anything. Omit it to use the agent's configured opener. It's the same field as on the phone-call API, and you can referencevariablesinside it ("Hi {name}!").
2. Connect the WebSocket from the browser
Messages are JSON frames with an event field. Audio is base64‑encoded μ‑law, 8 kHz, mono in ~20 ms frames. See the Web‑call API for the full message catalog.
const { sessionId, websocketUrl } = await fetch('/voice/session', { method: 'POST' }).then(r => r.json());
const ws = new WebSocket(websocketUrl);
ws.onmessage = (evt) => {
const msg = JSON.parse(evt.data);
switch (msg.event) {
case 'ready':
ws.send(JSON.stringify({ event: 'start', sessionId }));
break;
case 'connected':
startMic(ws); // begin streaming microphone frames
break;
case 'media':
playFrame(msg.data, msg.format, msg.sampleRate); // agent audio → speakers
break;
case 'transcript':
renderTurn(msg.role, msg.text, msg.isFinal); // optional live captions
break;
case 'ping':
ws.send(JSON.stringify({ event: 'pong', t: msg.t })); // keepalive — must echo `t`
break;
case 'session_end':
ws.close();
break;
}
};
Echo every
pingwith a matchingpong(samet). If you don't, the server may treat the socket as idle and drop the call. The SDK handles this for you.
3. Send microphone audio
Capture mic audio with the Web Audio API, downsample to 8 kHz, encode to μ‑law, base64‑encode, and send as media frames:
function sendFrame(ws, mulawBytes) {
ws.send(JSON.stringify({
event: 'media',
data: btoa(String.fromCharCode(...mulawBytes)), // base64 μ-law 8k
}));
}
Implementing the PCM→μ‑law 8 kHz conversion and ~20 ms framing is the bulk of the work. If you'd rather not, the hosted widget already ships a tuned implementation (with barge‑in and jitter handling) — embed it and skip this entirely.
4. Handle the end
On session_end, stop the microphone, close the socket, and tear down your audio nodes. The finished call appears in call history and fires call.ended (and later recording.ready if recording is enabled) to your webhooks.
Manual (bridge) mode
Pass mode: "manual" with fromNumber and toNumber to init-web-call to bridge a human browser session to a carrier call instead of talking to the AI — this is how the dashboard dialer works.