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.mdand integrate the Revrag Embed SDK into this app. My API key is<KEY>.
Everything the agent needs is below. A typical integration is 3 files and a few minutes.
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
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:
-
Does an
Applicationsubclass exist? Search for: Application()andandroid:nameinAndroidManifest.xml. If none, you will create one. -
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 {}+NavHostD exactly one NavHostFragment/ fragment transactionsB several one screen each, no nav host anywhere A several at least one has a NavHostFragment/BottomNavigationViewC 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.
-
What are the screen names? Read them, do not invent them:
- Shape A → Activity class names (
LoginActivity) - Shape B/C → nav graph
android:labelvalues, or fragment class names - Shape D/E → NavHost route strings Report the list you found back to the user with your plan.
- Shape A → Activity class names (
Phase 1 — Dependency and permissions
1a. Add to the app module's build.gradle.kts (or .gradle):
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 <manifest>:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />⚠ 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
Create the Application subclass if absent, register it in the manifest
(<application android:name=".MyApp" …>), and add:
override fun onCreate() {
super.onCreate()
EmbedSDK.initialize(this, "<API_KEY>") { result ->
if (!result.success) Log.e("Embed", "init failed: ${result.error}")
}
EmbedSDK.event(EventKeys.USER_DATA, mapOf("app_user_id" to "<USER_ID>"))
}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, "<KEY>", embedUrl = "<URL>") { … }
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.
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
allowedScreensmeans 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
CONTINUOUSgroup, so the call survives navigation. - Names are matched exactly and are case-sensitive.
Phase 4 — Mount
Apply exactly one of the following, matching the shape from Phase 0.2.
Shape A — XML, one Activity per screen
In Application.onCreate, after initialize. Touch no Activity file.
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityResumed(activity: Activity) {
EmbedProvider.attachOverlay(
activity = activity as? ComponentActivity ?: return,
appUserId = "<USER_ID>",
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
In that Activity's onCreate, after setContentView:
val navHost = supportFragmentManager
.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
val navController = navHost.navController
navController.addOnDestinationChangedListener { _, _, _ ->
EmbedProvider.attachOverlay(
activity = this,
appUserId = "<USER_ID>",
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 = "<name>" from wherever
it swaps fragments instead, and omit navController.
Shape C — XML, several Activities with several screens
Both of the above. Create one helper so there is a single call site:
// In the Application's companion object
fun attachEmbed(activity: Activity) {
val host = activity as? ComponentActivity ?: return
EmbedProvider.attachOverlay(
activity = host,
appUserId = "<USER_ID>",
visibilityConfig = EMBED_VISIBILITY,
navController = (activity as? MainActivity)?.embedNavController,
currentScreen = null
)
}Application.onCreate→registerActivityLifecycleCallbackswithonActivityResumed { attachEmbed(activity) }(other overrides empty).- The tabbed Activity → expose the controller and re-attach on destination change:
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
Wrap the existing content. Do not restructure the app.
setContent {
val navController = rememberNavController()
val backStack by navController.currentBackStackEntryAsState()
EmbedProviderComposable(
currentScreen = backStack?.destination?.route ?: "<start route>",
appUserId = "<USER_ID>",
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
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)
For any DialogFragment / BottomSheetDialogFragment that should not cover the
widget, add to onStart():
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)
If the app has authentication, find the sign-out path and add:
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:
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)
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.
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<String, Any?>) -> 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
- Build.
./gradlew :app:assembleDebug. Fix compile errors. - 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- Check
[Init]:widgetConfig: nullorbackend 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.
- 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
Violating these produces bugs that look like SDK faults and are hard to trace.
- Never write teardown. No
detach()inonPause/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". - One mount mechanism per app.
attachOverlayorEmbedProviderComposable. Both = two widgets. - Always use named arguments on
attachOverlayandEmbedProviderComposable. The parameter lists grow; positional calls break on upgrade. - Import only
ai.revrag.embed.android.*. Neverai.revrag.embed.core.*. attachOverlaymust run aftersetContentView(). FromonResumethis is automatic; fromonCreateit is your responsibility.- Never invent an API key, and never fall back to a placeholder to "make it build". Ask.
- Do not add
RECORD_AUDIOhandling yourself — the SDK requests it. Just declare it in the manifest. - Do not use an empty
allowedScreensto mean "the screens I listed". Empty means everywhere. - Do not rename the user's screens to match a config you wrote. Read the real names and write the config to match them.
- Do not turn off
autoTrackActivities. It defaults totrue, and off means a forgotten Activity keeps a live microphone on a screen the product excluded.
API REFERENCE
Everything below is ai.revrag.embed.android.*.
// 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<Boolean>
EmbedSDK.minimizeAvatar() // leave full-screen avatar mode
EmbedSDK.isAvatarOpen // StateFlow<Boolean>
// ── 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<Boolean>
EmbedSDK.widgetConfig // StateFlow<WidgetConfig?> — 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
EmbedButtonInset(right = 24, bottom = 80, left = 24, top = 0) // dp from edgesSet 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
EmbedButtonVisibilityConfig(
allowedScreens: List<String> = emptyList(), // EMPTY = everywhere
excludedScreens: List<String> = emptyList(), // wins over allowedScreens
showDelay: Long = 0L,
groups: List<EmbedButtonGroupConfig> = emptyList(),
endCallWhenHiddenByVisibility: Boolean = true
)
EmbedButtonGroupConfig(
id: String,
screens: List<String>,
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:
currentScreen/handle.setCurrentScreen(...)— explicitNavControllerdestinationandroid:labelor route- Fragment class name
- 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)
| 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
- Dependency added;
minSdk >= 24 -
INTERNET+RECORD_AUDIOin the manifest -
EmbedSDK.initializeinApplication.onCreate;Applicationregistered - Exactly one mount mechanism, matching the detected shape
-
allowedScreensnon-empty and matching real reported names - Splash/trampolines in
excludedScreens - No teardown code anywhere for the widget — but any
onAgent/onAvatarStatelistener 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:assembleDebugsucceeds - Logcat shows
delay elapsed — showingon an allowed screen andNOT allowed — hiddenon an excluded one -
SdkLifecyclelog tag shows events firing (Widget visibleat minimum) -
USER_DATAsent with a realapp_user_idbefore any call - Reported to the user: shape, screen names, files changed, logcat evidence
Full human-readable guide: Android Integration Guide