Embed a voice and chat agent on your site

Create a widget, lock it to your domains, ship the embed, build a custom UI, add browser voice and take over as a human.
View as Markdown

In this tutorial you put one of your Jelliu agents on a website, first with the ready-made widget and then with your own interface, and you add voice so visitors can talk to it from the browser. At the end you reply to a visitor as a human operator from your own backend.

You will build:

  • A widget restricted to your site’s origins, with voice enabled.
  • The one-line embed, tested locally.
  • A custom chat page on the web chat runtime API, with realtime replies.
  • A voice button that connects over WebRTC and falls back to WebSocket.
  • A server script that replies into the visitor’s chat as an operator.

Time: about 30 minutes.

Before you start

You needWhy
A workspace API key with the write scopeCreating and updating widgets, and operator replies. See API keys.
An agent in the workspaceThe widget answers with it. Create one in the dashboard or with the Agents API.
A plan with AI message allowanceEvery web chat reply counts against it. See Billing and usage.
Node.js 18+ or Python 3.9+For the setup scripts.
A local static serverAny will do, for example npx serve -l 8080 or python -m http.server 8080.
export JELLIU_API_KEY="jl_..."

How the pieces fit

The browser only ever holds the public widget ID. Your workspace API key and the widget’s secret API key stay on your server.

1

Pick the agent

List your agents and copy the id of the one that should answer visitors.

curl -sS "https://api.jelliu.co/api/agents?limit=20" \
-H "Authorization: Bearer $JELLIU_API_KEY"

Expected output:

7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4 Recepción Clínica Norte
export JELLIU_AGENT_ID="7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4"
2

Create the widget

Create a widget with voice enabled. List every origin the chat will run on: your production site and, for this tutorial, your local server. Origins are compared by exact scheme and port, and one leading www. is ignored, so https://example.com also covers https://www.example.com. Wildcards are not accepted.

curl -sS -X POST "https://api.jelliu.co/api/widgets" \
-H "Authorization: Bearer $JELLIU_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"agent_id\": \"$JELLIU_AGENT_ID\",
\"name\": \"Website chat\",
\"allowed_origins\": [\"https://www.example.com\", \"http://localhost:8080\"],
\"voice_enabled\": true,
\"greeting_message\": \"¡Hola! ¿En qué te puedo ayudar?\",
\"branding\": {
\"primary_color\": \"#2563EB\",
\"position\": \"bottom-right\",
\"title\": \"Clínica Norte\",
\"quick_replies\": [\"Agendar cita\", \"Horarios\"]
}
}"

Expected output:

Widget ID: d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f70
Widget API key: 3f9a0c1d2e4b5a6978c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2 (shown once, store it now)
Embed: <script src="https://api.jelliu.co/widget-embed/d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f70/embed.js" async></script>
export JELLIU_WIDGET_ID="d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f70"
export JELLIU_WIDGET_API_KEY="3f9a0c1d..."

plaintext_api_key is returned only in this response; Jelliu keeps a keyed hash. You do not need it for the embed or for a browser UI, only to call the widget endpoints from a server. If you lose it, create a new widget.

3

Embed the widget and test it locally

Create index.html in an empty folder:

index.html
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Clínica Norte</title>
</head>
<body>
<h1>Clínica Norte</h1>
<script src="https://api.jelliu.co/widget-embed/YOUR_WIDGET_ID/embed.js" async></script>
</body>
</html>

Replace YOUR_WIDGET_ID, serve the folder on port 8080 and open http://localhost:8080:

npx serve -l 8080

A round launcher appears in the bottom-right corner. Open it, send “Horarios”, and the agent answers. The same conversation shows up in the dashboard under Conversations with the channel webchat.

The embed script is cached for up to 5 minutes, so branding changes can take that long to appear.

4

Confirm the origin lock works

Call init twice from your terminal, once declaring an allowed origin and once a foreign one. This is exactly the check a browser page on another site would fail.

# Allowed origin
curl -sS -o /dev/null -w "%{http_code}\n" -X POST "https://api.jelliu.co/widget/init" \
-H "x-widget-id: $JELLIU_WIDGET_ID" \
-H "x-widget-parent-origin: http://localhost:8080" \
-H "Content-Type: application/json" \
-d '{"visitor_id":"origin-check-1"}'
# Foreign origin
curl -sS -X POST "https://api.jelliu.co/widget/init" \
-H "x-widget-id: $JELLIU_WIDGET_ID" \
-H "x-widget-parent-origin: https://attacker.example" \
-H "Content-Type: application/json" \
-d '{"visitor_id":"origin-check-2"}'

Expected output:

201
{"error":{"code":"FORBIDDEN","message":"Origin not allowed"}}

The origin header can be forged outside a browser, so this protects you from other sites embedding your chat, not from scripted abuse. The per-IP, per-widget, per-workspace and daily limits cover that; see Web chat limits.

5

Build your own chat UI

When the standard panel does not fit your design, drive the same runtime API from your page. The complete, tested client (session resume, history, WebSocket with polling fallback, deduplication) is in Build a custom chat UI. Save it as chat.html next to index.html, set WIDGET_ID, and open http://localhost:8080/chat.html.

The flow it implements:

MomentCallWhat to do with the result
Page loadPOST /widget/init with a random visitor_id kept in localStorageKeep session_id. If resumed is true, call GET /widget/history; otherwise show greeting_message.
Visitor sendsPOST /widget/messageRender reply and remember message_id.
After the first replyOpen wss://api.jelliu.co/widget/wsRender message events whose id you have not seen.
WebSocket downGET /widget/poll every 4 secondsPass the last now as since.

Always render text with textContent, never innerHTML. Replies are plain text, and treating them as HTML opens your page to injection.

To verify the server side of the flow without a browser, run this script. It uses the widget API key, which is why it must never ship to a page.

// chat-smoke-test.mjs — run with: node chat-smoke-test.mjs
import { randomUUID } from 'node:crypto';
const API = 'https://api.jelliu.co';
const headers = {
'x-widget-api-key': process.env.JELLIU_WIDGET_API_KEY,
'x-widget-parent-origin': 'http://localhost:8080',
'Content-Type': 'application/json',
};
async function call(path, body) {
const res = await fetch(API + path, { method: 'POST', headers, body: JSON.stringify(body) });
const json = await res.json();
if (!res.ok) throw new Error(`${path} -> ${res.status} ${json.error.code}: ${json.error.message}`);
return json.data;
}
const init = await call('/widget/init', { visitor_id: randomUUID() });
console.log('session:', init.session_id, 'resumed:', init.resumed);
const turn = await call('/widget/message', { session_id: init.session_id, message: '¿Cuál es el horario del sábado?' });
console.log('conversation:', turn.conversation_id);
console.log('reply:', turn.reply);

Expected output:

session: b2e4c6a8-0d1f-4b3a-9c5e-7f8a9b0c1d2e resumed: false
conversation: 9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a
reply: Los sábados atendemos de 8:00 a 13:00. ¿Quieres agendar una cita?

Keep the conversation ID for the last step.

6

Add a voice button

Because the widget has voice_enabled: true, the standard panel already shows a microphone button. For your custom page, add the button yourself. POST /widget/voice-session returns a conversation_token for WebRTC (with echo cancellation and noise removal) and a signed_url for WebSocket; connect with the first and fall back to the second.

Add this to chat.html, inside the same <script type="module">, and a <button id="voice">Hablar</button> to the page:

chat.html (voice)
import { Conversation } from 'https://cdn.jsdelivr.net/npm/@elevenlabs/client@1.25.0/+esm';
let call = null;
const voiceBtn = document.getElementById('voice');
voiceBtn.addEventListener('click', async () => {
if (call) {
await call.endSession();
call = null;
voiceBtn.textContent = 'Hablar';
return;
}
voiceBtn.disabled = true;
let probe = null;
try {
// Must run inside the click handler so the browser shows the prompt.
probe = await navigator.mediaDevices.getUserMedia({ audio: true });
const res = await fetch(`${API}/widget/voice-session`, {
method: 'POST',
credentials: 'omit',
headers: { 'x-widget-id': WIDGET_ID, 'x-widget-parent-origin': ORIGIN, 'Content-Type': 'application/json' },
body: '{}',
});
const body = await res.json();
if (!res.ok) throw new Error(body.error.code);
const { conversation_token: token, signed_url: signedUrl } = body.data;
const callbacks = {
onConnect: () => { voiceBtn.textContent = 'Colgar'; },
onModeChange: ({ mode }) => { voiceBtn.title = mode === 'speaking' ? 'El agente habla' : 'Escuchando'; },
onDisconnect: () => { call = null; voiceBtn.textContent = 'Hablar'; },
onError: () => render('error', 'La llamada se interrumpió. Intenta de nuevo.'),
};
if (token) {
try {
call = await Conversation.startSession({ ...callbacks, conversationToken: token, connectionType: 'webrtc' });
} catch {
call = null;
}
}
if (!call) {
call = await Conversation.startSession({ ...callbacks, signedUrl, connectionType: 'websocket' });
}
} catch (err) {
render('error', err.name === 'NotAllowedError'
? 'Permite el micrófono para usar la voz.'
: 'No se pudo iniciar la llamada.');
} finally {
if (probe) probe.getTracks().forEach((t) => t.stop());
voiceBtn.disabled = false;
}
});

Reload http://localhost:8080/chat.html, click Hablar, allow the microphone and speak. localhost counts as a secure context, so browsers allow the microphone there; in production the page must be served over HTTPS.

If your chat UI runs inside an iframe, the frame needs allow="microphone".

7

Reply as a human operator

Web chat conversations can be taken over by a person. Replies posted to the conversation are stored and pushed to the visitor’s open chat over the WebSocket, or picked up on their next poll. This needs a write key.

curl -sS -X POST "https://api.jelliu.co/api/conversations/9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a/messages" \
-H "Authorization: Bearer $JELLIU_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "message": "Hola, soy Laura de recepción. Te confirmo la cita del sábado a las 9:00." }'

Expected output:

201 { data: { messageId: '6e5d4c3b-2a19-4f08-9e7d-6c5b4a392817', conversationId: '9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a' } }

To see it arrive in the browser, use a conversation started from chat.html (its ID is the conversation_id in the /widget/message response, visible in the browser’s network tab). The message appears in the open chat within a second over the WebSocket, or within 4 seconds by polling. message is 1 to 4096 characters.

8

Go to production

Remove the local origin once you are done testing. PUT /api/widgets/{id} accepts only the fields you send, and allowed_origins replaces the whole list.

curl -sS -X PUT "https://api.jelliu.co/api/widgets/$JELLIU_WIDGET_ID" \
-H "Authorization: Bearer $JELLIU_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "allowed_origins": ["https://www.example.com", "https://shop.example.com"] }'

Expected output:

200 [ 'https://www.example.com', 'https://shop.example.com' ]

The runtime caches widget settings for a short time on each server, so allow about 30 seconds for an origin change to apply everywhere.

Production checklist:

  • Every domain and subdomain that shows the chat is listed (https://shop.example.com needs its own entry).
  • The site is served over HTTPS, which the microphone requires.
  • rate_limit_rpm fits your traffic. The default is 30 requests per minute for the whole widget, and polling counts.
  • Only the widget ID is in your pages. The jl_ key and the widget API key are in server secrets.

Troubleshooting

The page’s origin is not in allowed_origins. Check the exact scheme and port: http://localhost:8080 and http://localhost:3000 are different origins, and so are http and https. If the embed is on a page with a strict Referrer-Policy, the standard embed already overrides it for its frame; a custom iframe must allow the referrer to carry the origin.

Your custom client did not send x-widget-parent-origin. Every /widget/* call needs it, including /widget/poll and /widget/history.

Polling counts against the widget’s rate_limit_rpm. With the default of 30 per minute, a single tab polling every 2 seconds uses the whole budget. Poll every 4 seconds only while the WebSocket is down, and every 30 seconds when it is up.

The session has no conversation yet, or parent_origin is missing or not allowed. Open the socket only after the first /widget/message succeeds, and pass wid, session_id and parent_origin as query parameters.

The agent is still being set up. Wait the Retry-After seconds (5) and try again.

Set voice_enabled to true with PUT /api/widgets/{id}.

That is 503 TEMPORARILY_UNAVAILABLE: the workspace has no active plan, or it used up the monthly AI message allowance. The owner gets an in-app notification. See Billing and usage.

HTML tags are stripped from JSON request bodies, and an unclosed < removes the rest of the text. Replace < before sending if visitors may type it.