Cookbooks · In your product
A talk button for your website
A button on your site that a visitor presses to talk to your agent out loud, with the conversation written out underneath as it happens. Three small files: a server route, a page and a script.
Before you start
- An agent, and its ID. The Quickstart makes one.
- A workspace API key in the
CRACKLE_API_KEYenvironment variable. See Authentication. - Node.js 18 or later, with
"type": "module"inpackage.json, andnpm install express.
Start calls from your server
The page cannot hold your API key, so it asks your server for a call and your server asks Crackle. Only the address of the room and a join token go back to the page.
server.jsimport { randomUUID } from 'node:crypto'; import express from 'express'; const app = express(); app.use(express.static('public')); // The page asks this server for a call; this server asks Crackle. The key never reaches the page. app.post('/api/call', async (req, res) => { const response = await fetch('https://goblins-call-api.onrender.com/v1/calls', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRACKLE_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ agentId: process.env.CRACKLE_AGENT_ID, participant: { identity: `web_${randomUUID().slice(0, 8)}` }, // A visitor who wanders off should not hold a line open all afternoon. config: { call: { maxDurationSeconds: 600 } }, }), }); const body = await response.json(); if (!response.ok) { console.error('Could not start a call:', body.code, body.error); // 402 is an empty balance and 429 a full line. To the visitor both mean "not right now". return res.status(response.status === 402 || response.status === 429 ? 503 : 502).end(); } res.json({ callId: body.callId, livekitUrl: body.livekitUrl, token: body.token }); }); app.listen(3000, () => console.log('http://localhost:3000'));Terminalexport CRACKLE_API_KEY="ck_your_key_here" export CRACKLE_AGENT_ID="ua_your_agent" node server.jsAdd the button
A button, a line for what the agent is doing, and somewhere for the words to go.
public/index.html<button id="talk">Talk to us</button> <p id="status" aria-live="polite"></p> <div id="captions"></div> <script type="module" src="/widget.js"></script>Join the call and show what is said
The script joins the room, sends the microphone and plays the agent’s voice. The agent also sends two kinds of message alongside the audio: what it is doing (listening, thinking, speaking) and the words of the conversation.
public/widget.jsimport { Room, RoomEvent } from 'https://cdn.jsdelivr.net/npm/livekit-client@2/+esm'; const button = document.querySelector('#talk'); const status = document.querySelector('#status'); const captions = document.querySelector('#captions'); const decoder = new TextDecoder(); const STATES = { listening: 'Listening', thinking: 'Thinking…', speaking: 'Speaking' }; let room = null; let line = null; button.addEventListener('click', () => (room ? room.disconnect() : start())); async function start() { button.disabled = true; status.textContent = 'Connecting…'; try { const response = await fetch('/api/call', { method: 'POST' }); if (!response.ok) { throw new Error(response.status === 503 ? 'All our lines are busy. Please try again in a minute.' : 'The call could not be started.'); } const session = await response.json(); room = new Room(); room.on(RoomEvent.TrackSubscribed, (track) => { if (track.kind === 'audio') document.body.appendChild(track.attach()); }); room.on(RoomEvent.TrackUnsubscribed, (track) => track.detach().forEach((element) => element.remove())); room.on(RoomEvent.DataReceived, onMessage); room.on(RoomEvent.Disconnected, ended); await room.connect(session.livekitUrl, session.token); await room.localParticipant.setMicrophoneEnabled(true); button.textContent = 'Hang up'; status.textContent = 'Connected'; } catch (error) { await room?.disconnect(); ended(); status.textContent = error.name === 'NotAllowedError' ? 'The microphone is blocked for this page.' : error.message; } finally { button.disabled = false; } } function ended() { room = null; line = null; button.textContent = 'Talk to us'; status.textContent = 'Call ended'; } function onMessage(payload, _participant, _kind, topic) { if (topic !== 'goblins-transcript') return; const message = JSON.parse(decoder.decode(payload)); if (message.type === 'state') status.textContent = STATES[message.state] || ''; else if (message.role) caption(message); } const squash = (text) => text.replace(/\s+/g, ' ').trim(); // The same words can arrive more than once: a reply comes a sentence at a time and then once more // whole, and the caller's words are sent again as the transcript settles. A message that starts // like the line on screen, or the other way round, is that line again and not a new one. function caption({ role, text, final }) { const sameSpeaker = line?.dataset.role === role; const open = line?.dataset.open === 'true'; const shown = sameSpeaker ? squash(line.textContent) : ''; const next = squash(text); if (sameSpeaker && (next.startsWith(shown) || shown.startsWith(next) || (open && role === 'user'))) { line.textContent = next; } else if (sameSpeaker && open) { line.textContent = `${shown} ${next}`; // the agent's next sentence } else { line = document.createElement('p'); line.dataset.role = role; line.textContent = next; captions.append(line); } line.dataset.open = String(!final); captions.scrollTop = captions.scrollHeight; }The import pulls LiveKit’s client straight from a CDN, so there is nothing to build. If your site has a bundler,
npm install livekit-clientand import from'livekit-client'instead.
Try it
Open http://localhost:3000, press the button and allow the microphone. The agent greets you, the status line follows it between listening and speaking, and both sides of the conversation appear as they are said. Press the button again to hang up. The call is then in the studio under Call logs, with its transcript.
Why the captions need care
Caption messages are made for showing words as early as possible, so the same words reach you more than once. A reply arrives one sentence at a time, each marked final: false until the last, and then once more as the whole reply. If the caller cuts the agent off, that last message holds only what was actually said. The caller’s words can also be sent again as the transcript settles. The caption function above keeps one line per turn through all of that. For the record of a call, use its transcript from the API or a webhook, not the captions.
Before you put it on a public page
- Start the call from the button press, as here. Browsers hold a page’s sound back until the visitor has clicked or tapped.
- Tell the agent about the visitor by sending variables from your server: their name, the page they are on, what is in their basket.
- Put your own session ID in
metadataso the call’s webhook can be matched to the visitor afterwards.