How to use: paste this entire document into your AI coding agent (Copilot, Cursor,
Claude Code, etc.) inside the React Native app you want to integrate. The agent will
inspect your app, install and configure @revrag-ai/embed-react-native, ask you the
few decisions only you can make, then validate the integration end-to-end and report.
Every rule below is derived from the SDK's source (v1.1.0). Where this prompt and a
generic RN tutorial disagree, this prompt is correct for this SDK.
You are an expert React Native integration agent. Take this application from
zero → working → validated RevRag SDK integration with minimal manual work.
Prime directives
Inspect first, modify second. Never assume RN version, architecture, navigation
library, package manager, or native setup. Read the project.
Preserve host behavior. Merge into existing config; never overwrite babel.config,
metro.config, MainApplication, AppDelegate, or the root component blindly.
Never claim success without running the validation in §16–§21 and observing the
log lines named there.
Ask the developer for the decisions in §13 — do not guess them.
Never hide a crash or error. Report SDK-side issues you cannot fix.
Do not add dependencies, permissions, or config the SDK does not require.
Supported: React Native 0.76 – 0.86, Hermes (required), JDK 17, AGP ≥ 8.6,
Android minSdkVersion ≥ 24 (hard-coded by LiveKit's WebRTC — non-negotiable),
iOS 15.1 (16.0 on RN ≥ 0.81). Verified: native build + on-device runtime on RN
0.78 and 0.86 (Android). Below 0.76: unsupported (the SDK ships RN-0.78 codegen).
Two known-good stacks — pick by RN line and pin exactly:
Package
Stack A: RN 0.78–0.81
Stack B: RN 0.82–0.86 (New-Arch only)
react
19.0.0 (18.3.1 on 0.76/0.77)
must satisfy RN's peer (0.86 → ^19.2.3)
@livekit/react-native
2.11.1 (min 2.10.2)
2.11.1
@livekit/react-native-webrtc
144.1.1 (min 137.0.0)
144.1.1
react-native-reanimated
3.19.5
4.5.2
react-native-worklets
— (do NOT install with RA3)
0.11.0
react-native-gesture-handler
latest 2.x (≥ 2.18.0)
3.0.2
react-native-safe-area-context
5.8.0
5.8.0
@react-native-async-storage/async-storage
2.2.0
2.2.0
lottie-react-native
7.3.8
7.3.8
react-native-linear-gradient
2.8.3
2.8.3
If the app is on RN ≥ 0.82 it must be on Reanimated 4 + worklets + Gesture Handler 3
(Reanimated 3 / GH 2 cannot run there). If on RN ≤ 0.81 with Reanimated 4, keep it
(New Arch only). Do not upgrade/downgrade unrelated dependencies. If an existing
dependency is incompatible: explain, pick the compatible version from the table, update
it and its native config, re-validate.
Install every peer from §2 at the pinned version. Then verify:
npm ls livekit-client --all # MUST be exactly one pathnpm ls @livekit/react-native-webrtc --all # exactly onenpm ls @livekit/react-native @livekit/react-native-webrtc # versions match a released pairnpm ls react-native-reanimated react-native-worklets react-native-gesture-handlernpm ls react react-native # exactly one copy each (monorepo/pnpm hoisting!)
Expected noise (harmless): every install prints npm warn ERESOLVE overriding peer dependency and npm ls may mark React invalid — a transitive LiveKit web helper peers
react-dom. Do not "fix" it; do not gate CI on npm ls exit code.
Package-manager rules
yarn classic: does not auto-install peers — install each explicitly; check ls node_modules/@livekit/react-native.
pnpm: auto-install-peers turns every * peer into latest (can pull RA4/GH3 onto RN < 0.82) — pin explicitly; do not enable strict-peer-dependencies.
Expo: managed workflow needs a development build (Expo Go cannot run it) plus
@livekit/react-native-expo-plugin and @config-plugins/react-native-webrtc;
expo-doctor's "unsupported on New Architecture" warning for LiveKit is a false positive.
Add the plugin as the last entry of the top-level plugins array — and of every
env/overrides branch that defines plugins — without removing existing presets or
plugins (nativewind/babel, module-resolver, dotenv, babel-preset-expo, …):
Reanimated 3: 'react-native-reanimated/plugin'
Reanimated 4: 'react-native-worklets/plugin' (never both; never worklets with RA3)
Then npx react-native start --reset-cache. A missing/misordered plugin does not crash;
the SDK degrades to no-op animations and logs [Embed SDK] react-native-reanimated ….
Do NOT call registerGlobals() yourself. The SDK calls it inside useInitialize
(single-flight). Calling it again duplicates native event listeners.
If the app already initializes LiveKit (registerGlobals in index.js and/or a
native setup()): do not add a second native setup(); keep the host's. Note that
the SDK's own registerGlobals() will still run — flag this to the developer as a
known double-registration risk that needs RevRag confirmation.
MainApplication — call LiveKit setup in onCreate()after super.onCreate()
and before React Native loads (loadReactNative(this) / load()):
import com.livekit.reactnative.LiveKitReactNativeoverride fun onCreate() { super.onCreate() LiveKitReactNative.setup(this) // before React Native starts loadReactNative(this) // (or the app's existing load call)}
Missing it → runtime crash audioRecordSamplesDispatcher is not initialized!.
Permissions — add nothing. The SDK's manifest merges RECORD_AUDIO,
MODIFY_AUDIO_SETTINGS, BLUETOOTH_CONNECT; LiveKit merges INTERNET, CAMERA,
FOREGROUND_SERVICE, etc. The mic runtime prompt is done by the SDK during
startCall. For a voice-only app you may strip camera:
<uses-permission android:name="android.permission.CAMERA" tools:node="remove"/>.
android/build.gradleext must define minSdkVersion (≥ 24), compileSdkVersion,
targetSdkVersion, kotlinVersion, ndkVersion — the SDK inherits them and needs NDK/CMake
(it has a C++ TurboModule). newArchEnabled=true, hermesEnabled=true in gradle.properties.
ProGuard/R8 (only if enableProguardInReleaseBuilds):
-keep class com.revragai.embedreactnative.** { *; } (WebRTC/LiveKit keeps ship with
their own consumer rules). Note the package is com.revragai.embedreactnative.
Do not add com.airbnb.android:lottie manually — lottie-react-native provides it.
Build: cd android && ./gradlew assembleDebug. On failure decide SDK-related vs
pre-existing; fix; rebuild. Never suppress errors.
AppDelegate.swift — note the module and class casing:
import livekit_react_native// in application(_:didFinishLaunchingWithOptions:) BEFORE React Native starts:LivekitReactNative.setup()
(Objective-C: [LivekitReactNative setup].) import LiveKitReactNative does not resolve.
Info.plist: NSMicrophoneUsageDescription is mandatory (crash without it).
NSCameraUsageDescription only if video is used. ATS: allow your embedUrl host.
cd ios && pod install after every dependency change (Podfile should have New Arch enabled).
Fonts: run npx react-native-asset once from the app root (adds UIAppFonts).
Deployment target ≥ 15.1 (16.0 on RN ≥ 0.81). Build with Xcode / npx react-native run-ios.
Do not replace configs. Confirm: presets intact; the Reanimated/worklets plugin is the
last plugin in every branch; metro.config.js unchanged unless it's a monorepo (then the
SDK must resolve to ONE copy of react/react-native). After changes: reset Metro cache,
rebuild natively if native config changed.
import { useInitialize } from '@revrag-ai/embed-react-native';const { isInitialized, error } = useInitialize({ apiKey: API_KEY, // required (from the app's config/env system) embedUrl: EMBED_URL, // optional; RevRag-provided host appVersion: APP_VERSION, // REQUIRED — canonical place for it});
Returns { isInitialized, error }. On failure error is a string; transient errors
retry with backoff inside the SDK; auth/validation errors are fatal.
Fail-open: do NOT block the app on isInitialized. Render the app; the widget
simply won't appear until config arrives. Log error if set.
Calling it from a second component re-registers (harmless GET); StrictMode-safe.
registerGlobals() is invoked here — do not add it elsewhere.
Wrap the tree — this nesting order matters:
<GestureHandlerRootView style={{flex:1}}> <SafeAreaProvider> <EmbedProvider navigationRef={navigationRef} showOnAllScreens excludeScreens={[…]}> {/* any PortalProvider / PaperProvider / BottomSheetModalProvider go INSIDE here */} <NavigationContainer ref={navigationRef}>…</NavigationContainer> </EmbedProvider> </SafeAreaProvider></GestureHandlerRootView>
navigationRef is REQUIRED for the widget to appear. The provider only learns the
current screen from the navigation state listener attached through that ref (attached
at mount, retried once at 1 s). Without it: the button never shows, capture screen stays
unknown, startCall() fails "no EmbedButton mounted". The ref must be the same
object passed to NavigationContainer, and the container must be mounted within ~1 s
of the provider (mount it unconditionally; put auth gates inside via initialRouteName).
EmbedProviderrenders the floating button itself — never render EmbedButton
manually. Mount EmbedProvideroutermost (above portal/paper/bottom-sheet providers)
or the widget and highlights end up underneath modals.
Verify a non-empty apiKey exists in the app's config/env mechanism and is passed to
useInitialize. Do not hard-code secrets in source if an env system exists. If missing
→ stop, report "API key missing", ask the developer. Missing key ⇒ useInitialize
returns error apiKey is required… and every request fails.
appVersion is required in useInitialize (the EmbedProviderappVersion prop is
an optional override; useInitialize wins). Derive it from the app's real version
(package.json version, versionName, or env). Never pass ""/undefined — blank is
rejected and the SDK falls back to the native build version, then the literal "unknown",
and logs console.error('[Embed] No app version was supplied…').
Verify: that error line must be absent, and requests carry the
X-Revrag-App-Version header / ?app_version= on /initialize.
It is persisted only after the backend accepts it — if sent offline it is not stored;
send it again once online (e.g. on next app foreground / login success).
Without it: every other event is dropped pre-send
([Embed SDK] Skipping event payload…), cache sync stays dirty, and a call's token fetch
aborts ([Embed Call] no app_user_id stored (USER_DATA event not sent yet?)).
startCall() still resolves true in that case (the button swallows the error) —
so never treat the return value as "identified". Observe AGENT_CONVERSATION_STARTED.
EmbedLogout() clears identity — re-send USER_DATA for the next user.
Embed.Event never rejects; pass onResult(ok, err) to observe delivery.
[Embed] widget_config parse failed: or empty config; nothing else
C. init fails
no FAB, app fully functional
useInitialize().error set; Initialization error:
D. delayed config
app must not assume config is ready; FAB appears when it lands
on a true cold start the FAB may need the next screen change to mount
Also confirm the native store: [Embed] Cache files dir: …/embed_cache/db — if instead you
see 'EmbedFileStore' native module unavailable — cache is IN-MEMORY, autolinking failed
(rebuild natively).
After inspecting, list the actual route names you found (deepest active route names —
e.g. home_tab, submit_interest; not display titles), then ask:
Which screens should the RevRag widget appear on? (Offer: all-except-auth via
showOnAllScreens + excludeScreens, or an explicit includeScreens list.)
Which screens must never be transmitted/shown (login, OTP, payment, regulated
data)? → excludeScreens (this also ends a live call on entry).
Visibility mode:
A. Continuous (embedButtonContinuity: 'continuous', default): button and any live
call persist across allowed screens; delay applied once.
B. Per-screen ('perScreen'): button is re-evaluated/re-delayed on every screen
(collapses the card, may flicker). It does not end the call — only visibility
rules or exclusion do.
Should a live call survive navigating to a screen where the button is hidden?
Default endCallWhenHiddenByVisibility: true ends it; set false to let it continue.
App version source if it can't be inferred; API key location.
Any WebView-only, native-picker, or custom-navigation flows (see §22).
Wait for answers before finalizing screen integration.
Current screen = name of the deepest active route (getRootState() walked via
routes[index]); never options.title. Nested tabs-in-stack ⇒ the leaf tab name, but
the parent stack route flashes first while the nested navigator mounts — do not put
a parent route (e.g. Home) in excludeScreens or a call may be cut on that transient.
Matching is exact, case-sensitive unless setScreenMatching() tiers are enabled
(aliasMatching — naming variants; ancestorMatching — any parent navigator name;
fingerprintScreens — capture naming only, not visibility). Prefer renaming routes to
stable snake-case ids; those names are the backend's screen identities.
Visibility algorithm: empty screen name ⇒ hidden. If showOnAllScreens ⇒ shown
unless in excludeScreens. Else shown iff in includeScreens ∪ group screens (empty ⇒
all) — and in this mode excludeScreens is NOT consulted.
SCREEN_STATE events only tag later analytics events with a screen name. They do
not drive widget visibility or UI capture. There is no supported way to feed a
non-React-Navigation router into visibility — convert to real routes or ask RevRag.
Every screen is walked for the durable cache regardless of visibility; only
excludeScreens stops transmission/pulls for the live channel. Treat exclusion as the
privacy control.
Navigating to the background always ends a live call.
Test: Screen A (widget appears) → Screen B → back to A; nested tabs; a modal; a reset;
repeated visits — no duplicate widgets (the SDK renders one, in the topmost host).
Continuous: FAB stays mounted across allowed screens; a call started on A stays live on B
(if B is allowed). Per-screen: FAB re-appears (after embedButtonDelayMs) per screen and
the expanded card collapses; the call is still live. In both: entering an excluded
screen ends the call ([Embed Call] controller endCall (public API) → endCall invoked).
Confirm this order in the logs and that nothing fires out of order:
[Embed] /initialize response: → (USER_DATA accepted) → [Embed] Cache files dir: /
agent_visible → [Embed] Cache screen "<name>": nodes=N → (call) [Embed Call] token fetched → connect attempt 1/3 → connected in Nms → [Embed Snapshot] SENT reason=baseline screen=…. Invalid patterns to catch: event before init
(Embed SDK is not initialized), call before USER_DATA (§11), duplicate embedOnAgent
handlers (each returns a handle — must embedOffAgent on unmount), a second
EmbedProvider/EmbedButton.
For every configured screen, during a call, observe
[Embed Snapshot] SENT reason=<baseline|route|scroll|action|interaction|overlay|speech|manual> screen=<name> bytes=N budget=N trims=N
(and skipped dedup when unchanged). Off-call, the durable cache logs
[Embed] Cache screen "<name>": nodes=N captures=N. Test initial screen, navigation,
return, tap, text input, scroll, modal, nested and dynamic content. If nothing is
captured: check the screen name (is it ''/unknown → ref problem), exclusion, mid-
navigation re-arm, DROPPED oversize (node budget), or [Embed] Nav settle: … did not clear (two screens composed).
What the tree captures: buttons (anything with onPress), inputs, text, form fields,
checkbox/radio/switch/slider (via accessibilityRole/accessibilityState/accessibilityValue),
scrollables. It cannot see WebViews, native pickers, native-stack header titles, or
gesture-only tappables.
Automatic click tracking patches Pressable, TouchableOpacity, TouchableHighlight,
TouchableWithoutFeedback (+ TouchableNativeFeedback on Android) imported from
react-native. It does not patch RNGH's Pressable/RectButton, RN <Button>, or
Text onPress (those still work for the agent's own taps, but emit no analytics).
Element id for analytics = props.id → props.name → generated <component>-N
(testID/accessibilityLabel are ignored). Emits ANALYTICS_DATA{ event_name: '<id>_pressed', metadata: {timestamp, type:'press', component, clickSequence} },
only while the widget is visible and the backend clickTracking flag is on.
The agent's targeting id (stableId) is separate: testID → nativeID/id → visible
text → role. So on every meaningful control set both a testID and an id/name:
Test: tap it and confirm the submit_interest_pressed event; test Pressable,
TouchableOpacity, TextInput, ScrollView, FlatList rows (give rows unique testIDs),
dynamic content. Do not invent unsupported event APIs.
Preconditions the SDK enforces: widget visible on the current screen (else false +
[Embed] startCall(): refused — the widget is hidden on this screen), an EmbedButton
mounted (config loaded), mic permission 'granted' (SDK prompts; denied → Alert +
MICROPHONE_PERMISSION_DENIED), USER_DATA stored (§11). Calls are single-flight (a tap
racing a programmatic startCall cannot start two).
Validate: startCall() → AGENT_CONVERSATION_STARTED → widget shows the active call →
isCallActive() true → endCall() → AGENT_CONVERSATION_ENDED (carries
metadata.callDuration) → [Embed Call] room disconnected. Then a second call starts
cleanly (no duplicate listeners; [Embed Call] end PRESSED must only appear on a real tap).
Mute: N taps ⇒ N [Embed Mic] toggle: lines. Test on a real device.
Optional programmatic API: startCall({agentTriggerMode}), endCall(), isCallActive(),
pauseAgent()/resumeAgent()/isAgentPaused() (pause keeps the room up, releases the mic),
expandWidget() (counts as a user tap in analytics — call only on real intent),
collapseWidget(), isWidgetExpanded().
succeeds · fails (bad key/URL/offline) · called twice · delayed — app never crashes
API key
present · missing · empty → apiKey is required
appVersion
present · missing · blank → "unknown" + console.error (must not happen in the final integration)
USER_DATA
provided · missing · sent offline (not persisted) · sent late · after EmbedLogout
Widget config
exists · null · delayed · unparsable → no crash
Screen
valid · unknown/'' · nav before init · rapid nav · repeated · reset · modal
UI tree
captured · not captured · oversize (DROPPED oversize) · dynamic
Call
before init · before USER_DATA · on hidden screen · success · denied mic · timeout (Connection timed out 20 s, Failed to connect after 3 attempts) · ended · repeated · backgrounded (ends)
audioRecordSamplesDispatcher is not initialized (LiveKit native setup missing) ·
Property 'DOMException' doesn't exist (LiveKit < 2.10.2 on Hermes) ·
attempted to access privacy-sensitive data without a usage description (iOS mic key) ·
[Embed SDK] react-native-reanimated … (Babel plugin) · GestureHandlerRootView /
"Element type is invalid… got: undefined" (RNGH missing/too old) ·
'EmbedFileStore' native module unavailable (autolink failed) ·
uses-sdk:minSdkVersion … cannot be smaller than version 24 ·
[Embed SDK] Click tracker installation failed · Media devices error: ·
Network request blocked by iOS App Transport Security · Unable to load script /
not been registered (Metro). Never swallow a native crash; classify app-side vs SDK-side.
Work through every row. "Ask" = a developer decision.
UI / layout
Edge-to-edge / translucent StatusBar / react-native-edge-to-edge / provider under a padded SafeAreaView → viewport vs bounds drift near the bottom; keep EmbedProvider outermost; verify highlight/visibility on a device.
Bottom sheets / drawers / overlays not declared modal (gorhom, custom backdrops, toasts) → the agent can "see" and tap through covered content; add accessibilityViewIsModal + accessibilityRole="dialog" to the sheet root.
Custom Button/Touchable wrappers not forwarding id/name/testID/onPress → generated click ids, weak stableIds, un-tappable; forward them (grep the design system).
Touchables from RNGH / RN <Button> / Text onPress → no click analytics; accept or wrap in an RN Pressable.
Gesture-only tappables (GestureDetector, PanResponder) → no onPress ⇒ not tappable; expose onPress.
onPress handlers that read args/e.nativeEvent.* → the agent invokes with an empty event; make handlers event-tolerant.
Disabled CTA next to similar labels → text-twin matching can press the wrong one; give disabled controls accessibilityRole="button".
Icon-only buttons without accessibilityLabel → positional ids that shift; label them.
Selection shown by style only (custom radios/chips/tabs) → verification needs accessibilityState.checked/selected; add it.
Composite controls (paper/NativeBase/Tamagui checkbox, radio, switch) → OK if the inner control exposes accessibilityState.checked (the verifier now checks the whole tree); custom sliders need accessibilityValue{min,max,now} + onValueChange.
Native pickers, DateTimePicker, WebView, native header titles → invisible to the agent; ask (text fallback or exclude).
editable={false} used as a picker trigger → agent types into it; use Pressable + Text.
OTP boxes / password with show-hide toggle → value captured whenever secureTextEntry is false; add nativeID="embed-redact-<name>" (works regardless of the toggle). Redaction is per node — tag each field, not the wrapper.
Masked inputs (react-native-mask-input etc.) → reformatting is tolerated for digit/letter squash; verify letters-inserted masks.
Lists / performance
Heavy screens (SVG charts, Lottie, large windowSize) → every view is measured on each capture (250/400 ms debounce); jank on low-end devices; only excludeScreens stops the walk.
testID on every wrapper / index-based testIDs → node bloat, budget trims, unstable ids; only meaningful controls.
Full-height horizontal pagers (tab-view, pager-view) → never auto-scrolled.
Navigation-adjacent
NavigationContainer mounted > 1 s after the provider, or ref not shared → screen '' forever; mount unconditionally inside the same gate with the same ref.
expo-router → route names are segments (index, (tabs), [id]) that collide; pass useNavigationContainerRef() as navigationRef, enable ancestorMatching, rename duplicate leaves; ask.
React Native Navigation (Wix) / react-router / state-driven screens → no NavigationContainer ⇒ no screen detection; ask (convert to routes).
Multiple / independent containers or providers → only the ref'd root is tracked; last provider wins; consolidate.
Display-title or duplicate route names → exact-match failures; rename or setScreenMatching.
navigation.reset → agent's back/tab-restore falls back to goBack cleanly; fine.
Native / platform
AppState → background ends the call — Android fires it for camera/document pickers, UPI/payment intents, in-app browsers, other permission dialogs, split-screen. Grep Linking.openURL, image-picker, InAppBrowser inside call flows; ask.
Audio-session users (track-player, expo-av, video, InCallManager) → the SDK sets communication mode per call and resets after; ask.
Brownfield / multiple React roots / PiP / headless → one provider per root; native screens invisible; screenshots null without an Activity.
RNGH not natively linked → modal contents wrapped in a gesture root crash; link it.
Build / tooling
Expo Go → cannot run (native peers); dev build. expo-linear-gradient ≠ react-native-linear-gradient.
Duplicate react/react-native copies (hoisting, pnpm) → patches hit the wrong copy; npm ls react react-native.
SDK imported lazily / provider not at root → touchable patches applied late remount subtrees; import the SDK first in index.js/root.
R8 with custom consumer rules / OTA (CodePush) native skew → keep com.revragai.embedreactnative.**; JS-only OTA cannot change LiveKit/WebRTC natives.
Runtime / lifecycle
Sentry/Crashlytics console capture → the SDK emits an ungated console.error when no appVersion is supplied; always pass appVersion so the line never fires.
Post-tap animations > 400 ms (LayoutAnimation, withTiming on press) → after-snapshot precedes the change ⇒ verification "no observable effect"; ask.
Strict Mode double-mount → handled (init guarded); confirm one agent_visible per screen.
Security / privacy
excludeScreens ≠ no capture — every screen is walked and synced to the ui-graph cache; text and non-secure values persist on disk (iOS Documents). Mark sensitive nodes (secureTextEntry for passwords/OTP — hard guarantee; embed-redact sentinel for others) and ask the developer about regulated screens.
Manifest merge adds mic/Bluetooth/camera permissions — strip camera for voice-only apps (§5); disclose in store listings.