Custom elements
Frameworks
Each frontend uses a supplied widget ID, matching bundle URL and a short-lived token from your server. These are the same bindings used by the quickstart reference projects.
Frontend and backend choices are separate
A Vue or React website can use your existing backend. The optional downloads include a Node reference server or a Next.js route; they do not define which production backend you must use. Framework build tools may still require Node locally.
Complete the quickstart's SDK installation and protected token route first. These bindings include their required support files; a starter ZIP is optional.
The included HTML, React, Vue and Angular entry files read this public JSON from your own host. Serve it at /api/config, or pass the same object directly to the framework component's config prop/input. This is a chosen host route, not a Returning.AI API. PHP and Django token examples do not create it for you.
GET /api/config
200 OK
Content-Type: application/json
{
"widgetId": "YOUR_WIDGET_ID",
"bundleUrl": "https://your-cdn.example/widget.js"
}Replace both values with your handover settings. Only add apiUrl, v2ApiUrl or domainKey if supplied for your environment. Never add credentials or a signing identity. Next.js's page example below passes this object directly.
For HTML without this route, set window.returningAiWidgetConfig = { widgetId, bundleUrl } in a script before app.js. Keep the IDs and containers in the supplied markup. Frontend source paths below assume the existing site's public root; adjust asset URLs if hosting under a subpath.
Plain HTML
Load the pinned browser SDK before your module and attach the shared session handling to the widget container.
import { createWidgetLifecycle } from "./shared/widget-lifecycle.js";
const STORAGE_PREFIX = "returning-ai-html-node-starter";
const widgetSlot = document.querySelector("#widget-slot");
const statusElement = document.querySelector("#app-status");
const errorPanel = document.querySelector("#error-panel");
const errorMessage = document.querySelector("#error-message");
const retryButton = document.querySelector("#retry-button");
const logoutButton = document.querySelector("#logout-button");
let publicConfig;
let publicConfigPromise;
let lifecycle;
let stopped = false;
let sessionGeneration = 0;
let bootstrapGeneration = 0;
let bootstrapInFlight;
function setState(state) {
document.body.dataset.appState = state;
}
function setStatus(message) {
statusElement.textContent = message;
}
function showError(message) {
if (stopped) return;
setState("error");
setStatus("The widget is not available.");
errorMessage.textContent = message;
errorPanel.hidden = false;
}
function hideError() {
errorPanel.hidden = true;
}
function clearStarterStorage() {
try {
for (const key of Object.keys(localStorage)) {
if (key.startsWith(`${STORAGE_PREFIX}-`)) localStorage.removeItem(key);
}
} catch {
// Storage can be disabled by the browser. Local widget cleanup still runs.
}
}
function isPublicConfig(value) {
return Boolean(
value &&
typeof value === "object" &&
typeof value.widgetId === "string" &&
value.widgetId &&
typeof value.bundleUrl === "string" &&
value.bundleUrl,
);
}
function getInlinePublicConfig() {
const inline = window.returningAiWidgetConfig;
if (inline === undefined) return null;
if (!isPublicConfig(inline)) {
throw new Error("window.returningAiWidgetConfig needs widgetId and bundleUrl");
}
return inline;
}
async function loadPublicConfig() {
const response = await fetch("/api/config", {
credentials: "same-origin",
cache: "no-store",
headers: { Accept: "application/json" },
});
const config = await response.json().catch(() => null);
if (!response.ok || !isPublicConfig(config)) {
throw new Error("Set the widget ID and bundle URL in the server environment first");
}
return config;
}
async function resolvePublicConfig() {
const inline = getInlinePublicConfig();
if (inline) return inline;
publicConfigPromise ??= loadPublicConfig().catch((error) => {
publicConfigPromise = undefined;
throw error;
});
return publicConfigPromise;
}
async function waitForSdk() {
if (customElements.get("rai-custom-widget")) return;
await Promise.race([
customElements.whenDefined("rai-custom-widget"),
new Promise((_, reject) => {
setTimeout(() => reject(new Error("The Returning.AI SDK did not load")), 5000);
}),
]);
}
function createWidget() {
const element = document.createElement("rai-custom-widget");
element.setAttribute("widget-id", publicConfig.widgetId);
element.setAttribute("bundle-url", publicConfig.bundleUrl);
element.setAttribute("height", "auto");
element.setAttribute("eager", "");
element.setAttribute("max-retries", "1");
element.setAttribute("storage-prefix", STORAGE_PREFIX);
if (publicConfig.apiUrl) element.setAttribute("api-url", publicConfig.apiUrl);
if (publicConfig.v2ApiUrl) element.setAttribute("v2-api-url", publicConfig.v2ApiUrl);
if (publicConfig.domainKey) element.setAttribute("domain-key", publicConfig.domainKey);
return element;
}
function makeLifecycle() {
return createWidgetLifecycle({
endpoint: "/api/widget-token",
createWidget,
widgetSlot,
onState: (state) => {
if (stopped) return;
setState(state);
if (state === "loading") setStatus("Loading secure session...");
if (state === "refreshing") setStatus("Refreshing secure session...");
if (state === "authenticating") setStatus("Authenticating widget...");
},
onError: showError,
onAuthenticated: () => {
if (stopped) return;
setState("authenticating");
setStatus("Authenticated. Loading experience...");
},
onMounted: () => {
if (stopped) return;
setState("ready");
setStatus("Experience ready.");
hideError();
logoutButton.hidden = false;
},
onToken: () => {
if (!stopped) hideError();
},
});
}
function isCurrentBootstrap(generation) {
return !stopped && bootstrapGeneration === generation;
}
async function startSession(nextGeneration = sessionGeneration) {
if (stopped) return;
const generation = bootstrapGeneration;
if (bootstrapInFlight?.generation === generation) {
return bootstrapInFlight.promise;
}
const promise = (async () => {
const resolvedConfig = publicConfig ?? (await resolvePublicConfig());
if (!isCurrentBootstrap(generation)) return;
publicConfig ??= resolvedConfig;
await waitForSdk();
if (!isCurrentBootstrap(generation)) return;
lifecycle ??= makeLifecycle();
await lifecycle.start({ sessionGeneration: nextGeneration });
})();
bootstrapInFlight = { generation, promise };
promise.then(
() => {
if (bootstrapInFlight?.promise === promise) bootstrapInFlight = undefined;
},
() => {
if (bootstrapInFlight?.promise === promise) bootstrapInFlight = undefined;
},
);
return promise;
}
function retry() {
void startSession().catch((error) => showError(error instanceof Error ? error.message : "Widget startup failed"));
}
function logout() {
if (stopped) return;
stopped = true;
bootstrapGeneration += 1;
bootstrapInFlight = undefined;
logoutButton.disabled = true;
// dispose() removes the element, aborts token work and clears timers before
// it makes the SDK logout request. logoutPublic is best effort and unawaited.
lifecycle?.dispose();
clearStarterStorage();
logoutButton.hidden = true;
hideError();
setState("signed-out");
setStatus("Signed out. Reload this page to start again.");
}
// The host owns authentication. On host logout/account change it should call
// this boundary with a new local generation. The marker is never sent upstream.
window.returningAiHostSessionChanged = (nextGeneration) => {
if (stopped) return;
sessionGeneration = nextGeneration;
bootstrapGeneration += 1;
void startSession(nextGeneration).catch((error) => showError(error instanceof Error ? error.message : "Widget startup failed"));
};
retryButton.addEventListener("click", retry);
logoutButton.addEventListener("click", logout);
window.addEventListener("pagehide", () => {
bootstrapGeneration += 1;
bootstrapInFlight = undefined;
lifecycle?.dispose({ logout: false });
clearStarterStorage();
});
void startSession().catch((error) => showError(error instanceof Error ? error.message : "Widget startup failed"));Required support files (4)
The shared helpers are required by the binding above. The entry files show initial mounting; merge that wiring into your existing app instead of overwriting its page. Save source at the shown paths, or update imports to match your layout. These are individual frontend files, not a starter ZIP, and contain no backend. Styles are optional if your app supplies its own; remove their import or link if omitted.
Open this frontend in the complete quickstart for the server contract, shared session handling, supplied settings and optional downloads.
React
Tested with React 19.2.3 and Vite 6.3.5. A client-rendered binding. Register the SDK in the browser and clean up when the host session changes or the component unmounts. Use JSX types matching the demonstrated React version; this is not a generic SSR recipe.
import { useEffect, useRef, useState } from "react";
import { createWidgetLifecycle } from "./shared/widget-lifecycle.js";
const STORAGE_PREFIX = "returning-ai-react-node-starter";
function clearStarterStorage() {
try {
for (const key of Object.keys(localStorage)) {
if (key.startsWith(`${STORAGE_PREFIX}-`)) localStorage.removeItem(key);
}
} catch {
// Storage can be disabled. The local widget is still removed.
}
}
function WidgetExperience({ config, sessionGeneration = 0 }) {
const widgetSlotRef = useRef(null);
const retryRef = useRef(() => undefined);
const disconnectRef = useRef(() => undefined);
const [status, setStatus] = useState("Loading secure session...");
const [error, setError] = useState(null);
const [ready, setReady] = useState(false);
const [signedOut, setSignedOut] = useState(false);
useEffect(() => {
let active = true;
let stopped = false;
const createWidget = () => {
const element = document.createElement("rai-custom-widget");
element.setAttribute("widget-id", config.widgetId);
element.setAttribute("bundle-url", config.bundleUrl);
element.setAttribute("height", "auto");
element.setAttribute("eager", "");
element.setAttribute("max-retries", "1");
element.setAttribute("storage-prefix", STORAGE_PREFIX);
if (config.apiUrl) element.setAttribute("api-url", config.apiUrl);
if (config.v2ApiUrl) element.setAttribute("v2-api-url", config.v2ApiUrl);
if (config.domainKey) element.setAttribute("domain-key", config.domainKey);
return element;
};
const lifecycle = createWidgetLifecycle({
endpoint: "/api/widget-token",
createWidget,
widgetSlot: widgetSlotRef.current,
onState: (state) => {
if (!active || stopped) return;
document.body.dataset.appState = state;
if (state === "loading") setStatus("Loading secure session...");
if (state === "refreshing") setStatus("Refreshing secure session...");
if (state === "authenticating") setStatus("Authenticating widget...");
},
onError: (message) => {
if (!active || stopped) return;
setReady(false);
setError(message);
setStatus("The widget is not available.");
},
onAuthenticated: () => {
if (!active || stopped) return;
setStatus("Authenticated. Loading experience...");
},
onMounted: () => {
if (!active || stopped) return;
setReady(true);
setError(null);
setStatus("Experience ready.");
},
onToken: () => {
if (active && !stopped) setError(null);
},
});
retryRef.current = () => {
if (stopped) return;
setError(null);
void lifecycle.start({ sessionGeneration }).catch((reason) => {
if (active) setError(reason instanceof Error ? reason.message : "Widget startup failed");
});
};
disconnectRef.current = () => {
if (!active || stopped) return;
stopped = true;
setReady(false);
// Local cleanup happens before the best-effort, unawaited SDK logout.
lifecycle.dispose();
clearStarterStorage();
setError(null);
setSignedOut(true);
setStatus("Signed out. Reload this page to start again.");
};
setSignedOut(false);
void lifecycle.start({ sessionGeneration }).catch((reason) => {
if (active) {
setError(reason instanceof Error ? reason.message : "Widget startup failed");
setStatus("The widget is not available.");
}
});
return () => {
active = false;
stopped = true;
retryRef.current = () => undefined;
disconnectRef.current = () => undefined;
lifecycle.dispose();
clearStarterStorage();
};
}, [config, sessionGeneration]);
return (
<section className="card" aria-labelledby="widget-heading">
<div className="card-heading">
<div>
<p className="eyebrow">SDK 1.8.11</p>
<h2 id="widget-heading">Your experience</h2>
</div>
{!signedOut && (
<button className="button secondary" type="button" onClick={() => disconnectRef.current()}>
Disconnect widget
</button>
)}
</div>
<p className="status" role="status" aria-live="polite">{status}</p>
{error && (
<div className="error-panel" role="alert">
<p>{error}</p>
<button className="button" type="button" onClick={() => retryRef.current()}>Try again</button>
</div>
)}
<div ref={widgetSlotRef} className="widget-slot" aria-live="polite" />
{ready && <p className="ready-note">The SDK mounted the supplied bundle.</p>}
</section>
);
}
export default function App() {
const [config, setConfig] = useState(null);
const [error, setError] = useState(null);
const [sessionGeneration, setSessionGeneration] = useState(0);
useEffect(() => {
window.returningAiHostSessionChanged = (nextGeneration) => {
setSessionGeneration(nextGeneration);
};
return () => {
delete window.returningAiHostSessionChanged;
};
}, []);
useEffect(() => {
fetch("/api/config", { credentials: "same-origin", cache: "no-store" })
.then(async (response) => {
const payload = await response.json().catch(() => null);
if (!response.ok || !payload?.widgetId || !payload?.bundleUrl) {
throw new Error("Set the widget ID and bundle URL in the server environment first");
}
return payload;
})
.then(setConfig)
.catch((reason) => setError(reason instanceof Error ? reason.message : "Configuration failed"));
}, []);
return (
<main className="page-shell">
<header className="intro">
<p className="eyebrow">Returning.AI starter</p>
<h1>Authenticated custom widget</h1>
<p>The server mints the short-lived embed token. The browser receives only that token and public widget configuration.</p>
</header>
{error && <div className="error-panel" role="alert"><p>{error}</p></div>}
{config && <WidgetExperience config={config} sessionGeneration={sessionGeneration} />}
<p className="security-note">
The disconnect button clears only the widget session. The host application owns user logout and token revocation.
</p>
</main>
);
}
export { WidgetExperience };Required support files (6)
The shared helpers are required by the binding above. The entry files show initial mounting; merge that wiring into your existing app instead of overwriting its page. Save source at the shown paths, or update imports to match your layout. These are individual frontend files, not a starter ZIP, and contain no backend. Styles are optional if your app supplies its own; remove their import or link if omitted.
Open this frontend in the complete quickstart for the server contract, shared session handling, supplied settings and optional downloads.
Next.js App Router
Tested with Next.js 16.3.4 / React 19.2.3. The browser SDK loads after mount. Keep its import out of server rendering, even inside a file marked use client. A change in the host session must restart the widget session even if its configuration has not changed.
"use client";
import { useEffect, useRef, useState } from "react";
import type { PublicWidgetConfig } from "@/lib/types";
import { createWidgetLifecycle } from "@/lib/shared/widget-lifecycle.js";
const STORAGE_PREFIX = "returning-ai-nextjs-starter";
type WidgetElement = HTMLElement & {
logoutPublic?: () => Promise<void>;
};
function clearStarterStorage(): void {
try {
for (const key of Object.keys(window.localStorage)) {
if (key.startsWith(`${STORAGE_PREFIX}-`)) {
window.localStorage.removeItem(key);
}
}
} catch {
// Storage can be disabled by the browser. Local widget cleanup still runs.
}
}
function setOptionalAttribute(
element: HTMLElement,
name: string,
value: string | undefined,
): void {
if (value) element.setAttribute(name, value);
}
export function WidgetStarter({
config,
sessionGeneration = 0,
}: {
config: PublicWidgetConfig;
/** Host-owned local marker. Never sent to Returning.AI or used as identity. */
sessionGeneration?: string | number;
}) {
const widgetSlotRef = useRef<HTMLDivElement>(null);
const retryRef = useRef<() => void>(() => undefined);
const logoutRef = useRef<() => void>(() => undefined);
const [status, setStatus] = useState("Loading secure session...");
const [error, setError] = useState<string | null>(null);
const [ready, setReady] = useState(false);
const [signedOut, setSignedOut] = useState(false);
const [hostSessionGeneration, setHostSessionGeneration] = useState(sessionGeneration);
useEffect(() => {
setHostSessionGeneration(sessionGeneration);
}, [sessionGeneration]);
useEffect(() => {
const hostWindow = window as Window & {
returningAiHostSessionChanged?: (generation: string | number) => void;
};
hostWindow.returningAiHostSessionChanged = (generation) => {
setHostSessionGeneration(generation);
};
return () => {
delete hostWindow.returningAiHostSessionChanged;
};
}, []);
useEffect(() => {
let active = true;
let stopped = false;
let lifecycle: ReturnType<typeof createWidgetLifecycle>;
const setAppState = (value: string) => {
document.body.dataset.appState = value;
};
const showError = (message: string) => {
if (!active || stopped) return;
setAppState("error");
setReady(false);
setError(message);
setStatus("The widget is not available.");
};
const waitForSdk = async () => {
// Browser-only registration. This import stays inside the client effect so
// Next server rendering never evaluates customElements or window.
await import("@returningai/widget-sdk");
if (customElements.get("rai-custom-widget")) return;
await Promise.race([
customElements.whenDefined("rai-custom-widget"),
new Promise<never>((_, reject) => {
window.setTimeout(
() => reject(new Error("The Returning.AI SDK did not load")),
5000,
);
}),
]);
};
const createWidget = (): WidgetElement => {
const element = document.createElement("rai-custom-widget") as WidgetElement;
element.setAttribute("widget-id", config.widgetId);
element.setAttribute("bundle-url", config.bundleUrl);
element.setAttribute("height", "auto");
element.setAttribute("eager", "");
element.setAttribute("max-retries", "1");
element.setAttribute("storage-prefix", STORAGE_PREFIX);
setOptionalAttribute(element, "api-url", config.apiUrl);
setOptionalAttribute(element, "v2-api-url", config.v2ApiUrl);
setOptionalAttribute(element, "domain-key", config.domainKey);
return element;
};
lifecycle = createWidgetLifecycle({
endpoint: "/api/widget-token",
createWidget,
widgetSlot: widgetSlotRef.current,
onState: (state) => {
if (!active || stopped) return;
setAppState(state);
if (state === "loading") setStatus("Loading secure session...");
if (state === "refreshing") setStatus("Refreshing secure session...");
if (state === "authenticating") setStatus("Authenticating widget...");
},
onError: showError,
onAuthenticated: () => {
if (!active || stopped) return;
setAppState("authenticating");
setStatus("Authenticated. Loading experience...");
},
onMounted: () => {
if (!active || stopped) return;
setAppState("ready");
setReady(true);
setError(null);
setStatus("Experience ready.");
},
onToken: () => {
if (active && !stopped) setError(null);
},
});
const startSession = async () => {
if (!config.widgetId || !config.bundleUrl) {
throw new Error("Set the widget ID and bundle URL in the server environment first");
}
await waitForSdk();
if (!active || stopped) return;
await lifecycle.start({ sessionGeneration: hostSessionGeneration });
};
retryRef.current = () => {
if (stopped) return;
setError(null);
void lifecycle.start({ sessionGeneration: hostSessionGeneration }).catch((reason: unknown) => {
showError(reason instanceof Error ? reason.message : "Widget startup failed");
});
};
logoutRef.current = () => {
if (!active || stopped) return;
stopped = true;
setReady(false);
// dispose() performs local invalidation/removal first. SDK logout is
// best-effort and deliberately not awaited, so a hanging request cannot
// keep the previous account's widget visible.
lifecycle.dispose();
clearStarterStorage();
setError(null);
setSignedOut(true);
setAppState("signed-out");
setStatus("Signed out. Reload this page to start again.");
};
setSignedOut(false);
void startSession().catch((reason: unknown) => {
showError(reason instanceof Error ? reason.message : "Widget startup failed");
});
const handlePageHide = () => {
lifecycle.dispose({ logout: false });
clearStarterStorage();
};
window.addEventListener("pagehide", handlePageHide);
return () => {
active = false;
window.removeEventListener("pagehide", handlePageHide);
stopped = true;
retryRef.current = () => undefined;
logoutRef.current = () => undefined;
lifecycle.dispose();
clearStarterStorage();
};
}, [
config.apiUrl,
config.bundleUrl,
config.domainKey,
config.v2ApiUrl,
config.widgetId,
sessionGeneration,
hostSessionGeneration,
]);
return (
<main className="page-shell">
<header className="intro">
<p className="eyebrow">Returning.AI starter</p>
<h1>Authenticated custom widget</h1>
<p>
The server mints the short-lived embed token. The browser receives only
that token and the supplied widget configuration.
</p>
</header>
<section className="card" aria-labelledby="widget-heading">
<div className="card-heading">
<div>
<p className="eyebrow">SDK 1.8.11</p>
<h2 id="widget-heading">Your experience</h2>
</div>
{!signedOut && (
<button className="button secondary" type="button" onClick={() => logoutRef.current()}>
Disconnect widget
</button>
)}
</div>
<p className="status" role="status" aria-live="polite">
{status}
</p>
{error && (
<div className="error-panel" role="alert">
<p>{error}</p>
<button className="button" type="button" onClick={() => retryRef.current()}>
Try again
</button>
</div>
)}
<div ref={widgetSlotRef} className="widget-slot" aria-live="polite" />
{ready && <p className="ready-note">The SDK mounted the supplied bundle.</p>}
</section>
<p className="security-note">
This disconnect button clears only the widget session. The host application
still owns user logout and any token revocation. Replace the server
host-auth adapter before using this starter with a real session.
</p>
</main>
);
}Required support files (6)
The shared helpers are required by the binding above. The entry files show initial mounting; merge that wiring into your existing app instead of overwriting its page. Save source at the shown paths, or update imports to match your layout. These are individual frontend files, not a starter ZIP, and contain no backend. Styles are optional if your app supplies its own; remove their import or link if omitted.
This page passes public settings directly, so no Node configuration helper or /api/config route is needed. Keep your existing App Router layout, TypeScript setup and @/* alias pointing to your project root.
Open this frontend in the complete quickstart for the server contract, shared session handling, supplied settings and optional downloads.
Vue
Tested with Vue 3.5.21 and Vite 6.3.5. The browser-rendered setup needs compiler configuration for rai-* custom elements. Dispose the previous session before a host-session change or unmount. Nuxt server rendering is outside this example.
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag === "rai-custom-widget",
},
},
}),
],
build: {
outDir: "dist",
emptyOutDir: true,
},
});<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { createWidgetLifecycle } from "../shared/widget-lifecycle.js";
const props = defineProps({
config: { type: Object, required: true },
sessionGeneration: { type: [String, Number], default: 0 },
});
const STORAGE_PREFIX = "returning-ai-vue-node-starter";
const widgetSlot = ref(null);
const status = ref("Loading secure session...");
const error = ref(null);
const ready = ref(false);
const signedOut = ref(false);
let lifecycle;
function clearStarterStorage() {
try {
for (const key of Object.keys(localStorage)) {
if (key.startsWith(`${STORAGE_PREFIX}-`)) localStorage.removeItem(key);
}
} catch {
// Storage can be disabled. The local widget is still removed.
}
}
function createWidget() {
const element = document.createElement("rai-custom-widget");
element.setAttribute("widget-id", props.config.widgetId);
element.setAttribute("bundle-url", props.config.bundleUrl);
element.setAttribute("height", "auto");
element.setAttribute("eager", "");
element.setAttribute("max-retries", "1");
element.setAttribute("storage-prefix", STORAGE_PREFIX);
if (props.config.apiUrl) element.setAttribute("api-url", props.config.apiUrl);
if (props.config.v2ApiUrl) element.setAttribute("v2-api-url", props.config.v2ApiUrl);
if (props.config.domainKey) element.setAttribute("domain-key", props.config.domainKey);
return element;
}
function start() {
if (signedOut.value || !lifecycle) return Promise.resolve();
return lifecycle.start({ sessionGeneration: props.sessionGeneration });
}
onMounted(() => {
lifecycle = createWidgetLifecycle({
endpoint: "/api/widget-token",
createWidget,
widgetSlot: widgetSlot.value,
onState: (value) => {
if (value === "loading") status.value = "Loading secure session...";
if (value === "refreshing") status.value = "Refreshing secure session...";
if (value === "authenticating") status.value = "Authenticating widget...";
},
onError: (message) => {
if (signedOut.value) return;
ready.value = false;
error.value = message;
status.value = "The widget is not available.";
},
onAuthenticated: () => {
status.value = "Authenticated. Loading experience...";
},
onMounted: () => {
ready.value = true;
error.value = null;
status.value = "Experience ready.";
},
onToken: () => {
error.value = null;
},
});
void start().catch((reason) => {
if (!signedOut.value) {
error.value = reason instanceof Error ? reason.message : "Widget startup failed";
}
});
});
watch(() => props.sessionGeneration, () => {
if (!lifecycle || signedOut.value) return;
void start().catch((reason) => {
if (!signedOut.value) {
error.value = reason instanceof Error ? reason.message : "Widget startup failed";
}
});
});
onBeforeUnmount(() => {
lifecycle?.dispose();
clearStarterStorage();
});
function retry() {
if (signedOut.value) return;
error.value = null;
void start().catch((reason) => {
if (!signedOut.value) {
error.value = reason instanceof Error ? reason.message : "Widget startup failed";
}
});
}
function disconnect() {
signedOut.value = true;
ready.value = false;
error.value = null;
// Local cleanup happens before the best-effort, unawaited SDK logout.
lifecycle?.dispose();
clearStarterStorage();
status.value = "Signed out. Reload this page to start again.";
}
</script>
<template>
<section class="card" aria-labelledby="widget-heading">
<div class="card-heading">
<div><p class="eyebrow">SDK 1.8.11</p><h2 id="widget-heading">Your experience</h2></div>
<button v-if="!signedOut" class="button secondary" type="button" @click="disconnect">Disconnect widget</button>
</div>
<p class="status" role="status" aria-live="polite">{{ status }}</p>
<div v-if="error" class="error-panel" role="alert">
<p>{{ error }}</p>
<button class="button" type="button" @click="retry">Try again</button>
</div>
<div ref="widgetSlot" class="widget-slot" aria-live="polite"></div>
<p v-if="ready" class="ready-note">The SDK mounted the supplied bundle.</p>
</section>
</template>Required support files (6)
The shared helpers are required by the binding above. The entry files show initial mounting; merge that wiring into your existing app instead of overwriting its page. Save source at the shown paths, or update imports to match your layout. These are individual frontend files, not a starter ZIP, and contain no backend. Styles are optional if your app supplies its own; remove their import or link if omitted.
Open this frontend in the complete quickstart for the server contract, shared session handling, supplied settings and optional downloads.
Angular
Tested with Angular 22.1.5 and TypeScript 6.0.2. Use a browser-rendered standalone component with custom elements enabled. Connect host-session changes and destruction to cleanup. Angular SSR is not covered by this reference.
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
ElementRef,
Input,
OnChanges,
OnDestroy,
SimpleChanges,
ViewChild,
} from "@angular/core";
import { createWidgetLifecycle } from "../shared/widget-lifecycle.js";
export type PublicWidgetConfig = {
widgetId: string;
bundleUrl: string;
apiUrl?: string;
v2ApiUrl?: string;
domainKey?: string;
};
type WidgetElement = HTMLElement & {
logoutPublic?: () => Promise<void>;
};
@Component({
selector: "rai-widget-experience",
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="card" aria-labelledby="widget-heading">
<div class="card-heading">
<div><p class="eyebrow">SDK 1.8.11</p><h2 id="widget-heading">Your experience</h2></div>
@if (!signedOut) {
<button class="button secondary" type="button" (click)="disconnect()">Disconnect widget</button>
}
</div>
<p class="status" role="status" aria-live="polite">{{ status }}</p>
@if (error) {
<div class="error-panel" role="alert">
<p>{{ error }}</p>
<button class="button" type="button" (click)="retry()">Try again</button>
</div>
}
<div #widgetSlot class="widget-slot" aria-live="polite"></div>
@if (ready) {
<p class="ready-note">The SDK mounted the supplied bundle.</p>
}
</section>
`,
styles: [],
imports: [],
})
export class WidgetExperienceComponent
implements AfterViewInit, OnChanges, OnDestroy
{
@Input({ required: true }) config!: PublicWidgetConfig;
@Input() sessionGeneration: string | number = 0;
@ViewChild("widgetSlot", { static: true }) widgetSlot!: ElementRef<HTMLDivElement>;
status = "Loading secure session...";
error: string | null = null;
ready = false;
signedOut = false;
private lifecycle?: ReturnType<typeof createWidgetLifecycle>;
constructor(private readonly changeDetector: ChangeDetectorRef) {}
ngAfterViewInit(): void {
this.lifecycle = createWidgetLifecycle({
endpoint: "/api/widget-token",
createWidget: () => this.createWidget(),
widgetSlot: this.widgetSlot.nativeElement,
onState: (state) => {
if (state === "loading") this.status = "Loading secure session...";
if (state === "refreshing") this.status = "Refreshing secure session...";
if (state === "authenticating") this.status = "Authenticating widget...";
this.changeDetector.markForCheck();
},
onError: (message) => {
this.ready = false;
this.error = message;
this.status = "The widget is not available.";
this.changeDetector.markForCheck();
},
onAuthenticated: () => {
this.status = "Authenticated. Loading experience...";
this.changeDetector.markForCheck();
},
onMounted: () => {
this.ready = true;
this.error = null;
this.status = "Experience ready.";
this.changeDetector.markForCheck();
},
onToken: () => {
this.error = null;
this.changeDetector.markForCheck();
},
});
void this.start().catch((reason: unknown) => {
this.error = reason instanceof Error ? reason.message : "Widget startup failed";
this.changeDetector.markForCheck();
});
}
ngOnChanges(changes: SimpleChanges): void {
if (changes["sessionGeneration"] && !changes["sessionGeneration"].firstChange) {
void this.start().catch((reason: unknown) => {
this.error = reason instanceof Error ? reason.message : "Widget startup failed";
this.changeDetector.markForCheck();
});
}
}
ngOnDestroy(): void {
this.lifecycle?.dispose();
this.clearStarterStorage();
}
retry(): void {
this.error = null;
void this.start().catch((reason: unknown) => {
this.error = reason instanceof Error ? reason.message : "Widget startup failed";
this.changeDetector.markForCheck();
});
}
disconnect(): void {
this.signedOut = true;
this.ready = false;
// Local cleanup happens before the best-effort, unawaited SDK logout.
this.lifecycle?.dispose();
this.clearStarterStorage();
this.status = "Signed out. Reload this page to start again.";
this.changeDetector.markForCheck();
}
private start(): Promise<void> {
if (this.signedOut || !this.lifecycle) return Promise.resolve();
return this.lifecycle.start({ sessionGeneration: this.sessionGeneration });
}
private createWidget(): WidgetElement {
const element = document.createElement("rai-custom-widget") as WidgetElement;
element.setAttribute("widget-id", this.config.widgetId);
element.setAttribute("bundle-url", this.config.bundleUrl);
element.setAttribute("height", "auto");
element.setAttribute("eager", "");
element.setAttribute("max-retries", "1");
element.setAttribute("storage-prefix", "returning-ai-angular-node-starter");
if (this.config.apiUrl) element.setAttribute("api-url", this.config.apiUrl);
if (this.config.v2ApiUrl) element.setAttribute("v2-api-url", this.config.v2ApiUrl);
if (this.config.domainKey) element.setAttribute("domain-key", this.config.domainKey);
return element;
}
private clearStarterStorage(): void {
try {
for (const key of Object.keys(window.localStorage)) {
if (key.startsWith("returning-ai-angular-node-starter-")) {
window.localStorage.removeItem(key);
}
}
} catch {
// Storage can be disabled. The local widget is still removed.
}
}
}Required support files (8)
The shared helpers are required by the binding above. The entry files show initial mounting; merge that wiring into your existing app instead of overwriting its page. Save source at the shown paths, or update imports to match your layout. These are individual frontend files, not a starter ZIP, and contain no backend. Styles are optional if your app supplies its own; remove their import or link if omitted.
Open this frontend in the complete quickstart for the server contract, shared session handling, supplied settings and optional downloads.
Other frameworks, including Svelte
The SDK exposes browser custom elements. Use the plain HTML binding and support files as the starting contract for another framework. This is not a tested Svelte or SvelteKit example, and there is no separate Svelte starter.
- Load and register the SDK only in the browser after mount. Do not import it during server rendering.
- Connect your framework's mount, unmount and host-session changes to the shared session lifecycle. Remove the old view immediately on logout or account change.
- Use your framework's custom-element configuration and verify its DOM event and attribute handling in your actual version.
- Keep the same protected token endpoint and supplied widget settings. Your frontend choice does not require replacing your backend.