For the complete documentation index, see llms.txt. This page is also available as Markdown.

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.

<!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>

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.

// 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;
        "dynamic-variables"?: 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.

Load the bundle once in index.html, then use the tag in any template.

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:

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 to get a Daily room and a token, then join the room yourself.

The response also carries session_id and agent_id. End the session with the cancel beacon described in the Session API Reference.

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. Copy that snippet rather than retyping the id.

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

dynamic-variables

none

ElevenLabs agents only. A JSON object of per-chat values for your agent's {{placeholders}}, read each time a chat starts. See Dynamic Variables for ElevenLabs Agents

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

Dynamic Variables for ElevenLabs Agents

If your agent is a Third-Party Agent on ElevenLabs Agents, its prompt, first message and tools can contain {{placeholders}}: ElevenLabs' dynamic variables. Fill them for each chat with the dynamic-variables attribute, a JSON object mapping each placeholder's name to its value. The name and format match ElevenLabs' own widget.

The widget reads the attribute each time a chat starts, so you can set or change it any time before then. Pages that compute the values set it from JavaScript:

The element takes only attributes, so in React pass dynamic-variables={JSON.stringify(values)}, and in Vue bind :dynamic-variables="JSON.stringify(values)".

Only ElevenLabs agents use the values for now. Other agents accept them and ignore them, so a page that sends values keeps working if you change the agent.

Rules

  • At most 32 variables.

  • Names start with a letter and use only letters, digits and underscores, up to 64 characters. Names starting with system__ are reserved for ElevenLabs' system variables, and constructor and prototype are reserved too.

  • Values are text, numbers or true/false. Objects, arrays and null are not allowed.

  • Text is at most 1000 characters, counted in UTF-16 code units (an emoji counts as two), and must not contain half of an emoji or other surrogate pair.

  • The whole object is at most 4096 bytes as JSON, in UTF-8.

  • Pass IDs as strings: JSON numbers above 2^53 lose precision in the browser.

If the attribute breaks a rule, the widget sends none of the values, starts the chat anyway, and logs the reason in the browser console as [ojin-agent] dynamic-variables ignored: .... The reason names the rule and the variable, never a value.

Keep HTML and code out of the values. Ojin's firewall can block a chat start whose values look like markup; see Troubleshooting.

Ojin uses the values only to start that chat. They are not stored, not shown in the dashboard and not written to logs. What the agent says with them is conversation content like any other.

Custom integrations send the same object as the dynamic_variables field of POST /v1/public/agents/connect.

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.

Widget styling and positioning options are configured in your agent's settings in the dashboard. Check your agent's widget configuration for available customization options.

Finding Your Agent ID

After creating and configuring your agent, copy the agent ID from the agent settings page in the Ojin dashboard.

Controlling Access

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

Hostname Allowlist

In the 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

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.

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, plus any dynamic-variables values

  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:

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

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, anything your user types, or your dynamic-variables values. 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 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 shows the browser version of this.

See the Session API Reference 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

  • 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

  • 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

No chat after adding dynamic-variables

If the chat stopped starting once you added dynamic-variables, and the browser console shows a CORS error or Failed to fetch, Ojin's firewall has most likely blocked a value that looks like HTML or code. The widget then also logs [ojin-agent] Chat start failed with a network or CORS error. Remove markup from the values and try again.

dynamic-variables ignored in the console

The attribute broke one of the rules, so the widget sent none of the values. The message names the rule and the variable to fix.

An ElevenLabs agent's chat fails to start

  • Check that every {{placeholder}} the ElevenLabs agent uses has a default value in ElevenLabs. A placeholder with no default and no value makes ElevenLabs refuse the conversation.

Last updated

Was this helpful?