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.
Embed Attributes
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.
Visitors can see and edit these values in their browser. Use them to personalise the conversation, never to identify an account or grant access.
Set a default value in ElevenLabs for every placeholder your agent uses. ElevenLabs refuses a conversation that uses a placeholder with no default and no value, so without defaults a chat where the page sent no values, or values the widget had to drop, fails to start.
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, andconstructorandprototypeare reserved too.Values are text, numbers or
true/false. Objects, arrays andnullare 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.
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:
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
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.
How It Works
When the widget loads on your page:
The widget calls the Session API (
POST /v1/public/agents/connect) with your agent ID, plus anydynamic-variablesvaluesThe server validates the request origin against the hostname allowlist
On success, the server returns a WebRTC room URL and token
The widget connects to the WebRTC room directly
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.
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:
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 usehttps://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-idattribute matches your agent's ID in the dashboardSet
core-api-endpointto the value in the dashboard snippet (https://api.ojin.aiin 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
Originheader sent by the browser matches one of your configured hostnames exactlyIf testing locally, add
localhostor127.0.0.1to 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?