Embed a voice and chat agent on your site
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
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.
Pick the agent
List your agents and copy the id of the one that should answer visitors.
Expected output:
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.
Expected output:
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.
Embed the widget and test it locally
Create index.html in an empty folder:
Replace YOUR_WIDGET_ID, serve the folder on port 8080 and open http://localhost: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.
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.
Expected output:
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.
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:
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.
Expected output:
Keep the conversation ID for the last step.
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:
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".
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.
Expected output:
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.
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.
Expected output:
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.comneeds its own entry). - The site is served over HTTPS, which the microphone requires.
rate_limit_rpmfits 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 launcher shows, but every message fails with 403
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.
403 Missing X-Widget-Parent-Origin header
Your custom client did not send x-widget-parent-origin. Every /widget/* call needs it, including /widget/poll and /widget/history.
429 Rate limit exceeded after a few minutes of testing
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 WebSocket closes immediately with 401
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.
Voice: 409 AGENT_PROVISIONING
The agent is still being set up. Wait the Retry-After seconds (5) and try again.
Voice: 403 Voice is not enabled for this widget
Set voice_enabled to true with PUT /api/widgets/{id}.
Visitors see: No podemos responder por aquí en este momento
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.
Part of a message disappears
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.

