Skip to content

6. Embed the widget

Getting a widget on the page takes one line. Getting one that feels part of your product takes a few decisions: where it renders, who sees it, how it is sized, and what it knows. This page covers both, in that order.

Script tag Framework package
Setup One <script>, no build step npm install, a component
Control window.UraiChat after load Typed props and a controller ref
Context updates Imperative calls Props, deep compared
Best for Marketing sites, server-rendered pages, CMS templates Anything inside your app shell

And a second choice, independent of the first:

Floating mounts a launcher button in a corner of the viewport and opens a panel above it. Nothing in your layout changes. This is the default.

Inline renders the panel into an element you provide, and you own the chrome around it. Use this when the agent is part of the interface rather than an overlay on top of it: a side flyout, a help page, a panel in a split view.

Every integration needs the widget token from the Install tab and a stable id for the visitor.

userId is your application’s identifier for the signed-in visitor. Urai treats it as opaque text and never resolves it against your user table, but it decides which conversation the visitor resumes: threads are isolated per widget and visitor id, and the same pair picks up the same conversation across reloads.

Make it opaque and hard to guess. The id is asserted by your page and never verified, so anyone who can guess another visitor’s id can read that visitor’s threads from an allowed origin. A random UUID stored on the account works well. Sequential integers, email addresses, and usernames do not.

Do not generate a fresh id on every page load either, because each one starts a new conversation. For signed-out visitors, generate one, keep it in local storage, and reuse it.

Securing your app covers what the widget token and the origin allowlist do and do not prove.

Paste the snippet from the Install tab before </body>:

<script
src="https://chat.app.urai.dev/api/widget/embed.js"
data-widget-token="wgt_..."
data-user-id="user_42"
></script>

That is the whole integration. The widget fetches its configuration from the server, renders into a closed shadow root so your CSS cannot leak in, and mounts a floating launcher on document.body.

Seed the first conversation with context by adding a JSON object:

<script
src="https://chat.app.urai.dev/api/widget/embed.js"
data-widget-token="wgt_..."
data-user-id="user_42"
data-vars='{"plan":"pro","page":"/pricing"}'
></script>

Server configuration covers most needs, but any field can be overridden per page:

Attribute Effect
data-color, data-title Primary color and header title
data-mode, data-position floating or inline, and which corner
data-welcome, data-placeholder Opening message and input placeholder
data-suggested JSON array of suggested questions
data-theme, data-layout, data-behavior Full JSON objects, passed through

Keep overrides rare. Anything you set here has to be changed by a deploy, while the console version does not.

Once the script has loaded, window.UraiChat is available:

UraiChat.open();
UraiChat.close();
UraiChat.toggle();
UraiChat.sendMessage("How do I export a timesheet?");
UraiChat.reset();
const off = UraiChat.on("assistant-reply", (e) => console.log(e.content));
off(); // unsubscribe

Events are ready, opened, closed, user-message, assistant-reply, command, and error. Calls made before the widget finishes loading are queued and replayed in order, so a “Chat with us” button in your header can call UraiChat.open() without waiting for anything.

Terminal window
npm install @uraiai/chat-widget-react
import { useRef } from "react";
import { UraiChatWidget, type WidgetController } from "@uraiai/chat-widget-react";
export function App() {
const widget = useRef<WidgetController>(null);
return (
<>
<button onClick={() => widget.current?.open()}>Chat with us</button>
<UraiChatWidget
ref={widget}
widgetToken="wgt_..."
userId="user_42"
onAssistantReply={(content) => console.log(content)}
/>
</>
);
}

Required props are widgetToken and userId. Optional ones are baseUrl, vars, theme, layout, behavior, mode, className and style for the inline container, and the callbacks onReady, onOpened, onClosed, onUserMessage, onAssistantReply, onCommand, and onError.

The ref gives you the full controller: open, close, toggle, sendMessage, reset, setUser, setVars, startConversation, configure, and on. Mount and unmount are idempotent and StrictMode safe.

Prop Effect
theme, layout, behavior Applied live through configure(), deep compared, so inline object literals are fine
vars setVars() on the current or next thread, also deep compared
userId setUser(), which resets the conversation for the new visitor
widgetToken, baseUrl, mode Destroys and recreates the widget

Within the live group there is a second distinction. Cosmetic changes such as colors and labels apply in place. Structural changes such as mode, position, header visibility, welcome message, and suggested questions rebuild the panel and clear the visible conversation. Do not drive those from state that changes while someone is typing.

Terminal window
npm install @uraiai/chat-widget-vue
<script setup lang="ts">
import { ref } from "vue";
import { UraiChatWidget, type WidgetController } from "@uraiai/chat-widget-vue";
const widget = ref<{ controller: WidgetController | null } | null>(null);
</script>
<template>
<button @click="widget?.controller?.open()">Chat with us</button>
<UraiChatWidget
ref="widget"
widget-token="wgt_..."
user-id="user_42"
:vars="{ plan: 'pro' }"
@assistant-reply="(content) => console.log(content)"
/>
</template>

Events are emitted as ready, opened, closed, user-message, assistant-reply, command, and error.

Terminal window
npm install @uraiai/chat-widget-svelte
<script lang="ts">
import { UraiChatWidget } from "@uraiai/chat-widget-svelte";
let widget: UraiChatWidget;
</script>
<button onclick={() => widget.getController()?.open()}>Chat with us</button>
<UraiChatWidget
bind:this={widget}
widgetToken="wgt_..."
userId="user_42"
onassistantreply={(content) => console.log(content)}
/>

Callback props are onready, onopened, onclosed, onusermessage, onassistantreply, oncommand, and onerror.

The core package is what the three above wrap:

Terminal window
npm install @uraiai/chat-widget-core
import { createUraiChatWidget } from "@uraiai/chat-widget-core";
const widget = createUraiChatWidget({
widgetToken: "wgt_...",
userId: "user_42",
vars: { plan: "pro" },
container: document.getElementById("assistant") ?? undefined,
});
await widget.ready; // resolves after config fetch and mount
widget.open();
// On teardown:
widget.destroy(); // idempotent

Passing a container switches the widget to inline mode; omitting it mounts the floating launcher on document.body. Importing the package is safe during SSR, but createUraiChatWidget itself has to run in the browser, so call it from an effect, onMount, or an equivalent.

fetchServerConfig: false skips the config request and uses your local options only. Reach for it when you want the widget to work offline in a test harness, not as a way to avoid configuring the widget properly.

A launcher in the corner is fine for a marketing site. Inside a product, four things separate a good integration from a demo.

Put the widget in your app shell or layout, not in individual pages. A widget that unmounts on navigation destroys and recreates itself, and the visible conversation goes with it. Mounted in the layout, it keeps its thread while the user moves around, which is the whole point of an in-app agent.

Decide server side whether it renders at all

Section titled “Decide server side whether it renders at all”

Two questions belong in your loader or controller, before the page reaches the browser: is the widget configured, and is this user allowed to use it.

// Route loader, running on the server.
export async function loader({ request }) {
const me = await currentUser(request);
// Not configured in this environment: render nothing, no errors.
if (!process.env.URAI_WIDGET_TOKEN) return { assistant: null };
// Not permitted: the widget never reaches the browser.
if (!can.useAssistant(me.role)) return { assistant: null };
return {
assistant: {
widgetToken: process.env.URAI_WIDGET_TOKEN,
userId: me.widgetUserId,
// Short-lived token the agent's library authenticates with.
sessionToken: await mintAssistantToken(me),
},
};
}

Gating in the browser leaks the token to users who should not have it, and a missing configuration should degrade to nothing rather than to a broken panel.

The pattern that works well in a product is a flyout: your own panel, your own open and close controls, the widget filling the inside. Your header button toggles it, and your layout reserves space for it on large screens so the content slides rather than being covered.

<aside
className={cn(
"fixed right-0 top-0 z-50 flex h-dvh w-full flex-col border-l bg-card shadow-xl",
"transition-transform duration-300 sm:w-[380px]",
open ? "translate-x-0" : "translate-x-full",
)}
aria-hidden={!open}
>
<button onClick={onClose} aria-label="Close assistant"></button>
<div className="min-h-0 flex-1">
<UraiChatWidget
mode="inline"
widgetToken={config.widgetToken}
userId={config.userId}
vars={vars}
onCommand={onCommand}
style={{ height: "100%", width: "100%" }}
/>
</div>
</aside>

Two details worth copying. Keep the widget mounted when the panel is closed, hiding it with a transform rather than unmounting, so the conversation survives being closed and reopened. And turn off the widget’s own header on the Layout tab if your panel already has one, otherwise you get two.

In inline mode the panel fills its container. Percentage heights only work when every ancestor has a definite height, and the widget adds a wrapper element of its own inside your container, so a chain of percentages tends to collapse to content height. The symptom is a gap below the input bar.

Give the container a height that does not depend on the chain:

/* A full-height flyout. */
height: calc(100dvh - env(safe-area-inset-bottom));

Use dvh rather than vh on anything full height. On mobile browsers 100vh is the viewport with the toolbar retracted, so with the URL bar on screen the input row sits underneath it. dvh tracks the toolbar, and the safe-area inset keeps the input clear of the gesture bar. Neither shows up in desktop device emulation, so test on real hardware.

For a fixed-size panel in a page, an explicit height is simplest:

<UraiChatWidget mode="inline" style={{ height: "600px" }} />

Vars are how the agent knows where the user is, and they are also how the agent’s library gets a credential to call your API. Both change over time.

const [session, setSession] = useState(config.session);
const location = useLocation();
// Deep compared by the widget, so a new object each render is fine.
const vars = useMemo(
() => ({ session_token: session.token, route: location.pathname }),
[session.token, location.pathname],
);
// Refresh the short-lived token shortly before it expires.
useEffect(() => {
const delayMs = Math.max(session.expiresAt - Date.now() / 1000 - 60, 5) * 1000;
const timer = setTimeout(async () => {
const res = await fetch("/internal/assistant-token");
if (res.ok) setSession(await res.json());
}, delayMs);
return () => clearTimeout(timer);
}, [session.expiresAt]);
return <UraiChatWidget vars={vars} />;

Deep comparison is what makes this safe: passing a fresh object on every render does not restart anything, and only a real change reaches the server. Pass context covers the var layers, and securing your app covers the token.

Give onCommand a handler that matches an allowlist of actions and validates values before acting on them, since the payload was composed by a model. Handle commands has the detail.

onError deserves a handler too. Log it with the context you have, because the error the user sees is deliberately vague.

Change userId when the visitor changes, and the widget resets the conversation. Do the same when someone switches tenant or workspace, because the previous context should not carry over.

Stop any token refresh timer on unmount. With the core package, call destroy() in your teardown; the framework wrappers do it for you.

Two widgets on one page with the same token and visitor id intentionally share the persisted thread, so a floating launcher and an inline panel stay in sync. Two different visitor ids give you two separate conversations.

403 on every request, including the stream. The origin is not on the allowed list. Copy the exact origin from the address bar, scheme and port included, and add it on the widget’s Security tab.

Nothing renders and the console shows an error. A missing data-widget-token or data-user-id stops the mount before anything appears.

The panel is the wrong size or has a gap under the input. Inline mode with a container that has no definite height. See above.

The conversation resets when the user navigates. The widget is mounted inside a page rather than the layout, or userId is being regenerated per render.

The conversation clears while someone is using it. A structural config change is being driven from state that updates during use, which rebuilds the panel.

Styling looks wrong in one browser. The widget renders into a closed shadow root, so your CSS is not reaching it. Everything visual is configured on the widget’s Theme and Layout tabs, or through the theme and layout props.

Load your app, open the widget, and get a real answer with your styling applied. Then navigate to another page and confirm the conversation is still there.

Next: pass context.