> ## Documentation Index
> Fetch the complete documentation index at: https://chatbase.co/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Iframe Developer Guide

> Embed your AI agent with a plain iframe or a mobile WebView and control it with postMessage: identity, metadata, resets, events, client-side actions and forms.

The iframe runs the same chat as the chat bubble. The chat bubble's embed script is a wrapper that creates this iframe and posts messages to it. Without the script there is no `window.chatbase`, so you post the same messages to the iframe yourself.

Your page and the chat talk only through `postMessage`, in both directions. You send messages such as `identify` or `resetChat`, and the chat sends events such as `assistant-message` back.

Almost everything in the **Features** section works in an iframe, and in a [mobile app WebView](#mobile-apps).

## When to use the iframe

Use the iframe when:

* The chat sits inline in your page, such as a help center section, a dashboard panel or a full-page chat.
* You build your own launcher, layout or open and close behavior.
* Your platform blocks third-party scripts but allows iframes.
* You add the chat to a mobile app. See [Mobile apps](#mobile-apps).

Use the [chat bubble](/docs/developer-guides/website-integration/chat-bubble) when you want a floating button with no extra code.

## Quick start

**1. Copy the iframe code**

1. Go to your [Chatbase Dashboard](https://www.chatbase.co/dashboard) and select your AI agent.
2. Open **Channels**, click **Manage** on the **Chat bubble** card.
3. Click **Deploy** and choose **Website iframe**.
4. Copy the code.

**2. Add an id**

Give the iframe an `id` so your code can find it. Add `allow="microphone"` if you use voice or dictation.

```html theme={null}
<iframe
  id="chatbase-iframe"
  src="https://www.chatbase.co/chatbot-iframe/YOUR_AGENT_ID"
  width="100%"
  style="height: 100%; min-height: 700px"
  frameborder="0"
  referrerpolicy="no-referrer-when-downgrade"
  allow="microphone"
></iframe>
```

If you use a [custom domain](/docs/developer-guides/custom-domains), the `src` uses that domain. Every example on this page reads the origin from `src`, so they work with either.

**3. Connect your page**

Paste the [connector](#connect-to-the-iframe) in a script right after the iframe, then log the agent's replies:

```javascript theme={null}
const chat = connectChatbase(document.getElementById("chatbase-iframe"));

chat.on("assistant-message", ({ content }) => console.log("Agent replied:", content));
```

**4. Test it**

Load the page and send a message.

> [!TIP]
>
> The agent answers in the iframe and its reply appears in your browser console.

## Connect to the iframe

Save this connector as `connect-chatbase.js`, or paste it into your page. Every example on this page uses it.

```javascript connect-chatbase.js theme={null}
function connectChatbase(iframe) {
  const origin = new URL(iframe.src).origin;
  const handlers = {};
  let markReady;
  const ready = new Promise((resolve) => {
    markReady = resolve;
  });

  function onMessage(event) {
    if (event.source !== iframe.contentWindow || event.origin !== origin) return;
    const type = event.data?.type;
    if (type === "iframeReady") markReady();
    for (const handler of handlers[type] ?? []) handler(event.data.data, event.data);
  }
  window.addEventListener("message", onMessage);

  return {
    ready,
    send: (message) => ready.then(() => iframe.contentWindow.postMessage(message, origin)),
    on: (type, handler) => (handlers[type] ??= []).push(handler),
    disconnect: () => window.removeEventListener("message", onMessage)
  };
}
```

It does four things for you:

* Posts only to the iframe's origin, and ignores messages from anywhere else.
* Holds messages until the chat is ready. Anything posted before `iframeReady` would otherwise be dropped.
* Calls your `on` handlers with the event's payload as the first argument and the full message as the second.
* Works with custom domains, because it reads the origin from `src`.

Using a module bundler? Add `export` in front of `function`.

### Timing rules

* Call `connectChatbase` right after the iframe element, before the iframe loads. If it runs later, it misses `iframeReady` and `send` never delivers.
* Don't use the iframe's `load` event. It can fire before the chat is listening.
* If the iframe reloads, the chat posts `iframeReady` again. Put setup such as `identify` in `chat.on("iframeReady", ...)` so it runs again too.

### Readiness signals

| Event | Fires when | Use it to |
| - | - | - |
| `iframeReady` | The chat is listening for messages. | Send `identify`, `setOptions`, `setInitialMessages`. |
| `conversationReady` | The conversation has loaded and rendered. Fires again after each reset. | Reveal a hidden iframe, reset on load. |

> [!WARNING]
>
> When the iframe restores an existing conversation, `conversationReady` waits for the chat to have a height, so it never fires under `display: none`. A new conversation fires regardless. To keep the iframe hidden until ready, use `opacity: 0` or `visibility: hidden`.

### Without the connector

The connector is optional. With plain `postMessage`:

* Post with the agent origin as `targetOrigin`, for example `https://www.chatbase.co`. `"*"` also works, but never use it for a message that carries a token.
* Wait for `iframeReady` before posting.
* In your `message` listener, check `event.source === iframe.contentWindow` and `event.origin`.
* Payloads sit under `event.data.data`.

## Common tasks

Each recipe assumes `const chat = connectChatbase(iframe)` from [Connect to the iframe](#connect-to-the-iframe).

### Identify a logged-in user

Render a JWT from your server into the page, then send it when the chat is ready. See [Identity and user data](#identity-and-user-data).

```javascript theme={null}
chat.on("iframeReady", () => {
  chat.send({
    type: "identify",
    params: { token: window.CHATBASE_TOKEN, publicAttributes: { plan: "pro" } }
  });
});
```

Reference: [`identify`](#jwt)

### Reset the conversation on every load

```javascript theme={null}
let didReset = false;

chat.on("conversationReady", () => {
  if (didReset) return;
  didReset = true;
  chat.send({ type: "resetChat" });
});
```

The guard matters. `conversationReady` fires again after the reset. If you also identify the user, `identify` must arrive within the [500 ms window](#choose-when-to-identify), or the reset applies to the anonymous conversation.

Reference: [`resetChat`](#resetchat)

### Reset the conversation from a button

```javascript theme={null}
document.getElementById("new-chat").addEventListener("click", () => {
  chat.send({ type: "resetChat" });
});
```

Reference: [`resetChat`](#resetchat)

### Show the chat only when it's ready

```html theme={null}
<iframe id="chatbase-iframe" style="opacity: 0; transition: opacity 0.2s" ...></iframe>
```

```javascript theme={null}
chat.on("conversationReady", () => {
  iframe.style.opacity = "1";
});
```

Reference: [Readiness signals](#readiness-signals)

### Build your own open and close button

```javascript theme={null}
const button = document.getElementById("chat-toggle");

function setOpen(open) {
  iframe.hidden = !open;
  chat.send({ type: open ? "focusInput" : "widgetClosed" });
}

button.addEventListener("click", () => setOpen(iframe.hidden));
chat.on("close", () => setOpen(false));
```

Reference: [`focusInput`](#focusinput), [`widgetClosed`](#widgetclosed), [`close`](#events-you-receive)

### Ask a question for the user

```javascript theme={null}
chat.send({ type: "sendMessage", message: "How do I upgrade my plan?" });
```

Set `options: { hidden: true }` to send it without showing it. The agent's reply still shows.

Reference: [`sendMessage`](#sendmessage)

### Give the agent context about the page

```javascript theme={null}
chat.on("iframeReady", () => {
  chat.send({
    type: "identify",
    params: { user_metadata: { page: location.pathname, plan: "pro" } }
  });
});
```

The agent reads this context, but anyone using the page can change it. Don't use it for anything that needs trust.

Reference: [Metadata without verification](#metadata-without-verification)

### Log the user out

```javascript theme={null}
chat.send({ type: "identify", params: { token: null } });
chat.send({ type: "resetChat" });
```

Reference: [Log out](#log-out)

### Track messages in analytics

```javascript theme={null}
chat.on("user-message", ({ content }) => {
  analytics.track("Chat message sent", { length: content.length });
});

chat.on("assistant-message", ({ content }) => {
  analytics.track("Chat reply received", { length: content.length });
});
```

Reference: [Events you receive](#events-you-receive)

### Set the chat language

```html theme={null}
<iframe src="https://www.chatbase.co/chatbot-iframe/YOUR_AGENT_ID?hostLang=fr" ...></iframe>
```

```javascript theme={null}
chat.send({ type: "setWidgetLanguage", lang: "de" });
```

Applies only when the agent's language source is set to the website.

Reference: [URL parameters](#url-parameters), [`setWidgetLanguage`](#setwidgetlanguage)

### Fit the chat to mobile screens

```css theme={null}
#chatbase-iframe {
  width: 100%;
  height: 100dvh;
  border: 0;
}
```

```javascript theme={null}
const sendWidth = () => chat.send({ windowInnerWidth: window.innerWidth });
sendWidth();
window.addEventListener("resize", sendWidth);
```

Reference: [`windowInnerWidth`](#windowinnerwidth)

## Feature support

Everything works in an iframe except the floating launcher, floating initial messages and automatic sizing.

### Works in the iframe

| Feature | Iframe message |
| - | - |
| Identity verification, JWT | [`identify`](#jwt) with `token` |
| Identity verification, user hash | [`identify`](#user-hash) with `user_hash` |
| Public user metadata | [`identify`](#metadata-without-verification) with `publicAttributes` or `user_metadata` |
| Log out | [`identify`](#log-out) with `token: null` |
| Reset the conversation | [`resetChat`](#resetchat) |
| Send a message, visible or hidden | [`sendMessage`](#sendmessage) |
| Custom initial messages | [`setInitialMessages`](#setinitialmessages) |
| Runtime options | [`setOptions`, `resetOptions`](#setoptions-and-resetoptions) |
| Focus the input | [`focusInput`](#focusinput) |
| Website language | [`hostLang`](#url-parameters), [`setWidgetLanguage`](#setwidgetlanguage) |
| Event listeners | [Events you receive](#events-you-receive) |
| Client-side custom actions | [`CHATBOT_TOOL_CALL`, `CHATBOT_TOOL_RESULT`](#client-side-custom-actions) |
| Client-side custom forms | [`GET_FORM_SCHEMA`, `FORM_SCHEMA_RESULT`](#client-side-custom-forms) |
| Widget functions | [`WIDGET_ACTION_CALL`, `WIDGET_ACTION_RESULT`](#widget-functions) |
| Recent chats, tickets, voice, dictation | Built in |

### Embed script only

These are features of the bubble wrapper, not of the chat. Build them yourself if you need them.

| Feature | Iframe alternative |
| - | - |
| Floating launcher button, bubble text, `open()`, `close()`, `toggle()` | [Build your own open and close button](#build-your-own-open-and-close-button). |
| `open({ message, hideMessage })` reveal timing | Send a hidden [`sendMessage`](#sendmessage), then show the iframe on `assistant-message-stream-start`. |
| [Floating initial messages](/docs/developer-guides/website-integration/floating-initial-messages) | None. These render outside the iframe. |
| Panel sizing and full screen on mobile | [Fit the chat to mobile screens](#fit-the-chat-to-mobile-screens). |
| `window.chatbaseUserConfig` | Send [`identify`](#identity-and-user-data) on `iframeReady`. |
| Command queue before load, `getState()` | The connector's `send` and `ready`. |
| Hidden paths, traffic sampling, client-side domain check | None. The server still enforces [allowed domains](#allowed-domains) and blocked countries. |

## Messages you can send

Every message is an object with a `type`. The examples use `chat.send` from the [connector](#connect-to-the-iframe).

| `type` | Payload | Effect |
| - | - | - |
| `identify` | `params` | Identifies the user. See [Identity and user data](#identity-and-user-data). |
| `initConfig` | `willIdentify` | Tells the chat whether to wait for `identify`. See [Choose when to identify](#choose-when-to-identify). |
| `resetChat` | none | Starts a new conversation. |
| `sendMessage` | `message`, `options.hidden` | Sends a message as the user. |
| `setInitialMessages` | `messages` | Replaces the initial messages. |
| `setOptions` | `params` | Overrides display options at runtime. |
| `resetOptions` | `params` | Clears runtime overrides. |
| `focusInput` | none | Focuses the message box. |
| `widgetClosed` | none | Stops voice mode. |
| `setWidgetLanguage` | `lang` | Sets the chat language. |
| none | `windowInnerWidth` | Reports your window width. |

### resetChat

Starts a new conversation. The old one stays in **Recent chats**.

```javascript theme={null}
chat.send({ type: "resetChat" });
```

To reset on every load, see [the recipe](#reset-the-conversation-on-every-load).

### sendMessage

Sends a message as if the user typed it.

**message** `string` *required*

The message text. Blank messages are ignored.

**options.hidden** `boolean` (default: false)

When `true`, the message is sent but not shown in the chat. The agent's reply is shown.

```javascript theme={null}
chat.send({ type: "sendMessage", message: "What is your refund policy?" });

chat.send({
  type: "sendMessage",
  message: "The user is on the pricing page",
  options: { hidden: true }
});
```

If the agent is busy, the chat holds the message until it is ready. It holds one message. A second message sent while busy replaces the first.

### setInitialMessages

Replaces the agent's initial messages for this session. They show in new conversations, including after `resetChat`.

**messages** `string[]` *required*

Non-empty strings. Text past 1000 characters in total is cut off.

```javascript theme={null}
chat.send({
  type: "setInitialMessages",
  messages: ["Hi Ada", "Need help with your Pro plan?"]
});
```

See [Custom Initial Messages](/docs/developer-guides/website-integration/custom-initial-messages) for use cases.

### setOptions and resetOptions

Override display options at runtime. `params` takes the same fields as [`setOptions`](/docs/developer-guides/website-integration/chat-interface-control#runtime-options) in the embed script.

| Field | Type | Limit |
| - | - | - |
| `displayName` | string | 100 characters |
| `messagePlaceholder` | string | 100 characters |
| `footer` | string | 1000 characters |
| `dismissibleNotice` | string | 500 characters |
| `initialMessages` | string\[] | 1000 characters in total |
| `suggestedMessages` | string\[] | 1 to 200 characters each |
| `hideActionsMenu` | boolean | |
| `dictationOnly` | boolean | |
| `dictationTimeout` | positive integer | |
| `dictationWaveformColor` | hex color | |

```javascript theme={null}
chat.send({
  type: "setOptions",
  params: { displayName: "Billing help", suggestedMessages: ["Download an invoice"] }
});

chat.send({
  type: "resetOptions",
  params: { displayName: true, suggestedMessages: true }
});
```

`resetOptions` takes the fields to clear, each set to `true`. An invalid payload is ignored and logs `[CHATBASE]: Invalid setOptions payload` in the [iframe's console](#see-the-iframes-console).

The iframe posts `optionsChanged` once the change is on screen.

### focusInput

Focuses the message box, unless something else in the iframe already has focus. Send it when you show the iframe.

```javascript theme={null}
chat.send({ type: "focusInput" });
```

### widgetClosed

Stops voice mode. Send it when you hide the iframe.

```javascript theme={null}
chat.send({ type: "widgetClosed" });
```

### windowInnerWidth

Reports your window width. Below 768 pixels the chat shows mobile initial messages. Below 640 pixels it uses its mobile layout. This message has no `type`.

```javascript theme={null}
chat.send({ windowInnerWidth: window.innerWidth });
```

### setWidgetLanguage

Sets the chat language. Applies only when the agent's language source is set to the website. `lang` is a language code, or `null` to fall back to the default.

```javascript theme={null}
chat.send({ type: "setWidgetLanguage", lang: "fr" });
```

For the first load, use the [`hostLang` URL parameter](#url-parameters) instead.

## Events you receive

The iframe posts these to your page. With the connector, `chat.on(type, (data) => ...)` receives the payload as `data`.

| `type` | `data` | Fires when |
| - | - | - |
| `iframeReady` | none | The chat can receive messages. |
| `conversationReady` | none | The conversation has rendered. |
| `user-message` | `{ content }` | The user sends a message. |
| `assistant-message-stream-start` | none | The agent starts replying. |
| `assistant-message` | `{ content }` | The agent finishes a reply. |
| `tool-call` | `{ id, name, type, args }` | The agent calls an action. |
| `tool-result` | `{ toolCallId, name, type, result }` | An action returns. |
| `dictation-transcript` | `{ transcript }` | The user finishes dictating. |
| `optionsChanged` | `{ options }` | Runtime options change and render. Also fires on load. |
| `close` | none | The user presses Escape, clicks the close button or ends the chat. |

`type` in `tool-call` and `tool-result` is the action type. `name` is the tool name. `tool-result` can arrive twice for the same call, so dedupe on `toolCallId`:

```javascript theme={null}
const seenResults = new Set();

chat.on("tool-result", ({ toolCallId, name, result }) => {
  if (seenResults.has(toolCallId)) return;
  seenResults.add(toolCallId);
  console.log("Action finished:", name, result);
});
```

> [!WARNING]
>
> Without the connector, the payload is at `event.data.data`. The embed script and the connector unwrap it for you, so `data.content` there is `event.data.data.content` in a raw `message` listener.

See [Event Listeners](/docs/developer-guides/website-integration/chatbot-event-listeners) for use cases.

## Identity and user data

The iframe supports the same [identity verification](/docs/developer-guides/website-integration/identity-verification) as the embed script. Verification happens on the Chatbase server, so it is as secure in an iframe as in the bubble.

### Choose when to identify

> [!WARNING]
>
> The chat waits **500 ms** after `iframeReady` for `identify`. If nothing arrives, the session starts as anonymous. A later `identify` still works, but earlier messages are not tied to the user, and a reset on load hits the anonymous conversation.

Pick one:

1. **Render the token into the page (recommended).** Your server signs the JWT and writes it into the HTML. Send `identify` on `iframeReady`. See the [PHP example](#complete-examples).
2. **Single-page apps: wait to render the iframe.** Fetch the token first, then render the iframe. See the [React example](#complete-examples).
3. **Token arrives late.** Send `{ type: "initConfig", willIdentify: true }` on `iframeReady`, then `identify` once you have the token. The chat waits with no user until then. `initConfig` also shows the chat's close button, which posts [`close`](#events-you-receive), so handle it.

There's no event that confirms identification. To check it worked, chat as the user, then open the conversation under **Activity** in the dashboard and confirm it shows that user.

### Methods

| Method | Verified | Updates the contact | Use it for |
| - | - | - | - |
| JWT | Yes | Yes | Logged-in users, actions that need contact data, Stripe actions. Recommended. |
| User hash | Yes | No | Logged-in users when you can't issue a JWT. |
| Metadata only | No | No | Personalizing replies with data that isn't sensitive. |

### JWT

Sign the JWT on your server with your agent's secret key. See [Generating the JWT payload](/docs/developer-guides/website-integration/identity-verification#generating-the-jwt-payload) for claims and server code in Node.js, Python, PHP and Ruby.

```javascript theme={null}
chat.on("iframeReady", () => {
  chat.send({
    type: "identify",
    params: {
      token: jwtFromYourServer,
      publicAttributes: { name: "Ada", plan: "pro" }
    }
  });
});
```

**params.token** `string` *required*

The signed JWT. Claims such as `email`, `name`, `phonenumber`, `custom_attributes` and `stripe_accounts` update the contact. The agent can't read them.

**params.publicAttributes** `object`

String values the agent can read. Leave out sensitive data.

> [!NOTE]
>
> In the embed script, every `identify` field except `token` becomes a public attribute. In the iframe you put them in `publicAttributes` yourself.

### User hash

Compute the hash on your server: HMAC-SHA256 of the user ID with your secret key, hex encoded.

```javascript Node.js theme={null}
const crypto = require("crypto");

const userHash = crypto
  .createHmac("sha256", process.env.CHATBASE_SECRET)
  .update(user.id)
  .digest("hex");
```

```php PHP theme={null}
<?php
$userHash = hash_hmac('sha256', $user['id'], $_ENV['CHATBASE_SECRET']);
```

```javascript theme={null}
chat.on("iframeReady", () => {
  chat.send({
    type: "identify",
    params: {
      user_id: "user_12345",
      user_hash: userHashFromYourServer,
      user_metadata: { name: "Ada", plan: "pro" }
    }
  });
});
```

If the hash is wrong, the user stays anonymous and the iframe logs `Invalid user id or user hash. Please check them.`

### Metadata without verification

Send `user_metadata` alone to give the agent context.

```javascript theme={null}
chat.on("iframeReady", () => {
  chat.send({
    type: "identify",
    params: { user_metadata: { name: "Ada", plan: "pro", page: "pricing" } }
  });
});
```

* Values are strings. Other values are converted to JSON text.
* The agent reads up to 1000 characters of metadata.
* Anyone using the page can change this data. Don't use it for anything that needs trust, such as account actions.

### Log out

Clear the identity when the user logs out, then reset so the next user doesn't see the conversation.

```javascript theme={null}
chat.send({ type: "identify", params: { token: null } });
chat.send({ type: "resetChat" });
```

### Conversation history

The chat stores the current conversation in the iframe's own browser storage. Browsers keep that storage separate for each site that embeds the iframe.

| User | Current conversation after reload | Recent chats |
| - | - | - |
| Anonymous | Same device, browser and site only | Same device, browser and site only |
| Identified by JWT or hash | Same device, browser and site only | Every conversation for that user ID, on any device |

Clearing site data or blocking third-party storage starts anonymous users fresh. Identify users to keep their history.

### Prefilling forms

| Form | Prefill |
| - | - |
| [Collect leads](/docs/user-guides/chatbot/actions/collect-leads) form | Not supported. |
| Chatbase ticket form | Email, from the contact for a verified user, or from `email` in a verified user's public attributes or metadata. The email is locked. |
| [Client-side custom form](#client-side-custom-forms) | Any field. Set `defaultValue` from the `user` object your handler receives. |
| In-chat questions | The agent reads public attributes and metadata, so it skips questions it already has answers to. |

## Client-side custom actions

Configure the action in the dashboard first. See [Client-Side Custom Actions](/docs/developer-guides/website-integration/client-side-custom-actions).

When the agent calls the action, the iframe posts `CHATBOT_TOOL_CALL`. Reply with `CHATBOT_TOOL_RESULT` and the same `toolName`.

| Direction | Message |
| - | - |
| Iframe to your page | `{ type: "CHATBOT_TOOL_CALL", toolName, args, user }` |
| Your page to iframe | `{ type: "CHATBOT_TOOL_RESULT", toolName, result }` |

`result` is one of:

* `{ status: "success", data }`
* `{ status: "error", error }`

These fields sit at the top level of the message, so read them from the handler's second argument.

```javascript theme={null}
const tools = {
  get_weather: async (args, user) => {
    const res = await fetch(`/api/weather?city=${encodeURIComponent(args.city)}`);
    if (!res.ok) return { status: "error", error: "Weather lookup failed" };
    return { status: "success", data: await res.json() };
  }
};

chat.on("CHATBOT_TOOL_CALL", async (_, { toolName, args, user }) => {
  const handler = tools[toolName];
  const result = handler
    ? await handler(args, user)
    : { status: "error", error: `Tool ${toolName} not found` };
  chat.send({ type: "CHATBOT_TOOL_RESULT", toolName, result });
});
```

* The chat waits 60 seconds, then fails the call with `Tool call timeout`.
* A result larger than 20 KB, by default, fails with `Tool result too large`. A malformed one fails with `Invalid tool result`.
* Results are matched by `toolName`. Two calls to the same tool at once can receive each other's result.
* Actions set not to wait for a response never post `CHATBOT_TOOL_CALL`. Listen for `tool-call` instead.

## Client-side custom forms

Configure the form action in the dashboard first. See [Client-Side Custom Forms](/docs/developer-guides/website-integration/client-side-custom-forms) for the schema fields.

| Direction | Message |
| - | - |
| Iframe to your page | `{ type: "GET_FORM_SCHEMA", toolName, args, user }` |
| Your page to iframe | `{ type: "FORM_SCHEMA_RESULT", toolName, schema }` |

```javascript theme={null}
chat.on("GET_FORM_SCHEMA", (_, { toolName, user }) => {
  if (toolName !== "contact_form") return;

  chat.send({
    type: "FORM_SCHEMA_RESULT",
    toolName,
    schema: {
      fields: [
        {
          name: "email",
          label: "Email",
          type: "email",
          defaultValue: user?.user_metadata?.email ?? "",
          validation: { required: { value: true, message: "Email is required" } }
        }
      ],
      submitButtonText: "Send"
    }
  });
});
```

The same 60 second timeout and `toolName` matching apply. An invalid schema fails with `Invalid form schema`.

## Widget functions

[Widget functions](/docs/developer-guides/widgets/functions) call your page from inside a widget.

| Direction | Message |
| - | - |
| Iframe to your page | `{ type: "WIDGET_ACTION_CALL", functionName, args }` |
| Your page to iframe | `{ type: "WIDGET_ACTION_RESULT", functionName, result }` |

```javascript theme={null}
chat.on("WIDGET_ACTION_CALL", async (_, { functionName, args }) => {
  if (functionName !== "enrollInClass") return;

  const res = await fetch("/api/enroll", { method: "POST", body: JSON.stringify(args) });
  chat.send({ type: "WIDGET_ACTION_RESULT", functionName, result: await res.json() });
});
```

The iframe accepts the result only from your page's origin. Keep `referrerpolicy="no-referrer-when-downgrade"` on the iframe so it can detect that origin in every browser.

## URL parameters

The iframe reads one URL parameter.

| Parameter | Example | Effect |
| - | - | - |
| `hostLang` | `?hostLang=fr` | Sets the chat language. Applies only when the agent's language source is set to the website. |

The iframe ignores every other parameter, including `name`, `email`, `user_id`, `user_metadata` and `token`. URLs end up in browser history, server logs and referrer headers, so identity goes through `identify` instead. To build the iframe on your server, render the token into the page and post it, as in the [PHP example](#complete-examples).

## Complete examples

**HTML**

Identifies the user, resets on load, logs replies and handles a client-side action. The token request starts while the iframe loads.

```html theme={null}
<iframe
  id="chatbase-iframe"
  src="https://www.chatbase.co/chatbot-iframe/YOUR_AGENT_ID"
  width="100%"
  style="height: 100%; min-height: 700px"
  frameborder="0"
  referrerpolicy="no-referrer-when-downgrade"
  allow="microphone"
></iframe>

<script>
  function connectChatbase(iframe) {
    const origin = new URL(iframe.src).origin;
    const handlers = {};
    let markReady;
    const ready = new Promise((resolve) => {
      markReady = resolve;
    });

    function onMessage(event) {
      if (event.source !== iframe.contentWindow || event.origin !== origin) return;
      const type = event.data?.type;
      if (type === "iframeReady") markReady();
      for (const handler of handlers[type] ?? []) handler(event.data.data, event.data);
    }
    window.addEventListener("message", onMessage);

    return {
      ready,
      send: (message) => ready.then(() => iframe.contentWindow.postMessage(message, origin)),
      on: (type, handler) => (handlers[type] ??= []).push(handler),
      disconnect: () => window.removeEventListener("message", onMessage)
    };
  }

  const iframe = document.getElementById("chatbase-iframe");
  const chat = connectChatbase(iframe);
  const tokenPromise = fetch("/api/chatbase-token").then((r) => r.json());

  const tools = {
    get_order_status: async ({ orderId }) => {
      const res = await fetch(`/api/orders/${encodeURIComponent(orderId)}`);
      return res.ok
        ? { status: "success", data: await res.json() }
        : { status: "error", error: "Order not found" };
    }
  };

  chat.on("iframeReady", async () => {
    const { token } = await tokenPromise;
    chat.send({ type: "identify", params: { token, publicAttributes: { plan: "pro" } } });
    chat.send({ windowInnerWidth: window.innerWidth });
  });

  let didReset = false;
  chat.on("conversationReady", () => {
    if (didReset) return;
    didReset = true;
    chat.send({ type: "resetChat" });
  });

  chat.on("assistant-message", ({ content }) => console.log("Agent replied:", content));

  chat.on("CHATBOT_TOOL_CALL", async (_, { toolName, args, user }) => {
    const handler = tools[toolName];
    const result = handler
      ? await handler(args, user)
      : { status: "error", error: `Tool ${toolName} not found` };
    chat.send({ type: "CHATBOT_TOOL_RESULT", toolName, result });
  });
</script>
```

> [!NOTE]
>
> If the token request can finish more than 500 ms after `iframeReady`, render the token into the page instead. See [Choose when to identify](#choose-when-to-identify).

**React**

Uses `connect-chatbase.js` from [Connect to the iframe](#connect-to-the-iframe), with `export` added. Render the component once you know whether the user is logged in, so `identify` arrives in time. Pass `null` for logged-out users. When `token` changes, the component identifies the new user, or logs out and resets.

```tsx ChatbaseIframe.tsx theme={null}
import { useEffect, useRef } from "react";
import { connectChatbase } from "./connect-chatbase";

const AGENT_URL = "https://www.chatbase.co/chatbot-iframe/YOUR_AGENT_ID";

type Props = {
  token: string | null;
  onAssistantMessage?: (content: string) => void;
};

export function ChatbaseIframe({ token, onAssistantMessage }: Props) {
  const iframeRef = useRef<HTMLIFrameElement>(null);
  const chatRef = useRef<ReturnType<typeof connectChatbase> | null>(null);
  const tokenRef = useRef(token);
  const onAssistantMessageRef = useRef(onAssistantMessage);
  onAssistantMessageRef.current = onAssistantMessage;

  useEffect(() => {
    const chat = connectChatbase(iframeRef.current!);
    chatRef.current = chat;
    chat.on("iframeReady", () => {
      chat.send({ type: "identify", params: { token: tokenRef.current, publicAttributes: {} } });
    });
    chat.on("assistant-message", ({ content }) => onAssistantMessageRef.current?.(content));
    return chat.disconnect;
  }, []);

  useEffect(() => {
    const previous = tokenRef.current;
    tokenRef.current = token;
    if (previous === token) return;
    chatRef.current?.send({ type: "identify", params: { token, publicAttributes: {} } });
    if (!token) chatRef.current?.send({ type: "resetChat" });
  }, [token]);

  return (
    <iframe
      ref={iframeRef}
      src={AGENT_URL}
      title="Chat"
      style={{ width: "100%", height: "100%", minHeight: 700, border: 0 }}
      referrerPolicy="no-referrer-when-downgrade"
      allow="microphone"
    />
  );
}
```

```tsx Usage theme={null}
const { user, isLoading } = useAuth();

if (isLoading) return null;
return <ChatbaseIframe token={user?.chatbaseToken ?? null} />;
```

**PHP**

The server signs a JWT and renders it into the page. The browser posts it to the iframe. The token never goes in the iframe URL. Uses `connect-chatbase.js` from [Connect to the iframe](#connect-to-the-iframe).

```php theme={null}
<?php
require 'vendor/autoload.php';
use Firebase\JWT\JWT;

$agentId = 'YOUR_AGENT_ID';
$token = JWT::encode([
    'user_id' => $user['id'],
    'email' => $user['email'],
    'name' => $user['name'],
    'exp' => time() + 3600,
], $_ENV['CHATBASE_SECRET'], 'HS256');
$publicAttributes = ['name' => $user['name']];
$language = substr($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? 'en', 0, 2);
?>
<iframe
  id="chatbase-iframe"
  src="https://www.chatbase.co/chatbot-iframe/<?= htmlspecialchars($agentId) ?>?hostLang=<?= urlencode($language) ?>"
  width="100%"
  style="height: 100%; min-height: 700px"
  frameborder="0"
  referrerpolicy="no-referrer-when-downgrade"
  allow="microphone"
></iframe>

<script src="/js/connect-chatbase.js"></script>
<script>
  const chat = connectChatbase(document.getElementById("chatbase-iframe"));
  const token = <?= json_encode($token) ?>;
  const publicAttributes = <?= json_encode($publicAttributes) ?>;

  chat.on("iframeReady", () => {
    chat.send({ type: "identify", params: { token, publicAttributes } });
  });
</script>
```

## Mobile apps

The simplest way to add your agent to a mobile app is to load the chat in a WebView. Users get the same chat as on your website, including actions, forms, tickets and voice, and there's no chat UI to build. Use the [iOS SDK](/docs/ios-sdk/overview) or [Android SDK](/docs/android-sdk/overview) only when you want your own native chat UI.

### How it works

The WebView loads the chat URL directly, with no iframe and no HTML of your own:

```
https://www.chatbase.co/chatbot-iframe/YOUR_AGENT_ID
```

Because nothing frames the chat, it posts its events to its own window and reads messages posted to its own window. Your app connects to it in two parts:

* **A bridge script.** Inject it before the page loads. It forwards chat events to your native code and sends `identify` on `iframeReady`, inside the 500 ms window.
* **Native sends.** To send any message from [Messages you can send](#messages-you-can-send), run `window.postMessage(message, location.origin)` in the WebView.

Every example below does the same four things: injects the bridge, receives events, opens reply links in the system browser, and lets voice use the microphone.

### Examples

**iOS**

`WKWebView` in a view controller. To use it from SwiftUI, wrap it in a `UIViewControllerRepresentable`.

```swift ChatbaseViewController.swift theme={null}
import UIKit
import WebKit

final class ChatbaseViewController: UIViewController, WKScriptMessageHandler, WKUIDelegate {
    private let agentURL = URL(string: "https://www.chatbase.co/chatbot-iframe/YOUR_AGENT_ID")!
    private let token: String?
    private var webView: WKWebView!

    init(token: String?) {
        self.token = token
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }

    override func viewDidLoad() {
        super.viewDidLoad()
        let tokenJS = token.map { "\"\($0)\"" } ?? "null"
        let bridge = """
        const TOKEN = \(tokenJS);
        window.addEventListener("message", (e) => {
          if (e.source !== window || !e.data?.type) return;
          if (e.data.type === "iframeReady") {
            window.postMessage({ type: "identify", params: { token: TOKEN, publicAttributes: {} } }, location.origin);
            window.postMessage({ windowInnerWidth: window.innerWidth }, location.origin);
          }
          window.webkit.messageHandlers.chatbase.postMessage(e.data);
        });
        """

        let config = WKWebViewConfiguration()
        config.userContentController.add(self, name: "chatbase")
        config.userContentController.addUserScript(
            WKUserScript(source: bridge, injectionTime: .atDocumentStart, forMainFrameOnly: true)
        )

        webView = WKWebView(frame: view.bounds, configuration: config)
        webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        webView.uiDelegate = self
        view.addSubview(webView)
        webView.load(URLRequest(url: agentURL))
    }

    func send(_ message: [String: Any]) {
        guard let data = try? JSONSerialization.data(withJSONObject: message),
              let json = String(data: data, encoding: .utf8) else { return }
        webView.evaluateJavaScript("window.postMessage(\(json), location.origin)")
    }

    func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
        guard let event = message.body as? [String: Any], let type = event["type"] as? String else { return }
        let data = event["data"] as? [String: Any] ?? [:]

        switch type {
        case "assistant-message":
            print("Agent replied:", data["content"] ?? "")
        case "close":
            dismiss(animated: true)
        default:
            break
        }
    }

    func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
                 for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
        if let url = navigationAction.request.url { UIApplication.shared.open(url) }
        return nil
    }

    func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
                 initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
                 decisionHandler: @escaping (WKPermissionDecision) -> Void) {
        decisionHandler(.grant)
    }
}
```

```swift Usage theme={null}
present(ChatbaseViewController(token: jwtFromYourServer), animated: true)
```

For voice, add `NSMicrophoneUsageDescription` to your `Info.plist`.

**Android**

`WebView` in an activity. Needs the `androidx.webkit` library for the bridge script.

```kotlin build.gradle.kts theme={null}
dependencies {
    implementation("androidx.webkit:webkit:1.12.1")
}
```

```kotlin ChatbaseActivity.kt theme={null}
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivity
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import org.json.JSONObject

class ChatbaseActivity : AppCompatActivity() {
    private val agentUrl = "https://www.chatbase.co/chatbot-iframe/YOUR_AGENT_ID"
    private lateinit var webView: WebView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val token = intent.getStringExtra("chatbaseToken")
        val tokenJs = token?.let { "\"$it\"" } ?: "null"
        val bridge = """
            const TOKEN = $tokenJs;
            window.addEventListener("message", (e) => {
              if (e.source !== window || !e.data?.type) return;
              if (e.data.type === "iframeReady") {
                window.postMessage({ type: "identify", params: { token: TOKEN, publicAttributes: {} } }, location.origin);
                window.postMessage({ windowInnerWidth: window.innerWidth }, location.origin);
              }
              ChatbaseAndroid.onEvent(JSON.stringify(e.data));
            });
        """.trimIndent()

        webView = WebView(this).apply {
            settings.javaScriptEnabled = true
            settings.domStorageEnabled = true
            addJavascriptInterface(Bridge(), "ChatbaseAndroid")
            webChromeClient = object : WebChromeClient() {
                override fun onPermissionRequest(request: PermissionRequest) {
                    request.grant(request.resources)
                }
            }
            webViewClient = object : WebViewClient() {
                override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
                    if (request.url.host == Uri.parse(agentUrl).host) return false
                    startActivity(Intent(Intent.ACTION_VIEW, request.url))
                    return true
                }
            }
        }

        if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
            WebViewCompat.addDocumentStartJavaScript(webView, bridge, setOf("https://${Uri.parse(agentUrl).host}"))
        }

        setContentView(webView)
        webView.loadUrl(agentUrl)
    }

    fun send(message: JSONObject) {
        webView.evaluateJavascript("window.postMessage($message, location.origin)", null)
    }

    inner class Bridge {
        @JavascriptInterface
        fun onEvent(json: String) {
            val event = JSONObject(json)
            when (event.optString("type")) {
                "assistant-message" -> Log.d("Chatbase", event.getJSONObject("data").getString("content"))
                "close" -> runOnUiThread { finish() }
            }
        }
    }
}
```

```kotlin Usage theme={null}
startActivity(Intent(this, ChatbaseActivity::class.java).putExtra("chatbaseToken", jwtFromYourServer))
```

For voice, declare `android.permission.RECORD_AUDIO` in your manifest and request it at runtime before opening the chat.

**React Native**

Uses [`react-native-webview`](https://github.com/react-native-webview/react-native-webview). Works on iOS and Android.

```tsx ChatbaseScreen.tsx theme={null}
import { useRef } from "react";
import { Linking } from "react-native";
import { WebView, type WebViewMessageEvent } from "react-native-webview";

const AGENT_URL = "https://www.chatbase.co/chatbot-iframe/YOUR_AGENT_ID";

export function ChatbaseScreen({ token, onClose }: { token: string | null; onClose: () => void }) {
  const webViewRef = useRef<WebView>(null);

  const bridge = `
    const TOKEN = ${JSON.stringify(token)};
    window.addEventListener("message", (e) => {
      if (e.source !== window || !e.data?.type) return;
      if (e.data.type === "iframeReady") {
        window.postMessage({ type: "identify", params: { token: TOKEN, publicAttributes: {} } }, location.origin);
        window.postMessage({ windowInnerWidth: window.innerWidth }, location.origin);
      }
      window.ReactNativeWebView.postMessage(JSON.stringify(e.data));
    });
    true;
  `;

  const send = (message: object) =>
    webViewRef.current?.injectJavaScript(
      `window.postMessage(${JSON.stringify(message)}, location.origin); true;`
    );

  const onMessage = (event: WebViewMessageEvent) => {
    const { type, data } = JSON.parse(event.nativeEvent.data);
    if (type === "assistant-message") console.log("Agent replied:", data.content);
    if (type === "close") onClose();
  };

  return (
    <WebView
      ref={webViewRef}
      source={{ uri: AGENT_URL }}
      injectedJavaScriptBeforeContentLoaded={bridge}
      onMessage={onMessage}
      onOpenWindow={(e) => Linking.openURL(e.nativeEvent.targetUrl)}
      mediaCapturePermissionGrantType="grant"
    />
  );
}
```

Call `send({ type: "resetChat" })`, or any other message, from your own buttons.

**Flutter**

Uses [`flutter_inappwebview`](https://pub.dev/packages/flutter_inappwebview) for the bridge script and [`url_launcher`](https://pub.dev/packages/url_launcher) for links.

```dart chatbase_webview.dart theme={null}
import 'dart:collection';
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:url_launcher/url_launcher.dart';

const agentUrl = 'https://www.chatbase.co/chatbot-iframe/YOUR_AGENT_ID';

class ChatbaseWebView extends StatefulWidget {
  const ChatbaseWebView({super.key, this.token});

  final String? token;

  @override
  State<ChatbaseWebView> createState() => _ChatbaseWebViewState();
}

class _ChatbaseWebViewState extends State<ChatbaseWebView> {
  InAppWebViewController? _controller;

  String get _bridge => '''
    const TOKEN = ${jsonEncode(widget.token)};
    window.addEventListener("message", (e) => {
      if (e.source !== window || !e.data?.type) return;
      if (e.data.type === "iframeReady") {
        window.postMessage({ type: "identify", params: { token: TOKEN, publicAttributes: {} } }, location.origin);
        window.postMessage({ windowInnerWidth: window.innerWidth }, location.origin);
      }
      window.flutter_inappwebview.callHandler("chatbase", e.data);
    });
  ''';

  void send(Map<String, dynamic> message) {
    _controller?.evaluateJavascript(
      source: 'window.postMessage(${jsonEncode(message)}, location.origin)',
    );
  }

  @override
  Widget build(BuildContext context) {
    return InAppWebView(
      initialUrlRequest: URLRequest(url: WebUri(agentUrl)),
      initialSettings: InAppWebViewSettings(
        useShouldOverrideUrlLoading: true,
        mediaPlaybackRequiresUserGesture: false,
      ),
      initialUserScripts: UnmodifiableListView([
        UserScript(source: _bridge, injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START),
      ]),
      onWebViewCreated: (controller) {
        _controller = controller;
        controller.addJavaScriptHandler(
          handlerName: 'chatbase',
          callback: (args) {
            final event = args.first as Map;
            if (event['type'] == 'assistant-message') {
              debugPrint('Agent replied: ${event['data']['content']}');
            }
            if (event['type'] == 'close') Navigator.of(context).maybePop();
          },
        );
      },
      onPermissionRequest: (controller, request) async => PermissionResponse(
        resources: request.resources,
        action: PermissionResponseAction.GRANT,
      ),
      shouldOverrideUrlLoading: (controller, action) async {
        final url = action.request.url;
        if (url == null || url.host == Uri.parse(agentUrl).host) {
          return NavigationActionPolicy.ALLOW;
        }
        await launchUrl(url, mode: LaunchMode.externalApplication);
        return NavigationActionPolicy.CANCEL;
      },
    );
  }
}
```

For voice, add the microphone permission for each platform: `NSMicrophoneUsageDescription` on iOS, `RECORD_AUDIO` on Android.

### Things to know

* **Pass the token when you open the chat.** The bridge reads it when the page loads. If the user logs in or out while the chat is open, either reload the WebView with the new token or send `identify` from native code.
* **History.** The WebView keeps its own storage, so the conversation survives app restarts. Identified users also see past conversations under **Recent chats**. Conversations in the WebView are website chat conversations, so the SDKs' conversation methods don't list them.
* **The `close` event.** It fires when the user ends the chat. Dismiss the screen when you receive it.
* **Specific domains doesn't apply.** Nothing frames the chat, so the allowed domains setting has no effect in a WebView.
* **Client-side actions and forms.** Your bridge already forwards `CHATBOT_TOOL_CALL` and `GET_FORM_SCHEMA` to native code. Reply with `CHATBOT_TOOL_RESULT` or `FORM_SCHEMA_RESULT` through a native send.
* **Using your own HTML page instead.** You can also load a page of your own that holds the iframe and the connector. Then everything above this section applies as on the web, and your domain must be in the allowed domains list if you turned it on.

## Security

### Origins

* Post to the agent origin, never `"*"`, when a message carries a token or user data. The connector does this for you.
* Check `event.source` and `event.origin` in every listener. Any script or frame can post to your window. The connector does this for you.
* The iframe accepts messages from any page that embeds it. Your allowed domains setting controls which pages can embed it.

### Allowed domains

In the dashboard, open **Deploy**, turn on **Specific domains** and add every site that embeds the iframe. Browsers then refuse to show the iframe on other sites.

### Secrets and data

* Sign JWTs and user hashes on your server. Never put the secret key in browser or app code.
* Set `exp` on every JWT.
* Public attributes and `user_metadata` are visible to the agent and editable by the user. Put sensitive data in the JWT.

### Iframe attributes

| Attribute | Why |
| - | - |
| `allow="microphone"` | Voice and dictation. Without it the browser blocks the microphone. |
| `referrerpolicy="no-referrer-when-downgrade"` | Lets the chat detect your page's origin for widget functions and analytics. Don't use `no-referrer`. |

## Troubleshooting

### See the iframe's console

Errors from the chat, such as `Invalid setOptions payload` or `Invalid user id or user hash`, log inside the iframe. In your browser's developer tools, open the console's context menu, which is labeled **top** by default, and select the Chatbase frame. For a WebView, use Safari's **Develop** menu on iOS or `chrome://inspect` on Android.

### Common problems

> **The iframe is blank or refuses to connect**
>
> * **Specific domains** is on and your site isn't in the list. Add the exact domain the page runs on.
> * The visitor is in a country you blocked. The iframe returns 404.
> * The `src` has the wrong agent ID.

> **Messages have no effect**
>
> * The message went out before `iframeReady`. Use the connector's `send`, or send from an `iframeReady` handler.
> * `targetOrigin` doesn't match the iframe's origin, so the browser dropped it silently. Use `new URL(iframe.src).origin`.
> * `type` is misspelled. Types are case sensitive, for example `resetChat`, not `resetchat`.
> * `setOptions` was invalid. Check the [iframe's console](#see-the-iframes-console).

> **iframeReady never arrives**
>
> * The connector or listener was added after the iframe loaded. Add it in a script right after the iframe element.
> * Your origin check rejects it. With a custom domain, the origin is your custom domain, not `https://www.chatbase.co`.

> **The user isn't recognized**
>
> * `identify` arrived more than 500 ms after `iframeReady`. See [Choose when to identify](#choose-when-to-identify).
> * The JWT is expired, or signed with the wrong secret or algorithm. Use HS256 and the secret from this agent.
> * The user hash is wrong. The iframe logs `Invalid user id or user hash. Please check them.`
> * You passed public fields at the top level of `params`. In the iframe they go in `publicAttributes`.

> **Event data is undefined**
>
> Without the connector, the payload sits one level deeper than in the embed script. Read `event.data.data.content`, not `event.data.content`.

> **Reset on load loops**
>
> `conversationReady` fires again after each reset. Reset only once, guarded by a flag.

> **A client-side action times out**
>
> * No listener replied with `CHATBOT_TOOL_RESULT` within 60 seconds.
> * The reply's `toolName` doesn't match the call.
> * `result` isn't `{ status: "success", data }` or `{ status: "error", error }`.

> **The conversation is gone after a reload**
>
> The browser cleared or blocked the iframe's storage. Anonymous history lives only in that storage. Identify users to keep their history in **Recent chats**.

> **The close button does nothing**
>
> You sent `initConfig`, which shows the close button. Handle the `close` event and hide the iframe.

> **WebView: links in replies do nothing**
>
> On iOS, `WKWebView` ignores links that open a new window. Implement `webView(_:createWebViewWith:for:windowFeatures:)` and open the URL in the system browser, as in the [iOS example](#examples).

> **WebView: no events reach native code**
>
> * The bridge was injected after the page loaded. Inject it at document start.
> * On Android, `DOCUMENT_START_SCRIPT` isn't supported by the device's WebView. Update Android System WebView.
> * On Android, `domStorageEnabled` or `javaScriptEnabled` is off.

## Next steps

> **[Identity Verification](/docs/developer-guides/website-integration/identity-verification)**
>
> JWT claims and server code

> **[Event Listeners](/docs/developer-guides/website-integration/chatbot-event-listeners)**
>
> Use cases for chat events

> **[Client-Side Custom Actions](/docs/developer-guides/website-integration/client-side-custom-actions)**
>
> Configure actions that run in the browser

> **[Chat Interface Control](/docs/developer-guides/website-integration/chat-interface-control)**
>
> Runtime options reference


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.