Revrag Embed — Web Integration Guide
The complete guide for adding the Revrag AI voice agent to a web app.
| What you get | One floating button that follows your user across every route, and a voice agent that can see your page and act on it |
| Package | @revrag-ai/embed-react 1.5.0 |
| React | 17, 18 or 19 |
| Next.js | App Router and Pages Router |
| Preact | 10, via preact/compat |
| TypeScript | Types included |
| API key | Get it from https://app.revrag.ai |
This guide documents 1.5.0. Do not follow it against 1.4.4 or earlier.
Earlier versions (up to 1.4.4) predate most of it. They have:
- no
/react17or/diagnosticsentry - no
appVersionoption - no Action Intelligence, no call controls and no agent events
- no
EmbedLogout()
Every web app integrates in the same three steps — a Vite or CRA single-page app, a Next.js site on either router, React 17 or Preact:
| Step | What you do | Details |
|---|---|---|
| 1. Initialize | Call useInitialize once, at your app root | §2 |
| 2. Mount | Wrap your app in one EmbedProvider | §3 |
| 3. Identify | Send your user (USER_DATA) after login | Identify your user |
Section 5 shows the exact setup for your app. Everything before it applies to every app.
Table of contents
- Install — package and stylesheet
- Initialize — one hook, once, at the root
- Mount — one provider, and the SDK follows the user
- Decide where it appears — paths, groups, delays, and the call licence
- Your app's shape — pick one, complete code
- Control it from your own code — calls and the widget card
- Events and analytics — what you send, what you can listen to
- How paths become screen names
- Verify it works
- Troubleshooting
- Known limitations
- Checklist
1. Install
Install the package, import the stylesheet once, and check the peer dependencies and requirements.
Package
npm install @revrag-ai/embed-react
# or
yarn add @revrag-ai/embed-react
pnpm add @revrag-ai/embed-reactStylesheet — required
Import it once, at your app's entry point:
import '@revrag-ai/embed-react/style.css';- Without it, the button looks broken. It gets only a minimal fallback (position and layout).
- The path is
@revrag-ai/embed-react/style.css. The old@revrag-ai/embed-react/dist/style.csspath does not resolve.
Peer dependencies
| Package | Range | Required | Notes |
|---|---|---|---|
react, react-dom | >=17.0.0 | Yes | React 17 has its own entry on webpack hosts — see 5D |
next | >=14.0.0 | Optional | The SDK never imports next |
react-router-dom | >=6.0.0 | Optional | The SDK never imports it |
next— on Next.js 12 or 13, npm refuses the install withERESOLVE. Usenpm install --legacy-peer-depsuntil the range is widened.react-router-dom— React Router 5 hits the sameERESOLVE.
Requirements
| React | 17, 18 or 19. Preact 10 through preact/compat |
| Browser | Any current browser with WebRTC and getUserMedia |
| Page | A secure context — HTTPS, or localhost in development. Browsers refuse the microphone anywhere else |
| Rendering | Safe to server-render: nothing touches window at import, and the button renders a hidden placeholder until it hydrates |
| TypeScript | moduleResolution: "bundler" or "nodenext". With "node", the /react17 subpath needs a paths entry (5D) |
| API key | From Revrag, per environment |
Your API key and environment are a pair.
- The SDK talks to
https://embed.revrag.aiunless you passbaseUrl. - A development key used against production (or the reverse) fails initialization.
useInitializereturns the reason inerror, and the button never appears.
What it pulls in
livekit-client(WebRTC) — loaded only when a call starts.lottie-web— bundled into a separate chunk for the avatar animation.framer-motion— drives the widget's animations on the default entry; the React 17 entry replaces it with a small built-in stub.- Expect a noticeable first-load cost. Most of it is the default avatar animation, which ships in the main bundle today.
2. Initialize
Initialize once at the root, identify your user after login, and call EmbedLogout() when they sign out.
- Call
useInitializeonce, in a component that stays mounted for the whole session — your app root, or the root layout. - Never call it inside a route or page component.
import { useInitialize } from '@revrag-ai/embed-react';
const API_KEY = 'YOUR_API_KEY';
const APP_VERSION = '4.2.0'; // the version your dashboard config is provisioned for
export function App() {
const { isInitialized, error } = useInitialize(API_KEY, { appVersion: APP_VERSION });
if (error) console.error('[Revrag] init failed:', error);
return <Routes />; // your app; see section 3 for the provider
}appVersion is required.
- Without it, initialization fails with
app_version is required…and the widget never appears. - The value must match a version your dashboard knows. Revrag provisions the widget's look and behaviour per platform and per app version.
- Source it from your build:
package.json, a build id, or an environment variable.
Options
| Option | Default | Meaning |
|---|---|---|
apiKey (first argument) | — | Your tenant's key, for this environment |
appVersion | — | Required. Selects the dashboard config for this version |
baseUrl | https://embed.revrag.ai | The Revrag backend. Omit it in production; pass the matching URL for any other environment |
enableTracker | true | Click and route tracking, and an anonymous user id until you send a real one (see below) |
What it returns
| Field | Type | Meaning |
|---|---|---|
isInitialized | boolean | The handshake with the backend succeeded |
isLoading | boolean | The handshake is in flight |
error | string | null | Why initialization failed — the only place a failure surfaces |
Three things worth knowing:
- It runs once per API key.
- Changing
optionsafter the first render is ignored. - A failed initialization is not retried until the key changes or the component remounts — so do not render the hook before you know
appVersion. - The handshake gives up after 5 seconds, so on a very slow network it can fail for the rest of that mount.
- Changing
- Unmounting it stops tracking.
- Leaving the component that called
useInitializedisposes the SDK's click and route trackers; the button and calls keep working. - Keep it at or above the component that renders
EmbedProvider.
- Leaving the component that called
- Every version bump is a dashboard change.
- On the web you deploy often; a deploy that bumps
appVersionto a version the dashboard has no config for makes the widget disappear. - Put "dashboard config exists for this version" on your release checklist.
- On the web you deploy often; a deploy that bumps
Identify your user
After login, and after isInitialized is true, tell the SDK who the user is:
import { useEffect } from 'react';
import { embedEvent, EventKeys } from '@revrag-ai/embed-react';
export function useRevragIdentity(isInitialized: boolean, user: { id: string; name?: string } | null) {
useEffect(() => {
if (!isInitialized || !user) return;
void embedEvent.event({
eventKey: EventKeys.USER_DATA,
data: { app_user_id: user.id, name: user.name },
});
}, [isInitialized, user?.id]);
}app_user_idis the important one — your stable id for this user. Everything the agent does is attributed to it.- Before login, calls still work. With
enableTrackeron (the default), the SDK registers an anonymous id (user_<timestamp>_<random>) during initialization. YourUSER_DATAreplaces it. - With
enableTracker: false, there is no anonymous id: a call started beforeUSER_DATAfails withapp_user_id is required. - Send it after initialization. Sent earlier, the request fails with
SDK not initialized. The id is still stored, but the backend never hears about this user until the next event.
On logout
When your user signs out, call EmbedLogout():
import { EmbedLogout } from '@revrag-ai/embed-react';
async function signOut() {
await EmbedLogout();
// …your own sign-out
}It does four things, in order:
- Ends the call, including one that is still connecting.
- Stops the agent, cancelling anything it was doing on the page.
- Wipes this user's cached screens (IndexedDB
revrag_embed_cache). Cached screens are tagged with the user id when they upload, so anything left behind would upload under the next user's id. - Forgets the user id, from memory and from
localStorage.
Worth knowing:
- Your API key and the widget's config stay, so you do not initialize again.
- Send
USER_DATAfor the next user as soon as they log in. EmbedLogout()never rejects and is safe to call twice, or when no call is running.- Until the next
USER_DATA, calls cannot start (app_user_id is required).- The anonymous id that initialization creates is only made on a page load.
- A full reload to your login page restores it, if you want calls to work before login.
- Same function as React Native, which also exports
EmbedLogout().
3. Mount
EmbedProvider is the only component you render. Put it once, around your app.
- Put it in a component that persists across navigation.
- Put it inside your router, so it can read the current path.
import { EmbedProvider } from '@revrag-ai/embed-react';
import { useLocation } from 'react-router-dom';
export function RevragShell({ children }: { children: React.ReactNode }) {
const { pathname } = useLocation();
return <EmbedProvider currentPath={pathname}>{children}</EmbedProvider>;
}- The provider renders the button itself. Do not also render
<EmbedButton />— that mounts a second widget with a second call manager. - Mount it once. Two providers (two React roots, a micro-frontend that mounts its own) means two widgets that drift apart.
- Always pass the path. Without
currentPathorusePathHook, the SDK falls back towindow.location.pathname:- The SDK only re-reads it on browser back/forward.
- Client-side navigations are missed until something re-renders the provider.
Full prop list
Every prop, with its default:
<EmbedProvider
// Where the user is — pass ONE of these (section 8)
currentPath={pathname} // string; pathname only, no query or hash
usePathHook={usePathname} // () => string, called as a hook on every render
// Where the button appears (section 4) — define these OUTSIDE your component
showOnAllScreens={true} // true: excludeScreens apply. false: excludeScreens are ignored
includeScreens={[]} // paths where the button may appear; empty (and no groups) = everywhere
excludeScreens={[]} // paths where it never appears; landing on one ends a call
matchMode="exact" // 'exact' | 'startsWith', for includeScreens
embedButtonDelayMs={0} // entrance delay on every path change
embedButtonVisibilityConfig={undefined} // journeys: groups, continuity, per-group delays
// Action Intelligence
getScreenName={undefined} // () => string | null | undefined — names a screen the URL can't
actionRootRef={undefined} // RefObject<HTMLElement> — narrow what the agent sees
>
{children}
</EmbedProvider>The props type is not exported by name; use React.ComponentProps<typeof EmbedProvider> if you need it.
The provider does more than render the button.
- It keeps the agent's highlight overlay mounted even where the button is hidden, and starts Action Intelligence for the session.
- It wraps its own UI in an error boundary: if the widget ever fails to render, it hides itself and logs
[RevRag] The voice widget crashed and was hidden; your app is unaffected. - Your own components are never inside that boundary.
4. Decide where it appears
Choose which paths show the button, which journeys keep a call alive, and any entrance delays.
- You do not register screens; you decide about paths.
- A path is
location.pathname(or whatever you pass ascurrentPath), matched exactly and case-sensitively.
import type { EmbedButtonVisibilityConfig } from '@revrag-ai/embed-react';
// Module scope — never inline these in JSX (see the callout below).
// Never show the button on these, and END a live call when the user lands on one.
// Matches the path itself and anything below it: '/settings' also covers '/settings/pin'.
const EXCLUDED = ['/login', '/otp', '/settings'];
// Optional allow-list. Empty means "everywhere not excluded" — but only while you
// define no groups: group screens join this list (see the warning below).
const INCLUDED: string[] = [];
const VISIBILITY: EmbedButtonVisibilityConfig = {
defaultDelayMs: 0,
groups: [
// A journey: the button stays put — no re-delay, and a live call keeps running —
// while the user moves between these paths.
{ id: 'loan', screens: ['/loans', '/loans/apply', '/loans/review'], continuity: 'continuous' },
// A per-screen group: re-evaluated on every path change.
{ id: 'help', screens: ['/help'], continuity: 'perScreen', delayMs: 3000, delayPolicy: 'oncePerAppSession' },
],
};
<EmbedProvider
currentPath={pathname}
excludeScreens={EXCLUDED}
includeScreens={INCLUDED}
embedButtonVisibilityConfig={VISIBILITY}
>What this buys you, with no further code
| User does | Widget does |
|---|---|
Moves between paths in one continuous group | Stays visible — no blink, no re-delay; a live call keeps running |
| Moves between allowed paths with no delay | Stays visible; a live call keeps running |
| Enters a path that has an entrance delay | Hides for the delay, then appears — a live call ends |
Opens a path outside includeScreens | Hides — a live call ends |
Opens a path in excludeScreens | Hides — a live call ends, and the agent stops seeing the page |
| Reloads the page | Starts fresh: the call ends; the SDK initializes again |
Where the button disappears, the call ends.
- On the web the call lives inside the button, so any hide ends it — an excluded path, a path outside
includeScreens, or an entrance delay. - To keep a call alive across a journey, put those paths in one
continuousgroup and give them no delay. - This is stricter than the mobile SDKs, where only exclusion ends a call; see §11.
Defining a group turns on the allow-list.
- Every group's
screensare added toincludeScreens. So once you define any group, the button appears only on the paths in your groups and inincludeScreens. - Everywhere else it is hidden, ending a live call. The example above shows the button only on the three loan paths and
/help. - To keep it on other paths too, add them to
includeScreens(withmatchMode="startsWith", a section prefix like'/accounts'covers everything below it).
Define includeScreens and embedButtonVisibilityConfig outside your component.
- The provider re-checks visibility whenever their identity changes.
- Written inline in JSX, they are new on every render — and with a delay configured, every parent re-render hides the button again, ending a live call.
excludeScreensandshowOnAllScreensare compared by content, so inline values are harmless there; but a change to them takes effect on the next path change, not immediately.
All options
EmbedProvider visibility props
| Prop | Default | Meaning |
|---|---|---|
includeScreens | [] | Paths where the button may appear. Empty means everywhere (that is not excluded). Group screens count as included too |
excludeScreens | [] | Paths where it never appears. Matches the path or anything below it. Wins over includes and groups — unless showOnAllScreens is false, which ignores it |
showOnAllScreens | true | Despite the name, it does not force the button on. true: excludeScreens apply. false: excludeScreens are ignored |
matchMode | 'exact' | How includeScreens match: 'exact', or 'startsWith' (the path, or anything below it) |
embedButtonDelayMs | 0 | Entrance delay in ms on every path change, unless a group or defaultDelayMs says otherwise |
embedButtonVisibilityConfig | — | Groups (journeys) and a default delay |
excludeScreens— landing on one ends a live call and stops the agent seeing the page.showOnAllScreens— effect on the paths the agent may see:true: the agent may see every path exceptexcludeScreens.false:includeScreens(plus group screens) also limits which paths the agent may see — always by prefix, whatevermatchModesays.
EmbedButtonVisibilityConfig
| Field | Default | Meaning |
|---|---|---|
defaultDelayMs | — | Delay for paths that no group sets one for. Wins over embedButtonDelayMs |
groups | [] | Journeys |
EmbedButtonGroupConfig
| Field | Default | Meaning |
|---|---|---|
id | — | Any stable string |
screens | — | The paths in this journey, matched exactly |
continuity | — (required) | 'continuous' keeps the button and a live call across the group; 'perScreen' re-evaluates on every path |
delayMs | — | Entrance delay for this group. Wins over defaultDelayMs and embedButtonDelayMs |
delayPolicy | 'perScreen' | 'perScreen' (every path change), 'oncePerGroupEntry' (only when entering the group), 'oncePerAppSession' (once per group until the page reloads or the provider remounts) |
screens— membership also matches the last path segment, so'apply'matches/loans/apply— but the button only shows there if the full path is also included.delayPolicy— for'oncePerAppSession', it counts only once the delay has finished.
Delay precedence: group delayMs → defaultDelayMs → embedButtonDelayMs.
A path in includeScreens that your app never reports does not error — it silently hides the button there.
- Log the path you pass (§8) and write the lists against it.
5. Your app's shape
Find your app below. Each is complete and copy-pasteable.
| Your app | Go to |
|---|---|
| Vite or CRA single-page app with React Router | 5A |
| Next.js, App Router | 5B |
| Next.js, Pages Router | 5C |
| React 17 (any bundler) | 5D |
| Preact | 5E |
| No router — tabs, steps or wizards in state | 5F |
| Several React roots, micro-frontends, iframes, or full page loads | 5G |
5A — Single-page app with React Router
// src/main.tsx — the entire integration
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Route, Routes, useLocation } from 'react-router-dom';
import { EmbedProvider, useInitialize } from '@revrag-ai/embed-react';
import '@revrag-ai/embed-react/style.css';
const API_KEY = import.meta.env.VITE_REVRAG_API_KEY as string;
const APP_VERSION = import.meta.env.VITE_APP_VERSION as string;
// 1) Visibility config at module scope (section 4).
const EXCLUDED = ['/login', '/otp'];
// 2) The provider lives INSIDE the router, so it can read the path.
function RevragShell({ children }: { children: React.ReactNode }) {
const { pathname } = useLocation();
return (
<EmbedProvider currentPath={pathname} excludeScreens={EXCLUDED}>
{children}
</EmbedProvider>
);
}
// 3) Initialize once, in the component that never unmounts.
function App() {
useInitialize(API_KEY, { appVersion: APP_VERSION });
return (
<BrowserRouter>
<RevragShell>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/loans" element={<Loans />} />
<Route path="/login" element={<Login />} />
</Routes>
</RevragShell>
</BrowserRouter>
);
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);- Your pages stay exactly what they are — no Revrag code in them.
HashRouterworks too: passuseLocation().pathname, which is the part after#.
Dynamic segments: useLocation().pathname is the real path.
- It is
/orders/8812, not/orders/:id. - Allow a whole subtree with
matchMode="startsWith"andincludeScreens={['/orders']}. - Or exclude it with
excludeScreens={['/orders']}(exclusion always covers everything below).
5B — Next.js, App Router
"use client"ships inside the package, so the components work from Server Components.useInitializeandusePathnameare hooks, so they still need one small client component:
// app/revrag-root.tsx
'use client';
import type { ReactNode } from 'react';
import { usePathname } from 'next/navigation';
import { EmbedProvider, useInitialize } from '@revrag-ai/embed-react';
const API_KEY = process.env.NEXT_PUBLIC_REVRAG_API_KEY as string;
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION as string;
const EXCLUDED = ['/login'];
export function RevragRoot({ children }: { children: ReactNode }) {
useInitialize(API_KEY, { appVersion: APP_VERSION });
const pathname = usePathname();
return (
<EmbedProvider currentPath={pathname ?? undefined} excludeScreens={EXCLUDED}>
{children}
</EmbedProvider>
);
}// app/layout.tsx — a Server Component
import type { ReactNode } from 'react';
import '@revrag-ai/embed-react/style.css';
import { RevragRoot } from './revrag-root';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<RevragRoot>{children}</RevragRoot>
</body>
</html>
);
}- Use the root layout. It persists across navigations, so the provider — and a live call — survive route changes. A provider in a page component remounts on every navigation.
- Expose the version at build time, e.g. in
next.config:env: { NEXT_PUBLIC_APP_VERSION: require('./package.json').version }. pathnameis the real path (/orders/8812). For dynamic routes, see the note in 5A.
5C — Next.js, Pages Router
// pages/_app.tsx — the entire integration
import type { AppProps } from 'next/app';
import { useRouter } from 'next/router';
import { EmbedProvider, useInitialize } from '@revrag-ai/embed-react';
import '@revrag-ai/embed-react/style.css';
const EXCLUDED = ['/login'];
export default function MyApp({ Component, pageProps }: AppProps) {
useInitialize(process.env.NEXT_PUBLIC_REVRAG_API_KEY as string, {
appVersion: process.env.NEXT_PUBLIC_APP_VERSION as string,
});
const router = useRouter();
return (
// router.pathname is the route PATTERN — '/orders/[id]' — ideal for screen lists.
<EmbedProvider currentPath={router.pathname} excludeScreens={EXCLUDED}>
<Component {...pageProps} />
</EmbedProvider>
);
}- Use
router.pathname, notrouter.asPath.asPathcarries the query string and hash, so it never matches your lists exactly. - On Next.js 12 with React 17, import from
@revrag-ai/embed-react/react17(5D), and install with--legacy-peer-deps(§1).
5D — React 17
On webpack hosts — Create React App, Next.js 12, custom webpack — import everything from the React 17 entry:
import { EmbedProvider, useInitialize, embedEvent, EventKeys } from '@revrag-ai/embed-react/react17';
import '@revrag-ai/embed-react/style.css';- The API is identical; only the import path changes.
- The React 17 entry swaps out the animation library (
framer-motion), which webpack rejects on React 17, for a small built-in fallback; everything else is the same code. - On Vite, the default entry also works on React 17.
With TypeScript on moduleResolution: "node", map the subpath:
{
"compilerOptions": {
"paths": { "@revrag-ai/embed-react/react17": ["./node_modules/@revrag-ai/embed-react/index.d.ts"] }
}
}5E — Preact
- Use the regular package through
preact/compat. - With Vite, there is no extra config.
@preact/preset-vitealready aliasesreact,react-domandreact/jsx-runtimetopreact/compat:
// vite.config.ts
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
export default defineConfig({ plugins: [preact()] });- Then integrate exactly as in 5A. With Preact Router or
preact-iso, pass the current path ascurrentPath. - On webpack, add the three aliases to
resolve.aliasyourself. - npm installs
react/react-domto satisfy the peer range; the alias means they are never bundled.
5F — No router
If your "screens" are state — tabs, wizard steps, a modal-driven flow — give each one a synthetic path:
<EmbedProvider currentPath={`/onboarding/${step}`} excludeScreens={['/onboarding/pin']}>
<Onboarding step={step} />
</EmbedProvider>Use stable, privacy-safe values: never a user id, an account number or a session token.
5G — Several roots, iframes, and multi-page apps
- Several React roots or micro-frontends: exactly one
EmbedProviderfor the whole page. Render it in the shell that owns navigation; the other roots need no Revrag code. - The app runs inside an iframe: the iframe needs
allow="microphone", and the agent sees only that iframe's document. - Your page embeds iframes: a cross-origin iframe (a payment form, a third-party widget) is one opaque box to the agent. It cannot read or fill it.
- Multi-page app (full page loads): each page load initializes the SDK again, and a live call ends on every full navigation. Put
useInitializeand the provider in a layout shared by every page.
6. Control it from your own code
Start and end calls, and open or close the widget card, from your own code.
Start and end calls
import { startCall, endCall, isCallActive } from '@revrag-ai/embed-react';
<button onClick={() => void startCall()}>Talk to an expert</button>
<button onClick={() => endCall()} disabled={!isCallActive()}>End call</button>| Function | Returns | Behaviour |
|---|---|---|
startCall() | Promise<void> | Connects a call. Never rejects, and does nothing if a call is already connecting or live |
endCall() | void | Ends the call. A no-op when there is none |
isCallActive() | boolean | true while a call is connected |
startCall()— call errors:- A denied microphone raises
microphone_permission_denied. - A network or token failure raises no event — it logs
Token generation failedto the console.
- A denied microphone raises
Three things worth knowing:
- Call
startCall()from a click. The browser shows the microphone prompt then, and may block audio playback for calls started without a user gesture. - The button must be on screen. The call lives in the button: on a path where it is hidden,
startCall()only warns[RevRag] Cannot startCall: SDK not initialized (no EmbedProvider mounted)and does nothing. - The dashboard can start calls too — make sure that is what your product wants.
- If the dashboard sets
collapsedView.inactivityBehaviortoauto_trigger, the widget expands and starts a call by itself once the nudge delay (default 1.5 s) has passed after the button appears. - It does so whatever the user is doing, and again each time the button reappears.
- It starts without a click, so the browser may block the microphone prompt or audio.
- If the dashboard sets
The widget card
import { expandWidget, collapseWidget, isWidgetExpanded, subscribeWidgetExpanded } from '@revrag-ai/embed-react';
expandWidget(); // open the card; returns true if it changed
collapseWidget(); // back to the floating button; returns true if it changed
isWidgetExpanded(); // boolean
useEffect(() => {
setOpen(isWidgetExpanded()); // the listener is not called with the current value
return subscribeWidgetExpanded((expanded) => setOpen(expanded)); // returns the unsubscribe
}, []);- These only change what is shown. They start no call and send no analytics.
- Before the dashboard config has loaded, calling
expandWidget()orcollapseWidget()makes the SDK ignore the dashboard's initial collapsed/expanded setting for that page load.
7. Events and analytics
Send your own context to Revrag, and listen to what the widget and the call are doing.
There are two separate systems:
- Data events —
embedEvent.event(…). You send context to Revrag (who the user is, what they did). They go to the backend. - Agent events —
embedOnAgent(…). The SDK tells you what the widget and the call are doing. They stay in the browser (and are also reported to Revrag).
Sending — embedEvent.event
import { embedEvent, EventKeys } from '@revrag-ai/embed-react';
// Who the user is — after login (section 2).
await embedEvent.event({ eventKey: EventKeys.USER_DATA, data: { app_user_id: 'user-123', email: 'a@b.co' } });
// Something happened in your product. event_name is required for ANALYTICS_DATA.
await embedEvent.event({
eventKey: EventKeys.ANALYTICS_DATA,
event_name: 'loan_offer_viewed',
data: { amount: 500000 },
context: { screen: '/loans', flow: 'loan_application' },
});
// Free-form context for the agent.
await embedEvent.event({ eventKey: EventKeys.CUSTOM_EVENT, data: { cart_value: 1299 } });- As long as you pass
data, everyembedEvent.event(…)call resolves with{ success, error? }and does not throw. - Without
data, the promise rejects. Pass{}if you have nothing.
| Key | Wire value | Use it for |
|---|---|---|
EventKeys.USER_DATA | user_data | Who the user is. Must carry app_user_id |
EventKeys.ANALYTICS_DATA | analytics_data | Named product events. Requires event_name |
EventKeys.CUSTOM_EVENT | custom_event | Free-form context |
Every key except USER_DATA needs a user id — your USER_DATA, or the anonymous one from initialization.
Watch every event in the browser
Register a listener to see the events you send and the ones the SDK raises. removeCallback matches on the same function instance:
import { embedEvent, type CustomEventPayload } from '@revrag-ai/embed-react';
const onEvent = (event: CustomEventPayload) => console.debug('[revrag event]', event.type, event);
embedEvent.addCallback(onEvent);
// later
embedEvent.removeCallback(onEvent);Agent events — embedOnAgent / embedOffAgent
AgentEvent | Value | Fires when |
|---|---|---|
AGENT_VISIBLE | agent_visible | The button becomes visible on the current path |
AGENT_CONVERSATION_STARTED | agent_conversation_started | The agent joins a connected call (a moment after isCallActive() turns true) |
AGENT_CONVERSATION_ENDED | agent_conversation_ended | A call the agent had joined ends, for any reason. A call that fails before the agent joins raises none |
POPUP_MESSAGE_VISIBLE | popup_message_visible | The inactivity nudge appears |
AGENT_TAP_TO_OPEN / AGENT_TAP_TO_CLOSE | agent_tap_to_open / agent_tap_to_close | The user expands or collapses the widget |
MICROPHONE_PERMISSION_ALLOWED | microphone_permission_allowed | Microphone access granted |
MICROPHONE_PERMISSION_DENIED | microphone_permission_denied | Microphone access denied — carries error_message and error_name |
GEN_TOOL_TRIGGERED | gen_tool_triggered | Reserved; not emitted on web yet |
AGENT_CONNECTED / AGENT_DISCONNECTED | agent_connected / agent_disconnected | Deprecated, never emitted. Use the conversation events |
Payload:
- Every event has
type,timestamp(ISO string) andsession_id, plus its own fields. - The conversation events add
call_id,reasonandcallDuration(seconds;0on start).
reason on an end is one of:
reason | Meaning |
|---|---|
manual_disconnect | Hung up, or the user landed on an excluded path |
destroyed | The button unmounted — see §4 |
room_disconnected | Network or server |
participant_disconnected | The agent left |
- Compare against the
AgentEventconstants, never string literals. - Agent events are only raised while a user id is stored — your
USER_DATA, or the anonymous one.
A drop-in event logger
Paste this once to see every agent event with a readable label. It is the fastest way to watch the widget working, and the hook where you forward events to your own analytics:
// useRevragEventLog.ts
import { useEffect, useRef } from 'react';
import { AgentEvent, embedOnAgent, embedOffAgent, type AgentEventPayload } from '@revrag-ai/embed-react';
const LABELS: Partial<Record<string, string>> = {
[AgentEvent.AGENT_VISIBLE]: 'button visible',
[AgentEvent.AGENT_TAP_TO_OPEN]: 'user opened the widget',
[AgentEvent.AGENT_TAP_TO_CLOSE]: 'user closed the widget',
[AgentEvent.POPUP_MESSAGE_VISIBLE]: 'nudge shown',
[AgentEvent.MICROPHONE_PERMISSION_ALLOWED]: 'microphone allowed',
[AgentEvent.MICROPHONE_PERMISSION_DENIED]: 'microphone DENIED',
[AgentEvent.AGENT_CONVERSATION_STARTED]: 'call started',
[AgentEvent.AGENT_CONVERSATION_ENDED]: 'call ended',
};
export function useRevragEventLog(options: { log?: boolean; onEvent?: (e: AgentEventPayload) => void } = {}) {
const latest = useRef(options);
latest.current = options;
useEffect(() => {
const handle = embedOnAgent((event) => {
if (latest.current.log) console.info(`[revrag] ${LABELS[event.type] ?? event.type}`, event);
latest.current.onEvent?.(event);
});
return () => embedOffAgent(handle); // one subscription, released on unmount
}, []);
}// In your root component, development only:
useRevragEventLog({ log: true });Gate logging to development.
- Payloads carry
app_user_idand call ids. - Do not forward them wholesale to error trackers or third-party analytics.
8. How paths become screen names
Learn where the SDK reads the path from, the rules for the path you pass, and how getScreenName names screens.
The SDK identifies "where the user is" by a path:
| Source | When it is used | |
|---|---|---|
| 1 | currentPath | You pass it — recommended |
| 2 | usePathHook() | You pass a hook (e.g. usePathname); called on every render, so pass a stable function and never switch between passing it and not |
| 3 | window.location.pathname | Nothing passed. Only re-read on browser back/forward — client-side navigations are missed |
Rules worth knowing:
- Paths are matched exactly and are case-sensitive.
/Loans≠/loans, and/loans/≠/loans. - Pass only the pathname as
currentPath— the button's lists match it exactly, so strip?…and#…if your router includes them. - Never put identifiers in a path you control (
/onboarding/${userId}): paths become the agent's screen map and travel to Revrag. getScreenNamenames the screen the user is on, for when the URL alone can't — a wizard whose steps share one path, or screens chosen by a query parameter.-
Without it, the screen is the path plus the hash.
-
It takes no arguments: read your own state (or
location) and return a name, ornullto fall back to the URL:getScreenName={() => (pathname === '/apply' ? `/apply/step-${step}` : null)} -
Its value is the screen's identity, not a display label. The agent's screen map is keyed on it, so two screens must never return the same name, and one screen must not return different names.
-
It runs on every capture, so keep it cheap.
-
It does not affect where the button appears, which always uses the path.
-
- Your path is the single source of truth.
- Once you pass
currentPath(orusePathHook), it is the only route the agent, the screen map and Revrag's analytics see. The SDK's own route inference (pushState,replaceState, back/forward,hashchange, Next.js route events, withenableTrackeron) is ignored. - Without either prop, that inference is the source instead — it reports the path plus hash, and on Next.js the full URL with query, so keep tokens and personal data out of query strings and hashes.
- Once you pass
9. Verify it works
Turn on debug logs, run the self-check, and walk through a good first run.
Turn on debug logs
- The SDK's debug logs are often off, even in development. They are on only when your bundle defines
process.env.NODE_ENVas something other thanproductionat runtime — which many browser builds do not. - Turn them on in the browser console (no reload needed):
window.__revragDebug = true; // false turns them off againWhat the logs look like:
- Every line starts with
[RevRag]and an area —call,runtime,scope,capture,cache,sync,widget. - Messages to and from the agent print as
[RevRag ▸ …](outgoing) and[RevRag ◂ …](incoming), with one JSON line per message under[RevRag wire]. - Failures log to the console even with debug off — a failed API request,
Token generation failed, thestartCallwarning.
Run the self-check
import { verifyIntegration } from '@revrag-ai/embed-react/diagnostics';
const result = await verifyIntegration(); // prints a report to the console
console.log(result.ok, result.summary);- Run it in development after you have logged in and visited two routes.
- Pass
{ offline: true }to skip the checks that make network requests.
| Id | Check |
|---|---|
| V1 | SDK initialized |
| V2 | API key accepted — reads the stored initialization result, so it also fails when sessionStorage is blocked |
| V3 | Provider mounted exactly once |
| V4 | Widget rendered, or hidden with a reason |
| V5 | User identity set |
| V6 | Route changes detected (needs two visited routes; reads the screen cache, so it fails when the dashboard turns localCache off, or right after EmbedLogout()) |
| V7 | Events accepted — sends a real custom_event named revrag_verify_integration |
| V8 | Capture healthy — fails only if nothing actionable is captured or the SDK's own UI leaks in; unlabeled controls and truncated containers are notes |
| V9 | Masking honored |
| V10 | The Revrag backend is reachable (it does not check the call server or the avatar host) |
- Each failing check carries a
remediationtelling you what to change. - Skipped checks: V7 when no user id is stored; V9 when the page has no masked fields.
- Pass
{ report: false }to skip the console report.
A good first run
- Page load (with
window.__revragDebug = true) →[RevRag] …initialization lines;useInitializereturnsisInitialized: true,error: null. - Log in → your
USER_DATAis sent. - Navigate to an allowed path → the button appears;
agent_visiblefires. - Tap the button and talk → the microphone prompt (first time) →
microphone_permission_allowed→agent_conversation_started. - Navigate inside a
continuousgroup → the call keeps running. - Hang up →
agent_conversation_endedwithreason: 'manual_disconnect'. - Navigate to an excluded path → the button is gone.
Invalid patterns: a call before initialization; agent_visible never firing; two buttons.
10. Troubleshooting
Find your symptom below, then work through its causes and fixes.
The button never appears
Check these in order — the first three account for most cases.
- Did initialization succeed? Log
errorfromuseInitialize.app_version is required…→ passappVersion.- Any other error → check the API key and
baseUrlbelong to the same environment, and that the dashboard has a config for thisappVersion.
- Did you import the stylesheet? Without
@revrag-ai/embed-react/style.cssthe button gets only a minimal fallback style and can look absent. - Does the path match? Log the
currentPathyou pass. Matching is exact and case-sensitive; a trailing slash counts. - Is the path excluded?
excludeScreensbeats everything, and covers every path below it. - Is an entrance delay running? A 3000 ms delay looks like "not working" for three seconds.
- Did
useInitializerun before the provider? The button renders only once initialization has stored its result. (UnmountinguseInitializelater only stops tracking; the button stays.) - Is
sessionStorageblocked? Strict privacy settings and sandboxed iframes block it; the widget cannot render without it.
The call ends when the user navigates
- Cause: the button was hidden — and on the web, a hidden button ends the call (§4). Either:
- the next path is outside
includeScreensor excluded - the next path has an entrance delay
- your visibility config is written inline and re-applies its delay on re-render
- the next path is outside
- Fix: put the journey in one
continuousgroup, give it no delay, and move the config to module scope.
Two buttons appear
- Cause: two widgets are mounted — two
EmbedProviders (two React roots, a micro-frontend), or a provider and an<EmbedButton />. - Fix: keep exactly one provider and no standalone button.
The call does not start
- No microphone prompt: the page is not a secure context (HTTPS), or it runs in an iframe without
allow="microphone". - The user denied the microphone: listen for
microphone_permission_deniedand tell them how to re-enable it in the site settings. app_user_id is required: you setenableTracker: falseand have not sentUSER_DATAyet.- Nothing in particular: turn on debug logs and read the
[RevRag] calllines; check your CSP allows the call server overwss:andhttps:.
startCall() logs "no EmbedProvider mounted"
- Cause: the button is not mounted on this path (hidden by visibility), so there is nothing to start a call from.
- In development: with React
StrictMode, this can also happen on a path where the button is visible — production is unaffected (§11).
Next.js: "createContext only works in Client Components" / hook errors
- Cause: you are on
1.4.4, whose files carry no"use client". Fix: upgrade. - Cause: you called
useInitialize/usePathnamefrom a Server Component. Fix: move them into a client component (5B).
npm ERESOLVE on install
- Cause: you are on Next.js 12–13 or React Router 5, which the peer range does not include yet.
- Fix: install with
--legacy-peer-deps(or anoverridesentry). The SDK imports neither package, so this is safe. - More: every out-of-range workaround is in the Dependency & Compatibility Guide.
React 17: errors about useSyncExternalStore or the animation library
- Cause: you are on a webpack host using the default entry.
- Fix: import from
@revrag-ai/embed-react/react17(5D).
TypeScript: "Cannot find module '@revrag-ai/embed-react/react17'"
- Cause: your
moduleResolutionis"node". - Fix: switch to
"bundler", or add apathsentry (5D).
Agent events never fire
- Cause: they are only raised while a user id is stored.
- Fix: with
enableTracker: false, sendUSER_DATAfirst. - Also check that you compare
event.typeagainstAgentEventconstants.
Something else
- Turn on debug logs.
- Reload.
- Reproduce.
- Send Revrag:
- the console output from page load
- the
verifyIntegration()report - the SDK version
- the browser and OS
- the list of paths you pass as
currentPath
11. Known limitations
Each is a current behaviour, its effect, and what to do.
| Id | Limitation | What to do |
|---|---|---|
| W1 | Hiding the button ends a call. The call lives in the button, so an entrance delay or a path outside includeScreens ends it, not only excludeScreens | continuous groups without delays for journeys |
| W2 | After EmbedLogout(), calls need a new user id. The anonymous id is only created on a page load | Send USER_DATA after the next login, or reload to your login page (§2) |
| W3 | Call errors are not shown in the widget, and only one reaches your code. A denied microphone raises microphone_permission_denied | Handle microphone_permission_denied with your own message; for connection failures, treat "no agent_conversation_started within a few seconds of startCall()" as a failure |
| W4 | A failed initialization is not retried until the API key changes or the component remounts | Render useInitialize only once you know appVersion |
| W5 | sessionStorage is required. Where it is blocked, the widget does not render | — |
| W6 | startCall() and isCallActive() in development StrictMode may report no provider. Production builds are unaffected | — |
| W7 | Without currentPath or usePathHook, the button misses client-side navigations, and the agent falls back to the SDK's inferred routes | Always pass one |
| W8 | First-load size: the default avatar animation ships in the main bundle | — |
| W9 | Peer ranges exclude Next.js 12–13 and React Router 5 | --legacy-peer-deps |
| W10 | No automatic PII detection; unmasked text and input values are captured and cached off-call, and click and route tracking ignore masking | Mask sensitive fields (data-revrag-mask), keep personal data out of URLs, and disclose to your security reviewer |
| W11 | The avatar fetch falls back to corsproxy.io (Revrag's own S3 avatars fall back to /s3-lottie/… on your site instead) | Allow it in CSP, or ask Revrag to serve your avatar from a CORS-enabled origin |
| W12 | Debug logs are often off in development too (they depend on a runtime process.env.NODE_ENV) | window.__revragDebug = true |
- W3 — a failed connection (token, network, a room that drops before the agent joins) raises no event at all — the button simply returns to idle.
12. Checklist
Check off each item for your integration.
Install and serve
-
@revrag-ai/embed-reactinstalled (--legacy-peer-depson Next.js 12–13 or React Router 5) -
@revrag-ai/embed-react/style.cssimported once at the entry point - Served over HTTPS;
allow="microphone"on any iframe that hosts the app
Initialize and identify
-
useInitialize(apiKey, { appVersion })once, in a component that never unmounts, at or above the provider - API key and
baseUrlfrom the same environment; production omitsbaseUrl - Dashboard has a widget config (and avatar) for this exact
appVersion— re-checked on every release that bumps it -
errorfromuseInitializelogged -
USER_DATAwith your stableapp_user_idsent after login, afterisInitialized - Sign-out calls
EmbedLogout()(§2)
Mount and visibility
- Exactly one
EmbedProvider, in a persistent layout, inside the router; no standalone<EmbedButton /> -
currentPath(orusePathHook) passed — pathname only, no query, hash or identifiers - Visibility lists and config defined at module scope, written against the paths your app actually reports
- Login, OTP, PIN and payment paths in
excludeScreens - Journeys that must keep a call alive are one
continuousgroup, with no entrance delay
Privacy and CSP
- Sensitive fields masked (
data-revrag-mask) or excluded - CSP allows the backend, the call server (
wss:andhttps:), the avatar host andcorsproxy.io
Verify and test
-
verifyIntegration()passes in development - A real call tested on desktop Chrome and iOS Safari, on the production build and CSP
Reference
Look up package entries, working examples, the backend contract and the other platform guides.
| Package | @revrag-ai/embed-react — entries ., /react17, /diagnostics, /style.css (also /styles/widget.css) |
| Working examples | examples/nextjs · examples/react · examples/react17-smoke · examples/next17-smoke · examples/preact-poc |
| Backend contract | docs/BE_DATA_CONTRACT.md · docs/WEB_TREE_PAYLOADS.md |
| Other platforms | Android · React Native |
| Versions & pitfalls | Dependency & Compatibility Guide |
| Release notes | React SDK release notes |
- Working examples — copy imports from this guide, not from them. Most build against the SDK's source through bundler aliases — including
…/dist/style.css, which does not resolve from the published package.examples/nextjs— Next.js, App Routerexamples/react— React 19 + React Routerexamples/react17-smoke— React 17examples/next17-smoke— Next.js 12, Pages Routerexamples/preact-poc— Preact, with Action Intelligence fixtures