View as Markdownllms.txt

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 <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.

NeededWhyIf unknown
API keyrequired to initializemust ask — never invent one
Environment URLkey and environment are a pairdefault null (production)
Which screens show the agentdrives allowedScreensmust ask — do not guess from screen names
User id expressionattaches events to a userask; 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:

    ActivitiesScreens inside themShape
    exactly oneCompose setContent {} + NavHostD
    exactly oneNavHostFragment / fragment transactionsB
    severalone screen each, no nav host anywhereA
    severalat least one has a NavHostFragment / BottomNavigationViewC
    severalat 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

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 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

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.onCreateregisterActivityLifecycleCallbacks with onActivityResumed { 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

  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
  1. 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.
  2. 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.

  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

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 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

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:

  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)

SymptomCauseFix
Button never appears; [Init] widgetConfig: nullkey has no widget for that environmentnot a code issue — tell the user to check key/environment with Revrag
Button never appears; [Visibility] 'X' NOT allowedX is not in allowedScreens, or is excludedmake the config match the reported name exactly, case included
Button never appears; no [Visibility] line at allmount never ranattachOverlay missing, or called before setContentView
Appears everywhereallowedScreens emptylist the screens
Disappears mid-flowteardown coderemove detach() from lifecycle callbacks
Two buttonstwo mount mechanismskeep one
Call never startsRECORD_AUDIO not declaredadd it to the manifest
Crash: findNavController in onCreateFragmentContainerView timinguse findFragmentById(...) as NavHostFragment
Unresolved reference on upgradepositional args, or a core importuse named args; import only …embed.android.*

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