# React (1.5.0)
> Web SDK Integration Guide - add the Revrag AI voice agent to React, Next.js and Preact apps (@revrag-ai/embed-react 1.5.0)
URL: /embed/integration/react/v1.5.0
Markdown: /embed/integration/react/v1.5.0.md
# Revrag Embed — Web Integration Guide [#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](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 `/react17` or `/diagnostics` entry
* no `appVersion` option
* 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-initialize) |
| 2. **Mount** | Wrap your app in one `EmbedProvider` | [§3](#3-mount) |
| 3. **Identify** | Send your user (`USER_DATA`) after login | [Identify your user](#identify-your-user) |
**[Section 5](#5-your-apps-shape) shows the exact setup for your app.** Everything before it applies to every app.
***
## Table of contents [#table-of-contents]
1. [Install](#1-install) — package and stylesheet
2. [Initialize](#2-initialize) — one hook, once, at the root
3. [Mount](#3-mount) — one provider, and the SDK follows the user
4. [Decide where it appears](#4-decide-where-it-appears) — paths, groups, delays, and the call licence
5. [Your app's shape](#5-your-apps-shape) — **pick one**, complete code
6. [Control it from your own code](#6-control-it-from-your-own-code) — calls and the widget card
7. [Events and analytics](#7-events-and-analytics) — what you send, what you can listen to
8. [How paths become screen names](#8-how-paths-become-screen-names)
9. [Verify it works](#9-verify-it-works)
10. [Troubleshooting](#10-troubleshooting)
11. [Known limitations](#11-known-limitations)
12. [Checklist](#12-checklist)
* [Reference](#reference)
***
## 1. Install [#1-install]
Install the package, import the stylesheet once, and check the peer dependencies and requirements.
### Package [#package]
```bash
npm install @revrag-ai/embed-react
# or
yarn add @revrag-ai/embed-react
pnpm add @revrag-ai/embed-react
```
### Stylesheet — required [#stylesheet--required]
Import it once, at your app's entry point:
```ts
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.css` path does not resolve.
### Peer dependencies [#peer-dependencies]
| Package | Range | Required | Notes |
| -------------------- | ---------- | -------- | --------------------------------------------------------------------- |
| `react`, `react-dom` | `>=17.0.0` | Yes | React 17 has its own entry on webpack hosts — see [5D](#5d--react-17) |
| `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 with `ERESOLVE`. Use `npm install --legacy-peer-deps` until the range is widened.
* **`react-router-dom`** — React Router **5** hits the same `ERESOLVE`.
### Requirements [#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](#5d--react-17)) |
| API key | From Revrag, per environment |
**Your API key and environment are a pair.**
* The SDK talks to `https://embed.revrag.ai` unless you pass `baseUrl`.
* A development key used against production (or the reverse) fails initialization.
* `useInitialize` returns the reason in `error`, and the button never appears.
### What it pulls in [#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.
**Read this before pinning versions.** Five known-good stacks (React 19, Next.js App Router, React 17, React 17 + Next.js 12, Preact), the peer ranges, everything the SDK installs into your app, and the workarounds for hosts outside the ranges — like Next.js 12–13 or React 17 on webpack.
***
## 2. Initialize [#2-initialize]
Initialize once at the root, identify your user after login, and call `EmbedLogout()` when they sign out.
* **Call `useInitialize` once**, 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.**
```tsx
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 ; // 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 [#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 [#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 `options` after 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.
* **Unmounting it stops tracking.**
* Leaving the component that called `useInitialize` disposes the SDK's click and route trackers; the button and calls keep working.
* Keep it at or above the component that renders `EmbedProvider`.
* **Every version bump is a dashboard change.**
* On the web you deploy often; a deploy that bumps `appVersion` to a version the dashboard has no config for makes the widget disappear.
* Put "dashboard config exists for this version" on your release checklist.
### Identify your user [#identify-your-user]
After login, and after `isInitialized` is `true`, tell the SDK who the user is:
```tsx
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_id` is the important one** — your stable id for this user. Everything the agent does is attributed to it.
* **Before login, calls still work.** With `enableTracker` on (the default), the SDK registers an anonymous id (`user__`) during initialization. Your `USER_DATA` replaces it.
* **With `enableTracker: false`**, there is no anonymous id: a call started before `USER_DATA` fails with `app_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 [#on-logout]
When your user signs out, call `EmbedLogout()`:
```ts
import { EmbedLogout } from '@revrag-ai/embed-react';
async function signOut() {
await EmbedLogout();
// …your own sign-out
}
```
It does four things, in order:
1. **Ends the call**, including one that is still connecting.
2. **Stops the agent**, cancelling anything it was doing on the page.
3. **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.
4. **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_DATA` for 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 [#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.
```tsx
import { EmbedProvider } from '@revrag-ai/embed-react';
import { useLocation } from 'react-router-dom';
export function RevragShell({ children }: { children: React.ReactNode }) {
const { pathname } = useLocation();
return {children};
}
```
* **The provider renders the button itself.** Do not also render `` — 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 `currentPath` or `usePathHook`, the SDK falls back to `window.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 [#full-prop-list]
Every prop, with its default:
```tsx
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 — narrow what the agent sees
>
{children}
```
The props type is not exported by name; use `React.ComponentProps` 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 [#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 as `currentPath`), matched **exactly and case-sensitively**.
```tsx
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' },
],
};
```
### What this buys you, with no further code [#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 `continuous` group and give them no delay.
* This is stricter than the mobile SDKs, where only exclusion ends a call; see [§11](#11-known-limitations).
**Defining a group turns on the allow-list.**
* Every group's `screens` are added to `includeScreens`. So once you define any group, the button appears **only** on the paths in your groups and in `includeScreens`.
* 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` (with `matchMode="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.
* `excludeScreens` and `showOnAllScreens` are 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 [#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 except `excludeScreens`.
* `false`: `includeScreens` (plus group screens) also limits which paths the agent may see — always by prefix, whatever `matchMode` says.
**`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](#8-how-paths-become-screen-names)) and write the lists against it.
***
## 5. Your app's shape [#5-your-apps-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](#5a--single-page-app-with-react-router) |
| Next.js, App Router | [5B](#5b--nextjs-app-router) |
| Next.js, Pages Router | [5C](#5c--nextjs-pages-router) |
| React 17 (any bundler) | [5D](#5d--react-17) |
| Preact | [5E](#5e--preact) |
| No router — tabs, steps or wizards in state | [5F](#5f--no-router) |
| Several React roots, micro-frontends, iframes, or full page loads | [5G](#5g--several-roots-iframes-and-multi-page-apps) |
### 5A — Single-page app with React Router [#5a--single-page-app-with-react-router]
```tsx
// 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 (
{children}
);
}
// 3) Initialize once, in the component that never unmounts.
function App() {
useInitialize(API_KEY, { appVersion: APP_VERSION });
return (
} />
} />
} />
);
}
createRoot(document.getElementById('root')!).render(
);
```
* **Your pages stay exactly what they are** — no Revrag code in them.
* **`HashRouter` works too:** pass `useLocation().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"` and `includeScreens={['/orders']}`.
* Or exclude it with `excludeScreens={['/orders']}` (exclusion always covers everything below).
### 5B — Next.js, App Router [#5b--nextjs-app-router]
* **`"use client"` ships inside the package**, so the components work from Server Components.
* **`useInitialize` and `usePathname` are hooks**, so they still need one small client component:
```tsx
// 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 (
{children}
);
}
```
```tsx
// 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 (
{children}
);
}
```
* **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 }`.
* **`pathname` is the real path** (`/orders/8812`). For dynamic routes, see the note in [5A](#5a--single-page-app-with-react-router).
### 5C — Next.js, Pages Router [#5c--nextjs-pages-router]
```tsx
// 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.
);
}
```
* **Use `router.pathname`, not `router.asPath`.** `asPath` carries 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](#5d--react-17)), and install with `--legacy-peer-deps` ([§1](#peer-dependencies)).
### 5D — React 17 [#5d--react-17]
On **webpack** hosts — Create React App, Next.js 12, custom webpack — import everything from the React 17 entry:
```tsx
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:
```json
{
"compilerOptions": {
"paths": { "@revrag-ai/embed-react/react17": ["./node_modules/@revrag-ai/embed-react/index.d.ts"] }
}
}
```
### 5E — Preact [#5e--preact]
* **Use the regular package through `preact/compat`.**
* **With Vite, there is no extra config.** `@preact/preset-vite` already aliases `react`, `react-dom` and `react/jsx-runtime` to `preact/compat`:
```ts
// vite.config.ts
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
export default defineConfig({ plugins: [preact()] });
```
* **Then integrate exactly as in [5A](#5a--single-page-app-with-react-router).** With Preact Router or `preact-iso`, pass the current path as `currentPath`.
* **On webpack, add the three aliases to `resolve.alias` yourself.**
* **npm installs `react`/`react-dom` to satisfy the peer range**; the alias means they are never bundled.
### 5F — No router [#5f--no-router]
If your "screens" are state — tabs, wizard steps, a modal-driven flow — give each one a synthetic path:
```tsx
```
**Use stable, privacy-safe values:** never a user id, an account number or a session token.
### 5G — Several roots, iframes, and multi-page apps [#5g--several-roots-iframes-and-multi-page-apps]
* **Several React roots or micro-frontends:** exactly **one** `EmbedProvider` for 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 `useInitialize` and the provider in a layout shared by every page.
***
## 6. Control it from your own code [#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 [#start-and-end-calls]
```tsx
import { startCall, endCall, isCallActive } from '@revrag-ai/embed-react';
```
| Function | Returns | Behaviour |
| ---------------- | --------------- | -------------------------------------------------------------------------------------------- |
| `startCall()` | `Promise` | 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`](#agent-events--embedonagent--embedoffagent).
* A network or token failure raises no event — it logs `Token generation failed` to the console.
**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.inactivityBehavior` to `auto_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.
### The widget card [#the-widget-card]
```tsx
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()` or `collapseWidget()` makes the SDK ignore the dashboard's initial collapsed/expanded setting for that page load.
***
## 7. Events and analytics [#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` [#sending--embedeventevent]
```ts
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`**, every `embedEvent.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 [#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**:
```ts
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` [#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) and `session_id`, plus its own fields.
* **The conversation events** add `call_id`, `reason` and `callDuration` (seconds; `0` on 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](#4-decide-where-it-appears) |
| `room_disconnected` | Network or server |
| `participant_disconnected` | The agent left |
* **Compare against the `AgentEvent` constants**, 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 [#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:
```tsx
// useRevragEventLog.ts
import { useEffect, useRef } from 'react';
import { AgentEvent, embedOnAgent, embedOffAgent, type AgentEventPayload } from '@revrag-ai/embed-react';
const LABELS: Partial> = {
[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
}, []);
}
```
```tsx
// In your root component, development only:
useRevragEventLog({ log: true });
```
**Gate logging to development.**
* Payloads carry `app_user_id` and call ids.
* Do not forward them wholesale to error trackers or third-party analytics.
***
## 8. How paths become screen names [#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.
* **`getScreenName`** names 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, or `null` to fall back to the URL:
```tsx
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` (or `usePathHook`)**, 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, with `enableTracker` on) 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.
***
## 9. Verify it works [#9-verify-it-works]
Turn on debug logs, run the self-check, and walk through a good first run.
### Turn on debug logs [#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_ENV` as something other than `production` at runtime — which many browser builds do not.
* **Turn them on in the browser console** (no reload needed):
```js
window.__revragDebug = true; // false turns them off again
```
**What 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`, the `startCall` warning.
### Run the self-check [#run-the-self-check]
```ts
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 `remediation`** telling 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 [#a-good-first-run]
1. Page load (with `window.__revragDebug = true`) → `[RevRag] …` initialization lines; `useInitialize` returns `isInitialized: true`, `error: null`.
2. Log in → your `USER_DATA` is sent.
3. Navigate to an allowed path → the button appears; `agent_visible` fires.
4. Tap the button and talk → the microphone prompt (first time) → `microphone_permission_allowed` → `agent_conversation_started`.
5. Navigate inside a `continuous` group → the call keeps running.
6. Hang up → `agent_conversation_ended` with `reason: 'manual_disconnect'`.
7. Navigate to an excluded path → the button is gone.
**Invalid patterns:** a call before initialization; `agent_visible` never firing; two buttons.
***
## 10. Troubleshooting [#10-troubleshooting]
Find your symptom below, then work through its causes and fixes.
### The button never appears [#the-button-never-appears]
Check these in order — the first three account for most cases.
1. **Did initialization succeed?** Log `error` from `useInitialize`.
* `app_version is required…` → pass `appVersion`.
* Any other error → check the API key and `baseUrl` belong to the same environment, and that the dashboard has a config for this `appVersion`.
2. **Did you import the stylesheet?** Without `@revrag-ai/embed-react/style.css` the button gets only a minimal fallback style and can look absent.
3. **Does the path match?** Log the `currentPath` you pass. Matching is exact and case-sensitive; a trailing slash counts.
4. **Is the path excluded?** `excludeScreens` beats everything, and covers every path below it.
5. **Is an entrance delay running?** A 3000 ms delay looks like "not working" for three seconds.
6. **Did `useInitialize` run before the provider?** The button renders only once initialization has stored its result. (Unmounting `useInitialize` later only stops tracking; the button stays.)
7. **Is `sessionStorage` blocked?** Strict privacy settings and sandboxed iframes block it; the widget cannot render without it.
### The call ends when the user navigates [#the-call-ends-when-the-user-navigates]
* **Cause:** the button was hidden — and on the web, a hidden button ends the call ([§4](#4-decide-where-it-appears)). Either:
* the next path is outside `includeScreens` or excluded
* the next path has an entrance delay
* your visibility config is written inline and re-applies its delay on re-render
* **Fix:** put the journey in one `continuous` group, give it no delay, and move the config to module scope.
### Two buttons appear [#two-buttons-appear]
* **Cause:** two widgets are mounted — two `EmbedProvider`s (two React roots, a micro-frontend), or a provider *and* an ``.
* **Fix:** keep exactly one provider and no standalone button.
### The call does not start [#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_denied` and tell them how to re-enable it in the site settings.
* **`app_user_id is required`:** you set `enableTracker: false` and have not sent `USER_DATA` yet.
* **Nothing in particular:** turn on debug logs and read the `[RevRag] call` lines; check your CSP allows the call server over `wss:` and `https:`.
### `startCall()` logs "no EmbedProvider mounted" [#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](#11-known-limitations)).
### Next.js: "createContext only works in Client Components" / hook errors [#nextjs-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` / `usePathname` from a Server Component. **Fix:** move them into a client component ([5B](#5b--nextjs-app-router)).
### npm `ERESOLVE` on install [#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 an `overrides` entry). The SDK imports neither package, so this is safe.
* **More:** every out-of-range workaround is in the [Dependency & Compatibility Guide](/embed/integration/react/v1.5.0/react-dependencies#5-outside-the-ranges-workarounds).
### React 17: errors about `useSyncExternalStore` or the animation library [#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](#5d--react-17)).
### TypeScript: "Cannot find module '@revrag-ai/embed-react/react17'" [#typescript-cannot-find-module-revrag-aiembed-reactreact17]
* **Cause:** your `moduleResolution` is `"node"`.
* **Fix:** switch to `"bundler"`, or add a `paths` entry ([5D](#5d--react-17)).
### Agent events never fire [#agent-events-never-fire]
* **Cause:** they are only raised while a user id is stored.
* **Fix:** with `enableTracker: false`, send `USER_DATA` first.
* **Also check** that you compare `event.type` against `AgentEvent` constants.
### Something else [#something-else]
1. Turn on debug logs.
2. Reload.
3. Reproduce.
4. 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 [#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](#on-logout)) |
| 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 [#12-checklist]
Check off each item for your integration.
#### Install and serve [#install-and-serve]
* [ ] `@revrag-ai/embed-react` installed (`--legacy-peer-deps` on Next.js 12–13 or React Router 5)
* [ ] `@revrag-ai/embed-react/style.css` imported once at the entry point
* [ ] Served over HTTPS; `allow="microphone"` on any iframe that hosts the app
#### Initialize and identify [#initialize-and-identify]
* [ ] `useInitialize(apiKey, { appVersion })` once, in a component that never unmounts, at or above the provider
* [ ] API key and `baseUrl` from the same environment; production omits `baseUrl`
* [ ] Dashboard has a widget config (and avatar) for this exact `appVersion` — re-checked on every release that bumps it
* [ ] `error` from `useInitialize` logged
* [ ] `USER_DATA` with your stable `app_user_id` sent after login, after `isInitialized`
* [ ] Sign-out calls `EmbedLogout()` ([§2](#on-logout))
#### Mount and visibility [#mount-and-visibility]
* [ ] Exactly **one** `EmbedProvider`, in a persistent layout, inside the router; no standalone ``
* [ ] `currentPath` (or `usePathHook`) 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 `continuous` group, with no entrance delay
#### Privacy and CSP [#privacy-and-csp]
* [ ] Sensitive fields masked (`data-revrag-mask`) or excluded
* [ ] CSP allows the backend, the call server (`wss:` and `https:`), the avatar host and `corsproxy.io`
#### Verify and test [#verify-and-test]
* [ ] `verifyIntegration()` passes in development
* [ ] A real call tested on desktop Chrome **and** iOS Safari, on the production build and CSP
***
## Reference [#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](/embed/integration/android) · [React Native](/embed/integration/react/v1.5.0-native) |
| Versions & pitfalls | [Dependency & Compatibility Guide](/embed/integration/react/v1.5.0/react-dependencies) |
| Release notes | [React SDK release notes](/embed/integration/react/react-releases) |
* **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 Router
* `examples/react` — React 19 + React Router
* `examples/react17-smoke` — React 17
* `examples/next17-smoke` — Next.js 12, Pages Router
* `examples/preact-poc` — Preact, with Action Intelligence fixtures