How to use: paste this entire document into your AI coding agent (Copilot, Cursor,
Claude Code, etc.) inside the Android app you want to integrate. The agent will inspect
your app, add and configure ai.revrag:embed-android, 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.2.0). Where this prompt and a
generic Android tutorial disagree, this prompt is correct for this SDK. This SDK is
for native Android apps (Kotlin/Java, Jetpack Compose or XML Views). React Native and
Flutter apps use embed-react-native and embed_flutter instead.
You are an expert Android integration agent. Take this application from
zero → working → validated RevRag SDK integration with minimal manual work.
Prime directives
Inspect first, modify second. Never assume the AGP/Kotlin version, UI toolkit,
navigation library, or Activity structure. Read the project.
Preserve host behavior. Merge into existing config. Never blindly overwrite
AndroidManifest.xml, settings.gradle(.kts), build.gradle(.kts), the Application
class, or any Activity.
Never claim success without running the validation in §19–§22 and observing the
log lines and on-device behavior named there.
Ask the developer for the decisions in §12. 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. In
particular: no BLUETOOTH_CONNECT, no SYSTEM_ALERT_WINDOW, no ProGuard rules, no
runtime-permission code, no Ktor/OkHttp/LiveKit dependencies of your own.
Public API only. Import from ai.revrag.embed.android.*. Never import
ai.revrag.embed.core.*; it is internal and changes without notice.
any android:process attribute (multi-process, §22)
Release build
isMinifyEnabled / R8, FLAG_SECURE usage, an SLF4J binding (logback-android, slf4j-android)
App version source
versionName in defaultConfig, or a CI-stamped value
VALIDATE: you can state, in one sentence each: the UI toolkit, the Activity structure,
the navigation type, where the Application class is, where login and logout happen,
and whether there are WebViews, dialogs, or third-party Activities.
Enforced by AAR metadata; the build fails with a clear message below it.
AGP
8.1.1
Needed for compileSdk 34.
Gradle
8.0
Kotlin
2.0
Kotlin 1.9 fails with "binary version of its metadata is 2.0.0"; there is no flag around it.
JDK / JVM target
17
compileOptions + kotlin { jvmToolchain(17) }.
Activities that host the widget
ComponentActivity
AppCompatActivity and FragmentActivity qualify. A plain android.app.Activity cannot host it.
Process
single process
Only the process that calls initialize has an agent.
If any target is below its floor, explain it, raise it, and re-validate. Do not upgrade or
downgrade unrelated dependencies. XML-only apps need no Compose toolchain (no compose
compiler plugin, no buildFeatures.compose): the Compose runtime arrives as an ordinary
library dependency.
settings.gradle(.kts): JitPack is required. LiveKit's audioswitch dependency is
published only there. Without it sync fails with Could not find com.github.davidliu:audioswitch.
The SDK brings: LiveKit Android 2.27.0 (WebRTC), Compose BOM 2024.09.02,
activity-compose, navigation-compose, lottie-compose6.4.0, and Ktor (shaded
into ai.revrag.shaded.ktor; it never appears in the host's dependency tree and needs no
handling).
Gradle resolving a higher version is fine. Never force / strictly LiveKit
below 2.27.0, because that breaks the voice transport.
A host on a newer Compose BOM may see a constraint conflict. Do not downgrade the host;
let Gradle pick the highest.
A host that already ships another WebRTC/VoIP SDK (Twilio, Agora, an older LiveKit):
stop and report it (§22). Two WebRTC stacks can clash at runtime and must be tested together.
Permissions. The AAR merges nothing into the host manifest, not even these.
Add to app/src/main/AndroidManifest.xml, inside <manifest>, above <application>:
A missing RECORD_AUDIO is auto-denied by the OS with no dialog: the widget shows
and every call fails.
Do not addBLUETOOTH_CONNECT (headset routing is OS-managed; declaring it causes a
spurious "Nearby devices" prompt) or SYSTEM_ALERT_WINDOW (the SDK never uses one).
Do not copyandroid:usesCleartextTraffic="true" from RevRag example apps. They
target a dev backend. Production is https://embed.revrag.ai.
Application class registered: <application android:name=".MyApp" …>. Create one
if the app has none.
LiveKit's transitive manifest entries. The merged manifest will also contain
ACCESS_NETWORK_STATE, MODIFY_AUDIO_SETTINGS, BLUETOOTH (maxSdk 30), CAMERA,
FOREGROUND_SERVICE, FOREGROUND_SERVICE_MEDIA_PROJECTION and a ScreenCaptureService.
The SDK never uses the camera or screen capture. Ask before removing any with
tools:node="remove", and re-test a call afterwards.
VALIDATE: read the merged manifest, not the source one:
app/build/intermediates/merged_manifest/debug/processDebugMainManifest/AndroidManifest.xml
contains INTERNET and RECORD_AUDIO.
Call EmbedSDK.initializeonce, in Application.onCreate. Not in a post-login
Activity: screens created before initialize never get the agent.
import ai.revrag.embed.android.EmbedSDKclass MyApp : Application() { override fun onCreate() { super.onCreate() EmbedSDK.initialize( context = this, apiKey = BuildConfig.REVRAG_API_KEY, // REQUIRED, from secure build config // embedUrl = "…", // OMIT for production; only for a RevRag-provided non-prod host // appVersion = "2.4.0", // OPTIONAL, see §9 ) { result -> if (!result.success) Log.e("Embed", "init failed: ${result.error}") } }}
Signature: initialize(context, apiKey, embedUrl = null, autoTrackActivities = true, onResult),
plus an overload with appVersion: String. Use named arguments.
onResult(InitResult): InitResult(success: Boolean, error: String?). This is the only
place init failure surfaces in a release build, so always log it.
Initialization is fail-open: the app keeps running; the widget simply won't appear
until config arrives.
autoTrackActivities = true (default) lets the SDK follow Activities on its own. Leave it on.
Do not pass an agentTriggerMode here; there is none. Call mode is chosen per call (§17).
VALIDATE (debug build):adb logcat | grep RevragEmbed shows, in order:
[RevragEmbed][Init] 🚀 Stage 1 — initialize() called → Stage 3 — calling GET /embedded-agent/initialize …
→ Stage 4 — /initialize response received → ✅ Stage 5 — EmbedEvent configured, isInitialized = true
→ 🎉 Initialization complete — SDK is ready.
Use grep RevragEmbed, notlogcat -s RevragEmbed: init lines are printed under the
System.out tag.
The key and the backend environment must be a matching pair. A dev key on the
production backend (or the reverse) returns no widget config: the button never appears
and no error is raised.
Never hardcode a key copied from RevRag's example apps; use the client's own key from
secure build config (BuildConfig field / local.properties / CI secret). If none is
available, stop, report "API key missing", and ask the developer.
A blank key fails init (Stage 0 — apiKey must not be empty).
VALIDATE:EmbedSDK.widgetConfig (a StateFlow<WidgetConfig?>) becomes non-null
after init. Do not judge readiness on isInitializedFlow: it flips true from having a
key, before any network call. A null widgetConfig after a successful request means
provisioning (wrong key/environment, or no config for this platform and app version). The
SDK latches that answer until initialize runs again.
Optional. Omitted or blank → the SDK reports the host's versionName automatically.
Pass appVersion = "x.y.z" to initialize only when the developer needs a different
reported value. An explicit init value wins over any later setter.
The widget config and the avatar are provisioned per platform and per app version
on the RevRag dashboard. A version bump can come back with no config (widget disappears)
or no avatar (generic animation). Tell the developer to re-verify on the dashboard at
every version bump.
VALIDATE: the dashboard has an Android widget config (and avatar) for exactly the
version this build reports.
No call can connect until the SDK knows the user. After login, send:
import ai.revrag.embed.android.EmbedSDKimport ai.revrag.embed.android.EventKeysEmbedSDK.event(EventKeys.USER_DATA, mapOf("app_user_id" to userId)) // stable id, required
or pass appUserId = userId to attachOverlay(...) (§13). For a no-auth/demo app, send it
on the first screen.
On every logout:
EmbedSDK.clearStorageCache()
Otherwise one user's identity and context carry into the next user's session.
Without app_user_id, startCall is refused (debug log:
[StartCall] refused — app_user_id is not set — send an EventKeys.USER_DATA event carrying 'app_user_id', or pass appUserId to attachOverlay())
and the UI-graph cache cannot upload.
Required order: initialize → init success → USER_DATA → widget config → widget shown on
an allowed screen → call.
Position, colors, avatar, collapsed view, media mode (audio / video / chat), nudges and
action intelligence come from the dashboard, not host code. Malformed backend values
fall back to defaults and never crash the host.
Case
Expected
Config exists
Widget appears on allowed screens; agent_visible analytics event fires.
Config null
No widget; app fully functional. See §8 for the cause.
Avatar missing for this app version
Widget shows a generic pulse animation instead of the client's avatar.
Video tenant not provisioned
Permanent "Connecting avatar…" placeholder during a call.
Slow first launch
HTTP timeout 15 s; init retries 3× (~47 s), then refetches on the next screen change. The widget is late, not lost.
If the developer does not want the agent to act on screens (tap/type/scroll), that is a
dashboard switch: action_config.flags.actionIntelligence = false. There is no client flag.
After inspecting, list the actual screen names the SDK will report (§14: route patterns,
nav labels, Fragment / Activity class names), then ask:
Which screens should the widget appear on? → allowedScreens (empty = everywhere).
Which screens must never show it (login, OTP, MPIN, payment, regulated data)?
→ excludedScreens. Entering one ends a live call.
Which journeys must keep a live call alive across screens? → one CONTINUOUS group
per journey (§15). Two allowed screens not in the same continuous group end the call
on every hop between them.
Should the widget appear after a delay on some screens? → showDelay / group delayMs
EmbedButtonDelayPolicy.
Any bottom bar, host FAB, or sticky CTA the widget would cover? → defaultInset /
group inset.
WebView screens with several pages: should each page be its own screen on the
backend? (→ setSubScreen, §14.4.)
Who starts calls: only the user via the widget, or also the app (§17)? Co-pilot or
full avatar (WORKFLOW) experience?
Is a call ending acceptable when the user backgrounds the app, opens Custom Tabs /
a payment SDK, or leaves for an excluded screen? (It always does; confirm they accept it.)
Exactly one mount mechanism in the whole app: attachOverlayorEmbedProviderComposable, never both (two mounts = two widgets that drift apart).
No teardown: never call detach() in onPause / onStop / onDestroy.
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { override fun onActivityResumed(activity: Activity) { val host = activity as? ComponentActivity ?: return EmbedProvider.attachOverlay( activity = host, appUserId = Session.userIdOrEmpty(), visibilityConfig = EMBED_VISIBILITY, // one shared val, §15 ) } override fun onActivityCreated(a: Activity, b: Bundle?) = Unit override fun onActivityStarted(a: Activity) = Unit override fun onActivityPaused(a: Activity) = Unit override fun onActivityStopped(a: Activity) = Unit override fun onActivitySaveInstanceState(a: Activity, b: Bundle) = Unit override fun onActivityDestroyed(a: Activity) = Unit})
attachOverlay is idempotent per Activity: the first call mounts, later calls move
the widget or update the screen. Arguments on repeat calls are ignored:
visibilityConfig, accentColor, appUserId are fixed at first mount. Define them once.
It must run aftersetContentView. onResume always satisfies this.
accentColor is a @ColorInt Int, not a Compose Color.
Keep the returned EmbedOverlayHandle if you need setCurrentScreen / setSubScreen.
Never use EmbedProvider.attach(...) on an XML Activity: it calls setContent and
wipes the View hierarchy.
Visibility config is matched against the name the SDK reports. Matching is exact and
case-sensitive. A config entry no screen ever reports hides the widget silently.
Host name:handle.setCurrentScreen("…") or attachOverlay(currentScreen = "…")
NavController destination: Compose: the android:label if set, else the route
truncated at the first / (receipt/{orderId} → receipt; the full template also
matches with alias matching on). XML nav graphs: android:label. Every XML
destination needs android:label, or it reports a numeric id no config can match.
Fragment class name (top-level resumed Fragment)
Activity class name
Once the host names one screen by hand (rung 1), automatic Fragment naming stands down
for the session: name them all, or a silent screen keeps the previous name.
EmbedSDK.setScreenMatching(EmbedScreenMatching( aliasMatching = true, // a screen answers to all its names (route template, label, class) ancestorMatching = false, // children inherit a parent's allow; global; leave off unless asked fingerprintScreens = false, // auto-name unannounced sub-screens from content; off when the host names them))
null leaves each flag to the dashboard. EmbedScreenMatching.ALL turns all three on.
Works automatically (rungs 3–4). Name screens with handle.setCurrentScreen only if class names are not what the config should use.
Compose state tabs inside one route (BottomNavigation switching a when)
On every tab switch: handle.setCurrentScreen(tabName). Also re-apply it when returning to that route (key the effect on the current route and the tab).
Several NavHosts / nested graphs
Re-attach with the active controller on each tab switch. One controller at a time; a static currentScreen on an attach that also passes a controller is ignored.
Fragments shared across Activities
Composite names (landing/Details) via setCurrentScreen from the Fragment's onResume.
Two screens with the same name
Give them distinct names, or they merge into one entry.
Splash / trampoline Activities
Let them be named, then put them in excludedScreens. Don't "not attach" there.
EmbedSDK.setCurrentScreen(...)
Event enrichment only. Never moves the widget. Use handle.setCurrentScreen.
The SDK cannot see inside a WebView (the page is one opaque node), and navigation never
announces web page changes. To give each web page its own screen on the backend while
the widget follows the route that hosts the WebView, call setSubScreen:
webViewClient = object : WebViewClient() { // Fires on full loads AND single-page-app route changes (pushState) override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) { if (!isReload) embedHandle?.setSubScreen(webPageName(url)) }}/** Stable, privacy-safe name: no query string, no IDs, no session hashes. */fun webPageName(url: String?): String { val segments = url?.let { Uri.parse(it).pathSegments }.orEmpty() .filterNot { it.length > 24 || it.any(Char::isDigit) } return "web/" + segments.joinToString("/").ifEmpty { "home" }}
List the route / screen that hosts the WebView in allowedScreens, not page names.
No EmbedScreenMatching flag is needed.
Page hops don't re-run the widget's entrance and don't end a live call. The next
navigation clears the page name automatically. setSubScreen(null) returns to the route.
Never use setCurrentScreen for web pages. It replaces the route, so an unlisted page
name hides the widget on a route that allows it.
Never put tokens, session IDs, user IDs or personal data in the name. Ignore a WebView
"title" that is just the URL echoed back.
The agent cannot read or fill web forms; native chrome around the WebView is captured.
VALIDATE (debug): each navigation logs [Screen] A#n → B#n+1 (nav|host|fragment|activity|sub).
Write down every reported name, then write the config against that list. A hidden screen
logs [Visibility] '<name>' NOT allowed — hidden.
Imports: EmbedButtonVisibilityConfig, EmbedButtonGroupConfig, EmbedButtonContinuity,
EmbedButtonDelayPolicy are in ai.revrag.embed.android; EmbedButtonInset is in
ai.revrag.embed.android.ui (fields right, bottom, left, top, in dp).
Rules:
Empty allowedScreens = everywhere. A non-empty groups list is itself an allowlist
(group screens show the widget). excludedScreens beats everything.
Hide a screen with excludedScreens, never by not attaching there: the SDK follows
Activities on its own.
Call licence: a call starts only where the widget shows, ends when the user lands
where it doesn't (endCallWhenHiddenByVisibility = true by default), ends when the app
goes to background (no foreground service), and ends on any hop between allowed screens
that are not in the same CONTINUOUS group.
EmbedButtonDelayPolicy: PER_SCREEN (default), ONCE_PER_GROUP_ENTRY,
ONCE_PER_APP_SESSION. Explain delays to the developer: a 2500 ms delay looks like
"not working" for 2.5 s.
The dashboard's position, if set, wins over defaultInset / group inset.
VALIDATE: during a live call, navigate inside a CONTINUOUS group: the call persists.
Hop to an ungrouped or excluded screen: the call ends (agent_conversation_ended).
A dialog has its own window above the Activity; elevation/zIndex can't cross it. The SDK
moves its single overlay into the topmost dialog window and back.
Automatic adoption (default) covers DialogFragment, BottomSheetDialogFragment, plain
Dialog, Compose Dialog and ModalBottomSheet. Adoption of non-DialogFragment
windows uses a hidden API that fails on some OEM builds (e.g. ColorOS 13).
Guaranteed path where adoption can't be trusted: EmbedProvider.attachOverlay(dialog, requireActivity())synchronously from onStart() (no post, no teardown).
A dialog that hosts the widget needs a full-screen, transparent window with animations off:
On Android 14+, prefer a transparent windowBackground in the dialog theme. A dialog
created opaque and made transparent later leaves visual trails/stuck ripples behind the widget.
A full-screen DialogFragment used as a screen (MPIN, PAN verify) needs both the
dialog attach andhandle.setCurrentScreen(...). Adoption never renames.
VALIDATE: open every dialog/sheet while the widget is visible: it rises above it and
returns on dismiss, cancel, back, and rotation.
The SDK requests RECORD_AUDIO itself on the first call and shows its own explainer after a
permanent denial. Write no permission code. (Optional early ask:
EmbedSDK.checkPermissions(context) { granted -> }.)
EmbedSDK.expandWidget() // show the in-call UI firstEmbedSDK.startCall(activity) { ok -> } // default voice co-pilotEmbedSDK.startCall(activity, AgentTriggerMode.WORKFLOW) { ok -> } // full avatar surfaceEmbedSDK.endCall()EmbedSDK.isCallActive() // BooleanEmbedSDK.collapseWidget()EmbedSDK.sendText("…") { result -> } // typed message into the call
AgentTriggerMode: CO_PILOT (agent works on the host's screens) or WORKFLOW
(RevRag-owned avatar surface). Passed per call only. A call started by tapping the
widget is always the default co-pilot. To get WORKFLOW on every call, the app must start
calls itself.
Host-started calls: call expandWidget()beforestartCall(), on a real user gesture
(it fires the tap-to-open analytics event). startCall alone leaves a live mic behind a
collapsed button.
startCall's result is a plain Boolean. Refusal reasons (mic denied, no user id, token
failure) go to the backend as an error analytics event and, in debug builds, to logcat
as [StartCall] refused — <reason>.
Video is a dashboard setting (media_mode), not a client flag.
VALIDATE (physical device, never an emulator): tap the widget on a fresh install →
system mic prompt → call connects (agent_conversation_started) → end → second call starts
cleanly. Deny twice, tap again → the SDK's own explainer with "Open Settings" appears.
The agent reads the accessibility/semantics tree, the same data TalkBack uses.
Give every actionable element a stable identity: Modifier.testTag("checkout.pay") in
Compose, android:id in XML. Without one, ids derive from text/position and shift.
Icon-only buttons need contentDescription. Custom-drawn / Canvas controls need semantics
(contentDescription, range info, a setter). Self-test: if TalkBack can read and adjust
it, so can the agent.
Selection by style only (custom chips/tabs/radios) → expose selected / toggleableState.
One toolkit per screen: in hybrid screens only one substrate is captured (XML chrome
inside a Compose screen, or AndroidView content inside Compose, is invisible).
Password/secure fields are redacted at capture and refused at dispatch.
FLAG_SECURE screens are captured (screen text leaves the device). Confirm with
whoever set the flag.
The UI-graph cache captures every screen landing, including excludedScreens, while
the tenant's cache flags are on. Disclose this to the developer; turning off
actionIntelligence does not stop it.
VALIDATE: ask the agent to tap the floating widget itself; it must refuse (the SDK's own
UI is never a target).
Events to expect: agent_visible, agent_conversation_started, agent_conversation_ended,
microphone_permission_allow / microphone_permission_denied. Gate any host-side event
logging on BuildConfig.DEBUG; payloads can carry user data.
Tap / startCall → [LiveKit] Room connected → agent_conversation_started
End → agent_conversation_ended
Invalid patterns: a call before USER_DATA ([StartCall] refused — app_user_id is not set …),
agent_visible never firing (re-check §8, §10, §14), two widgets on screen (two mounts, §13).
No ProGuard/R8 rules needed. The AAR ships consumer rules for its classes, shaded Ktor,
and the Compose semantics it relies on.
Smoke-test the minified release build, not only debug (R8 has silently broken capture
layers before).
If the host ships an SLF4J binding (logback-android, slf4j-android), the SDK's HTTP
client logs request URLs at INFO in release, and some carry app_user_id. Report it to
RevRag; there is no host switch.
Ship an App Bundle (LiveKit/WebRTC native libs are large).
Two library warnings are expected even in release and are not SDK faults: Lottie
(You have N images…, from the avatar animation) and 3 SLF4J "no binding" lines at startup.
No widget, no crash, widgetConfig stays null. Fix the pair.
RECORD_AUDIO missing from merged manifest
No mic prompt, every call fails. Add it (§5).
app_user_id never set
startCall refused; cache never uploads. §10.
Widget never appears on a screen
Reported name ≠ config string (§14), or excluded, or delay, or null config (§8).
Widget vanishes mid-flow
Teardown code (detach()), or the screen's name changed (host named one screen but not this one).
Two widgets
Two mount mechanisms (§13).
WebView page hides the widget
setCurrentScreen used for pages; switch to setSubScreen (§14.4).
Call ends on navigation
Screens not in one CONTINUOUS group, or next screen hidden/excluded (§15).
Call ends on background / Custom Tabs / payment SDK
By design (no foreground service; widget not shown there). Confirm acceptance.
Incoming phone call during an agent call
Not handled: the agent keeps talking. Disclose.
Another VoIP/WebRTC SDK in the app
Ask; test both together. No coexistence guarantee.
Corporate network / VPN
Allowlist on 443: embed.revrag.ai, revrag-dev.s3.ap-south-1.amazonaws.com (in-call icons), and the LiveKit server + TURN from the token response. If UDP is blocked, TURN over TCP/TLS is needed.
Multi-window / split screen
Second pane has no widget. Picture-in-picture untested. Set expectations.
android:process
Only the initializing process has an agent.
Bluetooth headset
Audio routes to it when connected for calls; on disconnect it falls back to the speaker. Verify on device.
Host overrides the overlay with its own top-level window/portal
Verify z-order on device.
Shared device, user switch
clearStorageCache() on logout (§10).
16 KB page-size devices (Android 15)
Not certified for LiveKit 2.27.0 natives. Ask RevRag before certifying.