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.
Pick an integration
Section titled “Pick an integration”| 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.
The two values you need
Section titled “The two values you need”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.
Script tag
Section titled “Script tag”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.
Driving it from the page
Section titled “Driving it from the page”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(); // unsubscribeEvents 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.
npm install @uraiai/chat-widget-reactimport { 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.
What changes live and what remounts
Section titled “What changes live and what remounts”| 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.
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.
Svelte
Section titled “Svelte”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.
Vanilla JavaScript and other frameworks
Section titled “Vanilla JavaScript and other frameworks”The core package is what the three above wrap:
npm install @uraiai/chat-widget-coreimport { 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 mountwidget.open();
// On teardown:widget.destroy(); // idempotentPassing 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.
Building a production integration
Section titled “Building a production integration”A launcher in the corner is fine for a marketing site. Inside a product, four things separate a good integration from a demo.
Mount it once, high in the tree
Section titled “Mount it once, high in the tree”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.
Own the chrome in inline mode
Section titled “Own the chrome in inline mode”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.
Give the inline container a real height
Section titled “Give the inline container a real height”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" }} … />Keep context and credentials fresh
Section titled “Keep context and credentials fresh”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.
Handle what comes back
Section titled “Handle what comes back”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.
Sign-out, tenant switching, and cleanup
Section titled “Sign-out, tenant switching, and cleanup”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.
Troubleshooting
Section titled “Troubleshooting”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.
Check before moving on
Section titled “Check before moving on”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.