Android
MarkdownKotlin, Jetpack Compose, Material 3. minSdk 24.
The SDK depends only on Kotlin, Compose, coroutines, OkHttp, kotlinx.serialization, Play Integrity and Play In-App Review (plus the AndroidX Activity and Lifecycle glue Compose needs). No Firebase, no image library, no DI framework.
Install
Section titled “Install”dependencyResolutionManagement { repositories { google(); mavenCentral() }}
// app/build.gradle.ktsandroid { compileSdk = 37 // required: the SDK and its Compose dependencies compile against Android SDK 37 defaultConfig { minSdk = 24 }}
dependencies { implementation("com.conciergesdk:concierge:0.1.1")}The artifact is a prebuilt, R8 optimized AAR on Maven Central. Its ProGuard rules travel inside it, so a minified release build needs no extra keep rules. A lower compileSdk fails the build at checkDebugAarMetadata; targetSdk can stay where it is.
Configure
Section titled “Configure”Call once in Application.onCreate. This makes no network calls. The device registers lazily, the first time chat opens or a push token arrives.
import android.app.Applicationimport com.conciergesdk.Concierge
class MyApp : Application() { override fun onCreate() { super.onCreate() Concierge.configure(this, appKey = "app_...") // Self-hosted or local API: // Concierge.configure(this, appKey = "app_...", baseUrl = "http://10.0.2.2:4000") }}If the app had no Application subclass yet, register it in AndroidManifest.xml:
<application android:name=".MyApp" ... >The emulator reaches a local API on your computer at 10.0.2.2. Android blocks plain HTTP by default, so allow it for that host in debug builds only:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <application android:networkSecurityConfig="@xml/network_security_config" /></manifest>
<!-- app/src/debug/res/xml/network_security_config.xml --><network-security-config> <domain-config cleartextTrafficPermitted="true"> <domain includeSubdomains="false">10.0.2.2</domain> </domain-config></network-security-config>Without it the chat stays offline and the SDK reports CLEARTEXT communication to 10.0.2.2 not permitted.
Open the chat
Section titled “Open the chat”// val context = LocalContext.currentButton(onClick = { Concierge.open(context) }) { Text("Support") }Concierge.open(context) works from anywhere you have a Context (an Activity, a click listener, a notification tap). Or embed the chat anywhere in Compose (a tab, a bottom sheet, a navigation destination):
import com.conciergesdk.ui.ConciergeChat
ConciergeChat(modifier = Modifier.fillMaxSize(), onClose = { navController.popBackStack() })Unread badge
Section titled “Unread badge”val unread by Concierge.unreadCount.collectAsStateWithLifecycle()BadgedBox(badge = { if (unread > 0) Badge { Text("$unread") } }) { /* Support button */ }Once this device has used chat, the count updates live while the app is in the foreground (a team reply moves the badge with the chat closed), refreshes when the app returns to the foreground or a Concierge push arrives, and drops to zero while the chat is visible.
Identify your user
Section titled “Identify your user”History follows the user across devices once identified. token is an optional HS256 JWT you sign on your server with the app’s identity secret (required when “require verified identity” is on). See Identity verification.
Concierge.identify(userId = user.id, name = user.name, email = user.email, token = jwtFromYourServer)Concierge.setMetadata(mapOf("plan" to "pro", "accountId" to user.accountId))Concierge.setContext(mapOf("plan" to "pro", "screen" to "Paywall", "appState" to mapOf("launches" to 3)))Concierge.breadcrumb("tapped_restore")
// On sign out: forgets the user and local history, the device continues anonymously.Concierge.logout()setMetadata sets string fields next to the conversation. setContext is structured context for your team and the AI agent: it merges into what you set before, a null value removes a key, and clearContext() removes them all. Breadcrumbs keep the last 50. Metadata, context and breadcrumbs are stored on the device and sent in a batch shortly after they change once the device has registered (the first time chat opens), so setting them before that is fine.
Language
Section titled “Language”The chat ships in 34 languages and follows the app locale (including AppCompatDelegate.setApplicationLocales and the Android 13+ per-app language). Override it with a BCP 47 tag, or null to follow the app again. An open chat updates in place.
Concierge.locale = "ar" // Arabic, laid out right to leftConcierge.locale = "de"Concierge.locale = nullArabic, Hebrew and Persian lay the chat out right to left even when the app doesn’t declare android:supportsRtl. See Localization.
Review prompt
Section titled “Review prompt”lifecycleScope.launch { val result = Concierge.maybeRequestReview(activity, ReviewMoment.Milestone)}Moments: Milestone, PurchaseCompleted, ContentCreated and Custom(name). The SDK calls the Play In-App Review API only when its local rules pass (not in the first 3 days, 30 days between attempts, once per app version, 3 per year, never over the chat) and the server agrees (no recent complaint or negative sentiment). It never asks a question first. result.shown means the Play API was called; result.reason says why not otherwise. After the chat closes the SDK also asks on its own when the server saw praise; turn that off with Concierge.reviewPromptAfterSupport = false.
Screen links
Section titled “Screen links”Register the screens support answers may link to, once at launch. See Knowledge autopilot.
Concierge.registerScreen("settings.backup", "Backup settings", "myapp://settings/backup")Concierge.unregisterScreen("settings.backup") // its buttons stop showingTapping the button closes the chat and opens the deep link with an ACTION_VIEW intent limited to your own app’s package. To route in app instead (Navigation Compose), set a handler:
Concierge.onOpenScreen { id, deepLink -> navController.navigate(Uri.parse(deepLink))}The deep link never leaves the device: Concierge learns only the id and title, and only while the knowledge autopilot is on. When an AI answer is about a registered screen, it can end with an open screen button; the SDK shows it only if that id is registered on this device. Ids are 1 to 64 letters, digits, ., _ or -, starting with a letter; titles are 1 to 120 characters; up to 100 screens. Registering an id again replaces it, and invalid input is logged and ignored.
Push (FCM)
Section titled “Push (FCM)”Concierge doesn’t depend on Firebase. Forward the token and data payloads from your own FirebaseMessagingService (registered in the manifest with the com.google.firebase.MESSAGING_EVENT intent filter). Full guide: Push notifications.
override fun onNewToken(token: String) = Concierge.registerPushToken(token)
override fun onMessageReceived(message: RemoteMessage) { if (Concierge.handleNotification(message.data)) { // Ours: show a notification that calls Concierge.open(context) when tapped. return } // Yours.}Review cards open their URL (use a market:// or Play Store link). Paywall cards call you back with the paywall id. Offer cards are redeemed on the server and handed to onOfferCard; the SDK has no billing dependency, so store offers need it (see Offers):
Concierge.onPaywallCard { paywallId -> showPaywall(paywallId) }
Concierge.onOfferCard { redemption -> billing.launch(redemption.mechanism, obfuscatedAccountId = redemption.accountToken)}Link purchases to customers
Section titled “Link purchases to customers”The refund desk, receipts and renewal heads ups only know a purchase is this customer’s when the store says so. Pass the customer’s account token as the obfuscated account id on your own purchases (it is derived from the verified user id, see Offers):
private val UUID_RE = Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
fun conciergeAccountToken(userId: String): String = if (UUID_RE.matches(userId)) userId.lowercase() else UUID.nameUUIDFromBytes(userId.toByteArray(Charsets.UTF_8)).toString()
val params = BillingFlowParams.newBuilder() .setProductDetailsParamsList(productDetailsParams) .setObfuscatedAccountId(conciergeAccountToken(user.id)) .build()With RevenueCat, Purchases.sharedInstance.logIn(user.id) with the same id you sign into the identity token does the job instead. Offer card purchases keep using redemption.accountToken.
Wire your paywall, offers and reviews
Section titled “Wire your paywall, offers and reviews”Your app already has a paywall, products and maybe offers and a review prompt. Concierge cards should open those, not a new screen. The setup prompt does this for you: it finds what the app has (RevenueCat, including RevenueCat Paywalls, Superwall, Adapty, Qonversion, Play Billing or a custom paywall), wires the handlers below and prefills your account. By hand:
- Paywall cards call
onPaywallCard(paywallId). Map the id to your existing paywall, placement or offering, and open the main paywall for any id you don’t recognize: the dashboard sendsdefaultunless you type another id. - Offer cards call
onOfferCard(redemption)after the server redeems them. Run your existing purchase flow forredemption.mechanismand passredemption.accountTokento the store so the sale is attributed to the card. - Review cards open the store review page by themselves, but only when the app’s store listing is set: the App Store id and Google Play package under Settings, General in the dashboard (or
update_app_settingson the MCP server). - Review prompts: replace your own native review request with
maybeRequestReviewat the same moment, so the two don’t compete for the system’s yearly limit. - After a purchase in your own flow, set the plan in the context and ask for a review at that moment.
- Purchase identity: see Link purchases to customers.
Concierge.onPaywallCard { paywallId -> Paywall.show(currentActivity, paywallId) } // unknown ids open the main paywall
Concierge.onOfferCard { r -> when (val m = r.mechanism) { is ConciergeOfferMechanism.AndroidOffer -> Billing.buy(currentActivity, m.productId, m.basePlanId, m.offerId, obfuscatedAccountId = r.accountToken) is ConciergeOfferMechanism.Product -> Billing.buy(currentActivity, m.productId, obfuscatedAccountId = r.accountToken) is ConciergeOfferMechanism.OfferCode -> currentActivity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(m.redeemUrl))) is ConciergeOfferMechanism.Paywall -> Paywall.show(currentActivity, m.paywallId) is ConciergeOfferMechanism.Comp -> Unit // granted on the server else -> Paywall.show(currentActivity, "default") }}
// After a purchase in your own flowConcierge.setContext(mapOf("plan" to productId))lifecycleScope.launch { Concierge.maybeRequestReview(activity, ReviewMoment.PurchaseCompleted) }The handlers run on the main thread; keep a reference to the current Activity for the billing flow. The Android SDK has no billing dependency, so store offers need onOfferCard. With Play Billing, take the offer token from productDetails.subscriptionOfferDetails where basePlanId and offerId match, and call setObfuscatedAccountId(r.accountToken). With RevenueCat, buy the SubscriptionOption whose id is "$basePlanId:$offerId" after Purchases.sharedInstance.setAttributes(mapOf("concierge_card" to r.accountToken)). Replace the ReviewManager flow with Concierge.maybeRequestReview.
The SDK reports which handlers are set (SDK 0.1.1 or later), so the dashboard’s Connect page, the verify_install MCP tool and npx @conciergesdk/cli doctor show whether paywall and offer cards are wired and whether review cards have a link. A card with no handler logs a warning and emits PaywallUnhandled` or `OfferUnhandled.
Events
Section titled “Events”Concierge.addEventListener { event -> when (event) { is ConciergeEvent.MessageSent -> analytics.track("support_message_sent") is ConciergeEvent.OfferRedeemed -> analytics.track("support_offer", event.redemption.offerId) else -> Unit }}Events: ChatOpened, ChatClosed, MessageSent, MessageReceived (with fromAi), UnreadCountChanged, CardShown, CardTapped, ReviewCardTapped, PaywallCardTapped, PaywallUnhandled, OfferRedeemed, OfferUnhandled, IdentityRejected, AvailabilityChanged, ReviewPromptShown, Error.
Appearance
Section titled “Appearance”Accent colors, title, welcome text and team name display come from your dashboard (cached for 5 minutes). Anything you set locally wins. See Appearance.
Concierge.appearance = ConciergeAppearance( accentLight = Color(0xFF0A7AFF), accentDark = Color(0xFF4DA3FF), title = "Help", colorScheme = ConciergeColorScheme.System, // or Light / Dark)Reset and suspend
Section titled “Reset and suspend”// "Erase all data": wipe Concierge on this device.Concierge.reset()
// A decoy or guest session: Concierge goes invisible and silent.Concierge.suspend()Concierge.resume()Concierge.isSuspendedreset()wipes Concierge on this device back to a fresh install without a network request: the device token in secure storage, the identity, context, cached history and appearance, the outbox, seen cards and handled tool calls. It also forgets the screens fromregisterScreenand theonOpenScreenhandler, so register them again after a reset. It closes the chat. Tools, the other handlers, event listeners andConcierge.appearancestay, and the next open registers a new device.- While suspended the SDK sends no request and keeps no socket open, the chat closes and won’t open, the support button and badge are hidden, the unread count reads 0 and no events fire. Consent prompts and tool calls wait: a pending call is picked up after
resume()or expires on the server. resume()brings the entry points back, refreshes the unread count and sends anything queued. Suspension lasts until the app process ends, so callsuspend()again on the next launch if needed.
How it behaves
Section titled “How it behaves”- Instant open: the last 200 messages are cached on disk and drawn before any network call.
- Realtime only while visible: the WebSocket opens when the chat reaches STARTED and closes on STOPPED. Reconnects use jittered exponential backoff and catch up from the last message seen.
- Offline outbox: messages are persisted before sending and delivered in order. Each has a client generated
clientId, so retries after a lost response never duplicate. Server rejections (4xx) show “Not sent” with Retry and Delete. - Photos: picked with the Android Photo Picker (no storage permission), resized to 1600px, JPEG compressed, uploaded once, then sent.
- Paging: scrolling to the top loads 50 older messages at a time.
- Read receipts and typing: the latest customer message shows Delivered or Read; team and AI typing shows in the list.
- AI messages always carry an “AI” label, and TalkBack reads them as “AI assistant”. The app’s
showAsAisetting only picks the name (“AI assistant” or your app’s name with the AI badge). When the AI agent is on, a line under the welcome says replies may come from an AI assistant. - Device token is encrypted with an AES-GCM key held in the Android Keystore.
- Accessibility: each message is one TalkBack element, status changes are announced, text follows the system font scale, dark mode follows the system.
Sample app
Section titled “Sample app”sample/ points at http://10.0.2.2:4000 (the local API from the emulator). Cleartext HTTP is allowed for that host in debug builds only. It has a Support button with an unread badge, an identify toggle and a breadcrumb button.