> For the complete documentation index, see [llms.txt](https://docs.ojin.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ojin.ai/apps/overview/widget-integration.md).

# Widget Integration

Embed a fully interactive Human Agent on your website with a single HTML tag.

## Quick Start

Load the widget bundle once, then drop the `<ojin-agent>` tag wherever you want it. No backend code is required: the widget calls Ojin directly.

{% tabs %}
{% tab title="Plain HTML" %}

```html
<!DOCTYPE html>
<html>
  <body>
    <script src="https://cdn.jsdelivr.net/npm/@ojin/agent@latest/dist/ojin-agent.js"></script>
    <ojin-agent
      agent-id="your-agent-id"
      core-api-endpoint="https://api.ojin.ai"
      style="position: fixed; bottom: 24px; right: 24px; width: 320px; height: auto; z-index: 9999;"
    ></ojin-agent>
  </body>
</html>
```

{% endtab %}

{% tab title="React / Next.js" %}
The widget is a web component, so it needs to mount on the client. It reads `window` at mount time, which is why the App Router example loads the script with `strategy="afterInteractive"` rather than during server rendering.

Declaring the element in `IntrinsicElements` is what stops TypeScript rejecting the tag.

```tsx
// app/layout.tsx (Next.js App Router)
import Script from "next/script";

declare module "react" {
  namespace JSX {
    interface IntrinsicElements {
      "ojin-agent": React.DetailedHTMLProps<
        React.HTMLAttributes<HTMLElement>,
        HTMLElement
      > & {
        "agent-id"?: string;
        "core-api-endpoint"?: string;
        collapsable?: string;
        "default-collapsed"?: string;
        "enable-video"?: string;
        "preview-url"?: string;
      };
    }
  }
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script
          src="https://cdn.jsdelivr.net/npm/@ojin/agent@latest/dist/ojin-agent.js"
          strategy="afterInteractive"
        />
        <ojin-agent
          agent-id="your-agent-id"
          core-api-endpoint="https://api.ojin.ai"
          style={{ position: "fixed", bottom: 24, right: 24, width: 320, height: "auto", zIndex: 9999 }}
        />
      </body>
    </html>
  );
}
```

**Vite** and **Create React App** have no `next/script`, so put the bundle in the HTML shell instead, then use the tag in any component. Vite uses the project-root `index.html`, Create React App uses `public/index.html`.

```html
<script src="https://cdn.jsdelivr.net/npm/@ojin/agent@latest/dist/ojin-agent.js"></script>
```

{% endtab %}

{% tab title="Vue" %}
Load the bundle once in `index.html`, then use the tag in any template.

```html
<!-- index.html: load the widget bundle once -->
<script src="https://cdn.jsdelivr.net/npm/@ojin/agent@latest/dist/ojin-agent.js"></script>
```

Vue 3's template compiler treats an unknown tag as a component, so tell it `ojin-agent` is a custom element. Without this you get a `Failed to resolve component: ojin-agent` warning:

```js
// vite.config.js
import vue from "@vitejs/plugin-vue";

export default {
  plugins: [
    vue({
      template: {
        compilerOptions: {
          isCustomElement: (tag) => tag.startsWith("ojin-"),
        },
      },
    }),
  ],
};
```

```vue
<!-- Any Vue component: drop the custom element into a template -->
<template>
  <ojin-agent
    agent-id="your-agent-id"
    core-api-endpoint="https://api.ojin.ai"
    style="position: fixed; bottom: 24px; right: 24px; width: 320px; height: auto; z-index: 9999;"
  />
</template>
```

{% endtab %}

{% tab title="REST + Daily" %}
Only reach for this if you are building your own UI. You give up everything the widget handles: the avatar layout, audio controls, captions, reconnection and session teardown.

Call [`POST /v1/public/agents/connect`](/apps/overview/api-reference.md) to get a Daily room and a token, then join the room yourself.

```ts
// Install: npm i @daily-co/daily-js
import DailyIframe from "@daily-co/daily-js";

const AGENT_ID = "your-agent-id";
const CONNECT_URL = "https://api.ojin.ai/v1/public/agents/connect";

async function startSession(clientUserRef?: string) {
  const res = await fetch(CONNECT_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ agent_id: AGENT_ID, client_user_ref: clientUserRef }),
  });
  if (!res.ok) throw new Error(`connect failed: ${res.status}`);

  const { room_url, token } = await res.json();
  const call = DailyIframe.createCallObject();
  await call.join({ url: room_url, token });
  return call;
}
```

The response also carries `session_id` and `agent_id`. End the session with the cancel beacon described in the [Session API Reference](/apps/overview/api-reference.md).
{% endtab %}
{% endtabs %}

{% hint style="info" %}
The same script URL, `core-api-endpoint` and your agent id are in the embed snippet on your agent's settings page in the [dashboard](https://ojin.ai/dashboard). Copy that snippet rather than retyping the id.
{% endhint %}

## Embed Attributes

| Attribute           | Default              | Description                                                                                                 |
| ------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------- |
| `agent-id`          | required             | The agent to connect to. Copy it from the agent settings page                                               |
| `core-api-endpoint` | required             | `https://api.ojin.ai`                                                                                       |
| `enable-video`      | `true`               | Set `"false"` for an audio-only agent                                                                       |
| `collapsable`       | `true` when floating | Set `"false"` to pin the widget open. `widget-placement="inline"` is an alias for this                      |
| `default-collapsed` | `false`              | Set `"true"` to load collapsed. Only the value at mount matters; use `collapse()` and `expand()` afterwards |
| `widget-placement`  | `floating`           | `inline` embeds the widget in the page flow instead of floating over it                                     |

`preview-url` appears in the TypeScript declaration above because the widget *writes* it, exposing the resolved preview image. Do not set it yourself.

## Appearance & Layout

The widget renders as a floating overlay on your page. It includes the avatar video feed, audio controls, and session management: all self-contained.

The agent owner can choose `off`, `conversation`, `tiktok`, `youtube`, `arthouse`, or `journalist` in the dashboard. They can also choose dark or light text, an accent colour, text size, and no background or a solid background. New agents start with captions off.

Caption settings come from the agent configuration. The embed has no caption attributes or viewer controls. Captions hide while the viewer opens the chat panel and return when it closes.

{% hint style="info" %}
Widget styling and positioning options are configured in your agent's settings in the [dashboard](https://ojin.ai/dashboard). Check your agent's widget configuration for available customization options.
{% endhint %}

## Finding Your Agent ID

After [creating and configuring your agent](/apps/overview/configure.md), copy the agent ID from the agent settings page in the [Ojin dashboard](https://ojin.ai/dashboard).

## Controlling Access

Agents are **private by default**: connections from any website are rejected until you configure access.

### Hostname Allowlist

In the [dashboard](https://ojin.ai/dashboard), add the domains that are allowed to connect to your agent:

| Configuration                    | Behaviour                                           |
| -------------------------------- | --------------------------------------------------- |
| No hostnames configured          | **Private**: all connections rejected               |
| `myapp.com`, `staging.myapp.com` | Only requests from those exact domains are accepted |
| `*`                              | **Public**: connections accepted from any domain    |

{% hint style="warning" %}
Using `*` (wildcard) allows anyone to connect to your agent. This is useful for demos but not recommended for production. Use specific hostnames to restrict access to your own domains.
{% endhint %}

{% hint style="info" %}
No API key is needed in the widget. Access is controlled entirely by the hostname allowlist: the server checks the request's origin against your configured domains.
{% endhint %}

## How It Works

When the widget loads on your page:

1. The widget calls the Session API (`POST /v1/public/agents/connect`) with your agent ID
2. The server validates the request origin against the hostname allowlist
3. On success, the server returns a WebRTC room URL and token
4. The widget connects to the WebRTC room directly
5. Audio, video, and signaling all flow over a single WebRTC connection

The entire handshake takes a few seconds. After that, your user is in a live conversation with the agent.

## Network Requirements

If your site sets a Content Security Policy, the widget needs these sources allowed. Blocking the widget script stops it loading at all; blocking anything else fails quietly, leaving the widget running with the affected capability gone. The "If blocked" column says which is which.

| Source                          | Directive              | Purpose                                                                                            | If blocked                                                                  |
| ------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `https://cdn.jsdelivr.net`      | `script-src`           | The widget bundle itself                                                                           | The widget does not load                                                    |
| `https://api.ojin.ai`           | `connect-src`          | Session API: starting and monitoring a session                                                     | Sessions cannot start                                                       |
| `https://*.daily.co`            | `connect-src`          | WebRTC signalling, and fetching the call engine the widget joins the room with                     | No audio or video                                                           |
| `https://*.pluot.blue`, `wss:`  | `connect-src`          | Daily's media servers and their signalling sockets                                                 | No audio or video                                                           |
| `'unsafe-eval'`, `blob:`        | `script-src`           | The call engine is fetched at runtime and evaluated in the page                                    | No audio or video                                                           |
| `'self'`, `blob:`               | `worker-src`           | Daily runs noise cancellation in a worker started from a blob URL                                  | No audio or video                                                           |
| `https://*.amazonaws.com`       | `media-src`, `img-src` | The agent's idle preview video and poster image: the Session API returns them as presigned S3 URLs | The idle preview is blank; sessions still work                              |
| `https://fonts.googleapis.com`  | `style-src`            | The Manrope webfont stylesheet                                                                     | The widget renders in the fallback system font stack; everything else works |
| `https://fonts.gstatic.com`     | `font-src`             | The Manrope font files                                                                             | Same as above                                                               |
| `data:`                         | `font-src`             | Bundled Geist (`@font-face` data URL in the shadow sheet)                                          | Chrome falls back to `sans-serif`                                           |
| `'unsafe-inline'`               | `style-src`            | The widget's components inject their own styles at runtime                                         | The widget renders unstyled                                                 |
| `https://*.ingest.us.sentry.io` | `connect-src`          | Error and reliability reporting                                                                    | Reporting stops; conversations are unaffected                               |

A policy that covers all of it:

```
default-src 'self';
script-src 'self' https://cdn.jsdelivr.net 'unsafe-eval' blob:;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' data: https://fonts.gstatic.com;
img-src 'self' data: blob: https://*.amazonaws.com;
media-src 'self' blob: https://*.amazonaws.com;
connect-src 'self' https://api.ojin.ai https://*.daily.co https://*.pluot.blue https://*.ingest.us.sentry.io wss:;
worker-src 'self' blob:;
frame-src 'self';
```

{% hint style="info" %}
`'unsafe-eval'` is needed because the WebRTC SDK downloads its call engine at connect time and evaluates it in the page. If your policy cannot allow it, get in touch: the SDK has a script-loading mode that trades `'unsafe-eval'` for `script-src https://*.daily.co`.
{% endhint %}

The widget also needs `microphone` permission via `Permissions-Policy` if you serve one, and WebRTC must not be disabled by a browser extension or corporate proxy.

Note the widget has always contacted a Sentry host: the WebRTC library it embeds reports its own errors there. Ojin's own reporting uses the same host.

### What the widget reports

The widget records how each connection attempt ended (succeeded, the microphone was refused, the agent was unavailable, the connection dropped) so we can find and fix problems that only appear on real devices.

It does **not** send conversation audio, video, transcripts, or anything your user types. The conversation itself is never recorded. It does not set cookies for this, and does not send an identifier that follows a visitor between sessions. What it sends is the outcome, a coarse attribution of where the fault lay, a coarse browser family (for example "Safari"), whether the page is inside an in-app browser, the microphone permission state, the HTTP status when the connect was rejected, the widget version, the avatar model variant that served the attempt, and, when an attempt timed out without reporting, how far it had got.

## Reacting to the Agent

The widget emits DOM events on `window` while a session runs (speaking state, live transcripts, and client-delivered tool calls) so your page can build custom UI or trigger actions. See [**Tools & Events**](/apps/overview/tools.md) for the full event reference and for handling function-calling tools.

## Custom Integrations

If you need more control than the widget provides, for example building a native mobile app or a custom web experience, you can call the Session API directly from your backend and connect to the WebRTC room yourself. The **REST + Daily** tab in [Quick Start](#quick-start) shows the browser version of this.

See the [Session API Reference](/apps/overview/api-reference.md) for details.

## Troubleshooting

### Widget not loading

* Use `https://cdn.jsdelivr.net/npm/@ojin/agent@latest/dist/ojin-agent.js`. Do not use `https://widget.ojin.ai/ojin-agent.js`: that host has no DNS record, so the script never loads and `<ojin-agent>` stays an empty tag.
* Confirm the `agent-id` attribute matches your agent's ID in the dashboard
* Set `core-api-endpoint` to the value in the dashboard snippet (`https://api.ojin.ai` in production)

### "agent\_offline" error

* Check that your agent is **published** in the [dashboard](https://ojin.ai/dashboard)
* Ensure all required fields are filled: the dashboard won't let you publish with missing configuration

### "auth\_failed" error

* Verify your domain is in the agent's hostname allowlist in the [dashboard](https://ojin.ai/dashboard)
* Check that the `Origin` header sent by the browser matches one of your configured hostnames exactly
* If testing locally, add `localhost` or `127.0.0.1` to the allowlist

### "concurrency\_limit" error

* Your agent's maximum concurrent sessions are in use. Wait for an active session to end or increase the **max concurrency** setting in the dashboard

### No audio or video

* Ensure the user's browser has granted microphone and camera permissions
* Check that the WebRTC connection is not blocked by a firewall or corporate proxy


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.ojin.ai/apps/overview/widget-integration.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
