> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://developer.jelliu.co/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://developer.jelliu.co/_mcp/server.

# 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

| You need                                   | Why                                                                                                  |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| A workspace API key with the `write` scope | Creating and updating widgets, and operator replies. See [API keys](/platform/api-keys).             |
| An agent in the workspace                  | The widget answers with it. Create one in the dashboard or with the [Agents API](/resources/agents). |
| A plan with AI message allowance           | Every web chat reply counts against it. See [Billing and usage](/platform/billing-and-usage).        |
| Node.js 18+ or Python 3.9+                 | For the setup scripts.                                                                               |
| A local static server                      | Any will do, for example `npx serve -l 8080` or `python -m http.server 8080`.                        |

```bash
export JELLIU_API_KEY="jl_..."
```

## How the pieces fit

```mermaid
flowchart LR
    subgraph Your site
      E[Embed script] --> W[Widget panel]
      C[Custom chat page]
    end
    subgraph Your server
      S[Setup script]
      O[Operator reply script]
    end
    S -- "POST /api/widgets (jl_ key)" --> J[(api.jelliu.co)]
    W -- "/widget/* (widget ID + parent origin)" --> J
    C -- "/widget/* + /widget/ws" --> J
    C -- "POST /widget/voice-session" --> J
    C -. "WebRTC or WebSocket audio" .-> V[Voice provider]
    O -- "POST /api/conversations/ID/messages" --> J
    J -- "message event" --> C
```

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.

**`cURL`**

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

**`Node.js`**

```javascript title="Node.js"
const res = await fetch('https://api.jelliu.co/api/agents?limit=20', {
  headers: { Authorization: `Bearer ${process.env.JELLIU_API_KEY}` },
});
const { data } = await res.json();
for (const agent of data) console.log(agent.id, agent.name);
```

**`Python`**

```python title="Python"
import os
import requests

res = requests.get(
    "https://api.jelliu.co/api/agents",
    params={"limit": 20},
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    timeout=30,
)
for agent in res.json()["data"]:
    print(agent["id"], agent["name"])
```

Expected output:

```text
7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4 Recepción Clínica Norte
```

```bash
export JELLIU_AGENT_ID="7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4"
```

#### 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`**

```bash title="cURL"
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\"]
    }
  }"
```

**`Node.js`**

```javascript title="Node.js"
// setup-widget.mjs — run with: node setup-widget.mjs
const res = await fetch('https://api.jelliu.co/api/widgets', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    agent_id: process.env.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'],
    },
  }),
});

const body = await res.json();
if (!res.ok) {
  console.error(res.status, JSON.stringify(body.error, null, 2));
  process.exit(1);
}

const widget = body.data;
console.log('Widget ID:     ', widget.id);
console.log('Widget API key:', widget.plaintext_api_key, '(shown once, store it now)');
console.log(`Embed: <script src="https://api.jelliu.co/widget-embed/${widget.id}/embed.js" async></script>`);
```

**`Python`**

```python title="Python"
# setup_widget.py — run with: python setup_widget.py
import json
import os
import sys

import requests

res = requests.post(
    "https://api.jelliu.co/api/widgets",
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    json={
        "agent_id": os.environ["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"],
        },
    },
    timeout=30,
)
body = res.json()
if not res.ok:
    print(res.status_code, json.dumps(body["error"], indent=2), file=sys.stderr)
    sys.exit(1)

widget = body["data"]
print("Widget ID:     ", widget["id"])
print("Widget API key:", widget["plaintext_api_key"], "(shown once, store it now)")
print(f'Embed: <script src="https://api.jelliu.co/widget-embed/{widget["id"]}/embed.js" async></script>')
```

Expected output:

```text
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>
```

```bash
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.

#### Embed the widget and test it locally

Create `index.html` in an empty folder:

**`index.html`**

```html title="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`:

```bash
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.

#### 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.

```bash
# 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:

```text
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](/channels/webchat#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](/channels/webchat#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:

| Moment                | Call                                                                  | What to do with the result                                                                                |
| --------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| Page load             | `POST /widget/init` with a random `visitor_id` kept in `localStorage` | Keep `session_id`. If `resumed` is `true`, call `GET /widget/history`; otherwise show `greeting_message`. |
| Visitor sends         | `POST /widget/message`                                                | Render `reply` and remember `message_id`.                                                                 |
| After the first reply | Open `wss://api.jelliu.co/widget/ws`                                  | Render `message` events whose `id` you have not seen.                                                     |
| WebSocket down        | `GET /widget/poll` every 4 seconds                                    | Pass 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.

**`Node.js`**

```javascript title="Node.js"
// 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);
```

**`Python`**

```python title="Python"
# chat_smoke_test.py — run with: python chat_smoke_test.py
import os
import uuid

import requests

API = "https://api.jelliu.co"
HEADERS = {
    "x-widget-api-key": os.environ["JELLIU_WIDGET_API_KEY"],
    "x-widget-parent-origin": "http://localhost:8080",
}


def call(path, body):
    res = requests.post(API + path, headers=HEADERS, json=body, timeout=60)
    data = res.json()
    if not res.ok:
        raise RuntimeError(f"{path} -> {res.status_code} {data['error']['code']}: {data['error']['message']}")
    return data["data"]


init = call("/widget/init", {"visitor_id": str(uuid.uuid4())})
print("session:", init["session_id"], "resumed:", init["resumed"])

turn = call("/widget/message", {"session_id": init["session_id"], "message": "¿Cuál es el horario del sábado?"})
print("conversation:", turn["conversation_id"])
print("reply:", turn["reply"])
```

Expected output:

```text
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.

#### 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)`**

```javascript title="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"`.

#### 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`**

```bash title="cURL"
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." }'
```

**`Node.js`**

```javascript title="Node.js"
const conversationId = '9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a';

const res = await fetch(`https://api.jelliu.co/api/conversations/${conversationId}/messages`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ message: 'Hola, soy Laura de recepción. Te confirmo la cita del sábado a las 9:00.' }),
});
console.log(res.status, await res.json());
```

**`Python`**

```python title="Python"
import os
import requests

conversation_id = "9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a"
res = requests.post(
    f"https://api.jelliu.co/api/conversations/{conversation_id}/messages",
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    json={"message": "Hola, soy Laura de recepción. Te confirmo la cita del sábado a las 9:00."},
    timeout=30,
)
print(res.status_code, res.json())
```

Expected output:

```text
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.

#### 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`**

```bash title="cURL"
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"] }'
```

**`Node.js`**

```javascript title="Node.js"
const res = await fetch(`https://api.jelliu.co/api/widgets/${process.env.JELLIU_WIDGET_ID}`, {
  method: 'PUT',
  headers: {
    Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ allowed_origins: ['https://www.example.com', 'https://shop.example.com'] }),
});
console.log(res.status, (await res.json()).data.allowed_origins);
```

**`Python`**

```python title="Python"
import os
import requests

res = requests.put(
    f"https://api.jelliu.co/api/widgets/{os.environ['JELLIU_WIDGET_ID']}",
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    json={"allowed_origins": ["https://www.example.com", "https://shop.example.com"]},
    timeout=30,
)
print(res.status_code, res.json()["data"]["allowed_origins"])
```

Expected output:

```text
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 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](/platform/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.

## Related

#### [Web chat](/channels/webchat)

Full reference for the runtime API and the server-side web chat API.

#### [Web chat widget](/widget)

Widget settings, branding and allowed origins.

#### [Conversations](/resources/conversations)

Read transcripts and manage threads.

#### [Security](/security)

Which credential goes where.