# Android Agent Integration > Drop-in brief for coding agents (Claude Code, Cursor, Copilot) to integrate the Revrag Embed Android SDK - phases, hard rules, API reference, and definition of done URL: /embed/integration/android-agent-integration Markdown: /embed/integration/android-agent-integration.md # Revrag Embed — Android integration, for a coding agent [#revrag-embed--android-integration-for-a-coding-agent] **How to use this file:** drop it anywhere in your Android repo and tell your coding agent (Claude Code, Cursor, Copilot, Windsurf, …): > Read `AGENT_INTEGRATION.md` and integrate the Revrag Embed SDK into this app. > My API key is ``. Everything the agent needs is below. A typical integration is 3 files and a few minutes. *** ## AGENT: START HERE [#agent-start-here] You are integrating the Revrag Embed Android SDK — a floating AI voice-agent button that follows the user across screens. Work through the phases in order. Do not skip Phase 0. ### Phase 0 — Gather what you cannot guess [#phase-0--gather-what-you-cannot-guess] Ask the user for anything in this table you cannot determine from the repo. Ask for **all** of it in one message, then proceed. | Needed | Why | If unknown | | -------------------------------- | ------------------------------ | --------------------------------------------- | | **API key** | required to initialize | **must ask — never invent one** | | **Environment URL** | key and environment are a pair | default `null` (production) | | **Which screens show the agent** | drives `allowedScreens` | **must ask** — do not guess from screen names | | **User id expression** | attaches events to a user | ask; use `""` if the app has no auth yet | Then determine from the repo yourself, without asking: 1. **Does an `Application` subclass exist?** Search for `: Application()` and `android:name` in `AndroidManifest.xml`. If none, you will create one. 2. **Which shape is this app?** Count the Activities FIRST, then look inside. Answer both questions before choosing — the shapes differ on both axes, and picking on UI toolkit alone lands on the wrong one: | Activities | Screens inside them | Shape | | ----------- | ------------------------------------------------------------- | ----- | | exactly one | Compose `setContent {}` + `NavHost` | **D** | | exactly one | `NavHostFragment` / fragment transactions | **B** | | several | one screen each, no nav host anywhere | **A** | | several | at least one has a `NavHostFragment` / `BottomNavigationView` | **C** | | several | at least one is Compose with `setContent {}` | **E** | A single-Activity app is D or B and never anything else. A multi-Activity app is A, C or E — and E only when a Compose Activity is involved; a multi-Activity XML app with tabs is C, not E. 3. **What are the screen names?** Read them, do not invent them: * Shape A → Activity **class names** (`LoginActivity`) * Shape B/C → nav graph `android:label` values, or fragment class names * Shape D/E → NavHost **route** strings Report the list you found back to the user with your plan. ### Phase 1 — Dependency and permissions [#phase-1--dependency-and-permissions] **1a.** Add to the app module's `build.gradle.kts` (or `.gradle`): ```kotlin implementation("ai.revrag:embed-android:1.1.0-beta08") ``` `mavenCentral()` is already present in essentially every Android project — add it only if genuinely missing. Do **not** add any Compose dependency or the Compose compiler plugin; the SDK carries its own UI and works in pure-XML apps. **1b.** Add to `app/src/main/AndroidManifest.xml`, inside ``: ```xml ``` ⚠ **The SDK ships no permissions of its own.** If you skip this, the widget appears and every call fails. Do not write a runtime-permission flow — the SDK requests `RECORD_AUDIO` itself when the user first starts a call. **1c.** Verify `minSdk >= 24`, **Kotlin 2.0 or newer**, and that the project builds on JDK 17. The SDK is compiled with Kotlin 2.0.21 and its classes carry metadata 2.0 — a 1.9.x compiler fails with "the binary version of its metadata is 2.0.0, expected version is 1.9.0", and no flag works around it. If the project is on 1.9.x, say so and stop rather than attempting the integration. Ktor is shaded into `ai.revrag.shaded.ktor.*` and will not clash with the app's own Ktor. LiveKit, Lottie, Coil and Coroutines arrive transitively — do not add them yourself. ### Phase 2 — Initialize [#phase-2--initialize] Create the `Application` subclass if absent, register it in the manifest (``), and add: ```kotlin override fun onCreate() { super.onCreate() EmbedSDK.initialize(this, "") { result -> if (!result.success) Log.e("Embed", "init failed: ${result.error}") } EmbedSDK.event(EventKeys.USER_DATA, mapOf("app_user_id" to "")) } ``` Import from `ai.revrag.embed.android.*` only. Never import `ai.revrag.embed.core.*` — it is internal and will break on upgrade. If the user gave an environment URL, pass it: `EmbedSDK.initialize(this, "", embedUrl = "") { … }` ### Phase 3 — Visibility config [#phase-3--visibility-config] Put this in the `Application`'s `companion object`. Use the **real** screen names you found in Phase 0.3 and the screens the user named in Phase 0. ```kotlin val EMBED_VISIBILITY = EmbedButtonVisibilityConfig( allowedScreens = listOf(/* names the user chose */), excludedScreens = listOf(/* splash, trampolines, deep-link handlers */), groups = listOf( EmbedButtonGroupConfig( id = "main", screens = listOf(/* screens that form one journey */), continuity = EmbedButtonContinuity.CONTINUOUS, delayMs = 500L, delayPolicy = EmbedButtonDelayPolicy.ONCE_PER_GROUP_ENTRY ) ) ) ``` Rules you must follow: * **An empty `allowedScreens` means the widget shows EVERYWHERE.** Never leave it empty unless the user explicitly wants that. * Put splash/trampoline Activities in `excludedScreens`. Do **not** achieve this by skipping the attach there — that leaves a live call running. * Screens the user moves between within one task belong in one `CONTINUOUS` group, so the call survives navigation. * Names are matched **exactly** and are case-sensitive. ### Phase 4 — Mount [#phase-4--mount] Apply exactly one of the following, matching the shape from Phase 0.2. *** #### Shape A — XML, one Activity per screen [#shape-a--xml-one-activity-per-screen] In `Application.onCreate`, after `initialize`. **Touch no Activity file.** ```kotlin registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { override fun onActivityResumed(activity: Activity) { EmbedProvider.attachOverlay( activity = activity as? ComponentActivity ?: return, appUserId = "", visibilityConfig = EMBED_VISIBILITY ) } 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 }) ``` *** #### Shape B — XML, one Activity, many fragments [#shape-b--xml-one-activity-many-fragments] In that Activity's `onCreate`, **after `setContentView`**: ```kotlin val navHost = supportFragmentManager .findFragmentById(R.id.nav_host_fragment) as NavHostFragment val navController = navHost.navController navController.addOnDestinationChangedListener { _, _, _ -> EmbedProvider.attachOverlay( activity = this, appUserId = "", visibilityConfig = MyApp.EMBED_VISIBILITY, navController = navController ) } ``` Use `findFragmentById(...) as NavHostFragment`, **not** `findNavController()` — the latter crashes when called from `onCreate` with a `FragmentContainerView`. If the app has no NavController, pass `currentScreen = ""` from wherever it swaps fragments instead, and omit `navController`. *** #### Shape C — XML, several Activities with several screens [#shape-c--xml-several-activities-with-several-screens] Both of the above. Create one helper so there is a single call site: ```kotlin // In the Application's companion object fun attachEmbed(activity: Activity) { val host = activity as? ComponentActivity ?: return EmbedProvider.attachOverlay( activity = host, appUserId = "", visibilityConfig = EMBED_VISIBILITY, navController = (activity as? MainActivity)?.embedNavController, currentScreen = null ) } ``` * `Application.onCreate` → `registerActivityLifecycleCallbacks` with `onActivityResumed { attachEmbed(activity) }` (other overrides empty). * The tabbed Activity → expose the controller and re-attach on destination change: ```kotlin val embedNavController: NavController? get() = if (::navController.isInitialized) navController else null navController.addOnDestinationChangedListener { _, _, _ -> MyApp.attachEmbed(this) } ``` `allowedScreens` will contain **both** Activity class names and nav labels. *** #### Shape D — Compose, single Activity [#shape-d--compose-single-activity] Wrap the existing content. Do not restructure the app. ```kotlin setContent { val navController = rememberNavController() val backStack by navController.currentBackStackEntryAsState() EmbedProviderComposable( currentScreen = backStack?.destination?.route ?: "", appUserId = "", visibilityConfig = EMBED_VISIBILITY, navController = navController ) { // the app's existing NavHost / content, unchanged } } ``` Screen names are **routes**. `allowedScreens` must use route strings. *** #### Shape E — Compose, several Activities [#shape-e--compose-several-activities] Use **Shape A**'s `Application` mount — it works for Compose Activities too and one mount serves them all. Add Shape C's `navController` hand-over for any Activity that has its own. Do **not** also use `EmbedProviderComposable`. *** ### Phase 5 — Dialogs (only if the app has them) [#phase-5--dialogs-only-if-the-app-has-them] For any `DialogFragment` / `BottomSheetDialogFragment` that should not cover the widget, add to `onStart()`: ```kotlin EmbedProvider.attachOverlay(dialog!!, requireActivity()) ``` Write no teardown — the widget returns to the Activity on every dismissal path. ### Phase 5b — Logout and position (do these if they apply) [#phase-5b--logout-and-position-do-these-if-they-apply] **If the app has authentication**, find the sign-out path and add: ```kotlin EmbedSDK.clearStorageCache() ``` Without it, one user's identity and conversation context carry into the next user's session on a shared device. This is a correctness requirement, not a nicety. **If any allowed screen has a bottom navigation bar, FAB or sticky CTA**, set an inset so the widget does not sit under it: ```kotlin EmbedButtonVisibilityConfig( defaultInset = EmbedButtonInset(right = 24, bottom = 80), // dp // …or per group: EmbedButtonGroupConfig(inset = EmbedButtonInset(bottom = 160)) ) ``` **If the app uses one NavController per bottom tab**, pass the ACTIVE controller, and re-attach when the tab changes as well as on destination change. ### Phase 5c — Event observability (do this — it is how you verify) [#phase-5c--event-observability-do-this--it-is-how-you-verify] Add this helper file to the app. It logs every SDK event with a readable label, and is the hook the user forwards to their own analytics later. ```kotlin import ai.revrag.embed.android.EmbedAnalyticsEvents import ai.revrag.embed.android.EmbedSDK import ai.revrag.embed.android.EventCallback import ai.revrag.embed.android.EventKeys import android.util.Log object SdkLifecycleListener { private const val TAG = "SdkLifecycle" private var callback: EventCallback? = null fun register(onEvent: ((name: String, data: Map) -> Unit)? = null) { if (callback != null) return val cb: EventCallback = { data -> val name = data["event_name"] as? String ?: "unknown" Log.d(TAG, "${label(name)} → $data") onEvent?.invoke(name, data) } callback = cb EmbedSDK.on(EventKeys.ANALYTICS_DATA, cb) } fun unregister() { callback?.let { EmbedSDK.off(EventKeys.ANALYTICS_DATA, it) } callback = null } private fun label(name: String): String = when (name) { EmbedAnalyticsEvents.AGENT_TAP_TO_OPEN -> "Widget expanded" EmbedAnalyticsEvents.AGENT_TAP_TO_CLOSE -> "Widget collapsed" EmbedAnalyticsEvents.AGENT_VISIBLE -> "Widget visible" EmbedAnalyticsEvents.AGENT_CONVERSATION_STARTED -> "Call started" EmbedAnalyticsEvents.AGENT_CONVERSATION_ENDED -> "Call ended" EmbedAnalyticsEvents.AVATAR_MODE_OPENED -> "Avatar opened" EmbedAnalyticsEvents.POPUP_MESSAGE_VISIBLE -> "Popup shown" EmbedAnalyticsEvents.MICROPHONE_PERMISSION_ALLOW -> "Mic permission granted" EmbedAnalyticsEvents.GEN_TOOL_TRIGGERED -> "Tool triggered" EmbedAnalyticsEvents.RAGE_CLICK -> "Rage click" EmbedAnalyticsEvents.FORM_EVENT -> "Form event" EmbedAnalyticsEvents.ERROR -> "SDK error" else -> name } } ``` Call `SdkLifecycleListener.register()` in `Application.onCreate` after `initialize`, and `unregister()` on logout. Use its `SdkLifecycle` log tag in Phase 6 to prove events flow. If the app registers `onAgent` / `onAvatarState` listeners anywhere, they must be deregistered in the matching `onDestroy` **with the same callback instance** — an inline lambda at the `off` call site removes nothing and leaks the Activity. ### Phase 6 — Verify, then report [#phase-6--verify-then-report] 1. **Build.** `./gradlew :app:assembleDebug`. Fix compile errors. 2. **Run and read logcat**, filtered to `RevragEmbed`. You are looking for: ``` [Screen] SplashActivity#1 → LoginActivity#2 [Visibility] 'SplashActivity' NOT allowed — hidden [Visibility] 'LoginActivity' delay elapsed — showing ``` 3. **Check `[Init]`:** * `widgetConfig: null` or `backend returned NO widget_config` → the API key has no widget provisioned for that environment. This is **not** a code problem. Tell the user to confirm the key/environment pair with Revrag, and stop — do not try to work around it. 4. **Report to the user:** which shape you detected, the screen names you used, the files you changed, and what you saw in logcat. *** ## HARD RULES [#hard-rules] Violating these produces bugs that look like SDK faults and are hard to trace. 1. **Never write teardown.** No `detach()` in `onPause`/`onDestroy`/`onStop`. The next Activity claims the widget before the previous one dies; the SDK owns the hand-off. Teardown code is the single most common cause of "the widget disappears mid-flow". 2. **One mount mechanism per app.** `attachOverlay` **or** `EmbedProviderComposable`. Both = two widgets. 3. **Always use named arguments** on `attachOverlay` and `EmbedProviderComposable`. The parameter lists grow; positional calls break on upgrade. 4. **Import only `ai.revrag.embed.android.*`.** Never `ai.revrag.embed.core.*`. 5. **`attachOverlay` must run after `setContentView()`.** From `onResume` this is automatic; from `onCreate` it is your responsibility. 6. **Never invent an API key**, and never fall back to a placeholder to "make it build". Ask. 7. **Do not add `RECORD_AUDIO` handling yourself** — the SDK requests it. Just declare it in the manifest. 8. **Do not use an empty `allowedScreens`** to mean "the screens I listed". Empty means everywhere. 9. **Do not rename the user's screens** to match a config you wrote. Read the real names and write the config to match them. 10. **Do not turn off `autoTrackActivities`.** It defaults to `true`, and off means a forgotten Activity keeps a live microphone on a screen the product excluded. *** ## API REFERENCE [#api-reference] Everything below is `ai.revrag.embed.android.*`. ```kotlin // Initialize — once, in Application.onCreate EmbedSDK.initialize( context: Context, apiKey: String, embedUrl: String? = null, // null = production autoTrackActivities: Boolean = true, // leave true onResult: ((InitResult) -> Unit)? = null ) // Identify the user EmbedSDK.event(EventKeys.USER_DATA, mapOf("app_user_id" to "user-123")) // Mount / move / rename — XML and Compose Activities alike EmbedProvider.attachOverlay( activity: ComponentActivity, // required appUserId: String = "", appVersion: String = "", accentColor: Int = 0xFF6C63FF.toInt(), visibilityConfig: EmbedButtonVisibilityConfig = EmbedButtonVisibilityConfig(), navController: NavController? = null, currentScreen: String? = null, chatPanelUrl: String? = null ): EmbedOverlayHandle? // Lift the widget into a dialog's window EmbedProvider.attachOverlay(dialog: Dialog, activity: ComponentActivity) EmbedProvider.attachOverlay(window: Window, activity: ComponentActivity) // Compose hosts — single Activity @Composable fun EmbedProviderComposable( currentScreen: String, appUserId: String = "", accentColor: Color = Color(0xFF6C63FF), visibilityConfig: EmbedButtonVisibilityConfig = EmbedButtonVisibilityConfig(), navController: NavController? = null, chatPanelUrl: String? = null, content: (@Composable () -> Unit)? = null ) // Rename the current screen at any time handle.setCurrentScreen("Checkout") // ── Programmatic control (optional — the widget drives itself) ────────────── EmbedSDK.startCall(activity) // requests RECORD_AUDIO if needed EmbedSDK.startCall(activity, agentTriggerMode: AgentTriggerMode? = null) { started: Boolean -> } EmbedSDK.endCall() EmbedSDK.isCallActive(): Boolean // agentTriggerMode — WHO OWNS THE UI. A host decision, not the backend's. // AgentTriggerMode.CO_PILOT — your app's screens are the UI; the SDK draws // only its button. The usual choice. // AgentTriggerMode.WORKFLOW — Revrag draws its own panel (avatar screen with // Voice / Chat / Avatar tabs). // ⚠ Passed at startCall ONLY, never at initialize. A call the USER starts by // tapping the button carries no mode — only calls the host starts can carry // it. It does NOT enable video; that is media_mode in the backend's config. // ⚠ DEFAULT IS CO_PILOT. Omitting the argument, passing null, or a // user-initiated call all resolve to CO_PILOT + AUDIO. Do not pass // CO_PILOT explicitly to "make sure" — omit it. Pass WORKFLOW only when the // user has said Revrag should draw its own screen. EmbedSDK.collapseWidget() // dismiss the card, keep the call EmbedSDK.isWidgetExpanded // StateFlow EmbedSDK.minimizeAvatar() // leave full-screen avatar mode EmbedSDK.isAvatarOpen // StateFlow // ── Lifecycle callbacks ──────────────────────────────────────────────────── EmbedSDK.onAgent(AgentEvent.AGENT_CONNECTED) { } EmbedSDK.onAgent(AgentEvent.AGENT_DISCONNECTED) { data -> } // carries duration EmbedSDK.onAvatarState { isOpen -> } EmbedSDK.offAgent(...) / EmbedSDK.offAvatarState(...) // deregister! // AgentEvent: AGENT_CONNECTED, AGENT_DISCONNECTED, POPUP_MESSAGE_VISIBLE // ── Readiness ────────────────────────────────────────────────────────────── EmbedSDK.isInitialized(): Boolean // has credentials EmbedSDK.isInitializedFlow // StateFlow EmbedSDK.widgetConfig // StateFlow — non-null = can draw // ── Context on every event ───────────────────────────────────────────────── EmbedSDK.setCurrentFlow("loan_application") EmbedSDK.setAppVersion("4.2.0") // only if yours differs from PackageInfo // EventKeys: USER_DATA, SCREEN_STATE, ANALYTICS_DATA, CUSTOM_EVENT // ── Listening to events ──────────────────────────────────────────────────── val cb: EventCallback = { data -> } EmbedSDK.on(EventKeys.ANALYTICS_DATA, cb) EmbedSDK.off(EventKeys.ANALYTICS_DATA, cb) // SAME instance, or it does not unhook // Everything the SDK fires arrives on ANALYTICS_DATA keyed by "event_name": // EmbedAnalyticsEvents.AGENT_TAP_TO_OPEN / AGENT_TAP_TO_CLOSE / AGENT_VISIBLE // AGENT_CONVERSATION_STARTED / AGENT_CONVERSATION_ENDED / AVATAR_MODE_OPENED // POPUP_MESSAGE_VISIBLE / GEN_TOOL_TRIGGERED / MICROPHONE_PERMISSION_ALLOW // RAGE_CLICK / FORM_EVENT / ERROR // Compare against the constants, never string literals. // ── ⚠ On logout — REQUIRED if the app has auth ───────────────────────────── EmbedSDK.clearStorageCache() ``` **Button position** ```kotlin EmbedButtonInset(right = 24, bottom = 80, left = 24, top = 0) // dp from edges ``` Set `defaultInset` on the visibility config, or `inset` on a group to override it for that group's screens. Use it whenever a bottom navigation bar, FAB or sticky CTA would sit under the widget. **Config types** ```kotlin EmbedButtonVisibilityConfig( allowedScreens: List = emptyList(), // EMPTY = everywhere excludedScreens: List = emptyList(), // wins over allowedScreens showDelay: Long = 0L, groups: List = emptyList(), endCallWhenHiddenByVisibility: Boolean = true ) EmbedButtonGroupConfig( id: String, screens: List, continuity: EmbedButtonContinuity = EmbedButtonContinuity.PER_SCREEN, delayMs: Long = 0L, delayPolicy: EmbedButtonDelayPolicy = EmbedButtonDelayPolicy.PER_SCREEN ) enum EmbedButtonContinuity { PER_SCREEN, CONTINUOUS } enum EmbedButtonDelayPolicy { PER_SCREEN, ONCE_PER_GROUP_ENTRY, ONCE_PER_APP_SESSION } ``` **Screen-name resolution**, highest wins: 1. `currentScreen` / `handle.setCurrentScreen(...)` — explicit 2. `NavController` destination `android:label` or route 3. Fragment class name 4. Activity class name — the floor, never absent Naming one screen by hand disables rung 3 for the session: if you start naming, name them all. *** ## TROUBLESHOOTING (for the agent) [#troubleshooting-for-the-agent] | Symptom | Cause | Fix | | ---------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------- | | Button never appears; `[Init] widgetConfig: null` | key has no widget for that environment | not a code issue — tell the user to check key/environment with Revrag | | Button never appears; `[Visibility] 'X' NOT allowed` | `X` is not in `allowedScreens`, or is excluded | make the config match the reported name exactly, case included | | Button never appears; no `[Visibility]` line at all | mount never ran | `attachOverlay` missing, or called before `setContentView` | | Appears everywhere | `allowedScreens` empty | list the screens | | Disappears mid-flow | teardown code | remove `detach()` from lifecycle callbacks | | Two buttons | two mount mechanisms | keep one | | Call never starts | `RECORD_AUDIO` not declared | add it to the manifest | | Crash: `findNavController` in `onCreate` | `FragmentContainerView` timing | use `findFragmentById(...) as NavHostFragment` | | Unresolved reference on upgrade | positional args, or a `core` import | use named args; import only `…embed.android.*` | *** ## DEFINITION OF DONE [#definition-of-done] * [ ] Dependency added; `minSdk >= 24` * [ ] `INTERNET` + `RECORD_AUDIO` in the manifest * [ ] `EmbedSDK.initialize` in `Application.onCreate`; `Application` registered * [ ] Exactly one mount mechanism, matching the detected shape * [ ] `allowedScreens` non-empty and matching real reported names * [ ] Splash/trampolines in `excludedScreens` * [ ] No teardown code anywhere for the widget — but any `onAgent` / `onAvatarState` listener you registered is deregistered with its owner * [ ] `EmbedSDK.clearStorageCache()` wired into logout, if the app has auth * [ ] Inset set if a bottom bar / sticky CTA overlaps an allowed screen * [ ] `./gradlew :app:assembleDebug` succeeds * [ ] Logcat shows `delay elapsed — showing` on an allowed screen and `NOT allowed — hidden` on an excluded one * [ ] `SdkLifecycle` log tag shows events firing (`Widget visible` at minimum) * [ ] `USER_DATA` sent with a real `app_user_id` before any call * [ ] Reported to the user: shape, screen names, files changed, logcat evidence Full human-readable guide: [Android Integration Guide](/embed/integration/android-native)