React Native and Expo
Markdown@conciergesdk/react-native is an Expo module that wraps the native iOS and Android SDKs, so the chat is the same native SwiftUI and Jetpack Compose screen on both platforms. Not a webview. The native SDKs ship as prebuilt binaries inside the package, free to use with a Concierge account.
- iOS 16.4+ and Android 7 (API 24)+, Expo SDK 58. Bare React Native apps should work once Expo Modules is installed, but we haven’t tested that path yet (see below).
- One API on both platforms. It matches the native SDKs one to one.
- A config plugin wires push entitlements and permissions during
expo prebuild. - In Expo Go, where native code can’t load, a small pure JS chat takes over with the same API, so you can prototype before you make a development build.
configuremakes no network calls. The device registers the first time the chat opens or a push token arrives.- History follows the user across devices once you call
identifywith a signed token.
Install
Section titled “Install”npx expo install @conciergesdk/react-nativeWith npm or yarn: npm install @conciergesdk/react-native or yarn add @conciergesdk/react-native.
Add the config plugin to app.json, then build a development client:
{ "expo": { "plugins": [["@conciergesdk/react-native", { "push": true }]] }}npx expo prebuildnpx expo run:ios # or run:android| Plugin option | Default | Effect |
|---|---|---|
push |
true |
iOS: adds the aps-environment entitlement. Android: adds POST_NOTIFICATIONS. |
apsEnvironment |
"development" |
Initial aps-environment value. Signing for the App Store switches it to production. |
backgroundRemoteNotifications |
false |
Adds UIBackgroundModes: remote-notification. Concierge doesn’t need it. |
The plugin never asks for notification permission. Your app decides when to ask.
Bare React Native (untested): the package is a standard Expo module, so the usual Expo Modules route should apply, but we haven’t run it in a bare app yet. Run npx install-expo-modules@latest once, then install the package and its peer dependencies (@react-native-async-storage/async-storage, expo-image-manipulator, expo-image-picker, expo-secure-store), then pod install in ios/. Bare apps don’t run config plugins, so add the Push Notifications capability in Xcode and POST_NOTIFICATIONS to your Android manifest yourself. If something breaks, tell us and we’ll fix it.
Quick start
Section titled “Quick start”import { Button } from 'react-native';import Concierge, { ConciergeProvider, useUnreadCount } from '@conciergesdk/react-native';
Concierge.configure({ appKey: 'app_YOUR_APP_KEY' });
export default function App() { return ( <ConciergeProvider> <SupportButton /> </ConciergeProvider> );}
function SupportButton() { const unread = useUnreadCount(); return <Button title={unread ? `Support (${unread})` : 'Support'} onPress={() => Concierge.open()} />;}With Expo Router (the default create-expo-app template), call configure at the top of the root layout, app/_layout.tsx (src/app/_layout.tsx in newer templates), outside the component, and wrap what the layout returns in <ConciergeProvider>. Put the Support button on any screen.
<ConciergeProvider> only matters for the Expo Go fallback (it hosts the JS chat modal). With the native module linked it just renders its children, so keep it in production.
configure({ appKey, baseUrl?, locale?, reviewPromptAfterSupport?, storage?, debug? }) is called once at startup. baseUrl defaults to https://api.conciergesdk.com and must be https; plain http works only for localhost and the emulator host addresses, or any host in a development build. debug: true shows a debug overlay in development builds (connection state, ids, tools, the last 20 requests). Release builds ignore it.
To try it against a local API, pass baseUrl: Platform.OS === 'android' ? 'http://10.0.2.2:4000' : 'http://localhost:4000' (the Android emulator reaches your computer at 10.0.2.2). Development builds from expo run already allow plain HTTP, so no manifest or Info.plist change is needed.
Entry points
Section titled “Entry points”Concierge.open()opens the chat full screen (a sheet on iOS, an activity on Android).Concierge.dismiss()closes it.<ConciergeChatView onClose={...} />is the chat as a component, for a tab, a modal or a navigation screen. WithonCloseit shows a close button.useConciergeEnabled()(orisEnabled()) isfalsewhile the app is turned off in the dashboard. While offopen()does nothing, so hide your Support button.
Unread count
Section titled “Unread count”const unread = useUnreadCount();The hook gives the live count of team and AI replies. Once the chat has been opened on the device, it updates live while the app is in the foreground, even with the chat closed, and refreshes when the app returns to the foreground. getUnreadCount() returns the last known value and refreshUnreadCount() fetches it now.
// After sign in, and on every launch while signed inConcierge.identify(user.id, { name: user.name, email: user.email, token });
// On sign outConcierge.logout();Cross-device history needs token: an HS256 JWT your server signs with the app’s identity secret, with exp at most 30 days out. See Identity verification. An identify without a token stays on this device only. A rejected token emits identityRejected; chat keeps working on this device.
logout() forgets the user on this device, and the next user never sees the previous chat. It does nothing for guests, so never call it at launch for signed-out users.
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.isSuspended();reset()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 dashboard appearance, the outbox, seen cards and handled tool calls, plus the screens fromregisterScreenand theonOpenScreenhandler. It closes the chat. Tools, other handlers andsetAppearanceoverrides stay, and the next open registers a new device.- While suspended the SDK sends no request and keeps no socket open, the chat closes and
open()does nothing, the unread count reads 0 and no events fire. Hide your own Support button whileisSuspended()is true. Consent prompts and tool calls wait: a pending call is picked up afterresume()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.
Context
Section titled “Context”Concierge.setMetadata({ plan: 'Pro' }); // string fields next to the conversationConcierge.setContext({ plan: 'pro', screen: 'Paywall' }); // structured context, mergedConcierge.breadcrumb('tapped_restore'); // last 50 are keptConcierge.captureError(error); // newest error, shown next to the conversationContext, breadcrumbs and the last error are batched and ride along with the next message the user sends. Never put names or emails in metadata; use identify. clearContext() removes every context key the app set.
Client tools
Section titled “Client tools”Register actions the AI agent or your team can ask the app to run. The user is asked first, then your handler runs and its result goes back to whoever asked.
const sub = Concierge.registerTool( 'resync_library', 'Resync the music library from the server', { type: 'object', properties: { full: { type: 'boolean' } } }, async ({ full }) => { const tracks = await library.resync({ full: full === true }); return { tracks }; // any JSON, up to 8KB }, { title: 'Resync your library?' },);// later: sub.remove();- Up to 32 tools.
schemais a JSON Schema object for the arguments. - The consent sheet text always comes from your registration, never from the server. Pass
requiresConsent: falsefor harmless actions. - The handler has 30 seconds. Throw to report a failure.
- With the native module the native SDK shows the sheet; your JS handler only runs the action. On Android, JS timers keep running while the native chat covers your app.
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 Linking.openURL (with the native module, the native SDK opens it). To route in app instead (React Navigation, Expo Router), set a handler:
Concierge.onOpenScreen((id, deepLink) => { router.push(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 notifications
Section titled “Push notifications”Concierge sends APNs pushes on iOS and FCM pushes on Android. Forward the device token (never the Expo push token) and route taps. With expo-notifications:
import * as Notifications from 'expo-notifications';import Concierge from '@conciergesdk/react-native';
// After your app has asked for permission:const token = await Notifications.getDevicePushTokenAsync();Concierge.registerPushToken(token);
Notifications.setNotificationHandler({ handleNotification: async (n) => { const data = n.request.trigger && 'payload' in n.request.trigger ? n.request.trigger.payload : n.request.content.data; const ours = Concierge.handleNotification(data, { openChat: false }); const show = !(ours && Concierge.isChatVisible()); return { shouldShowBanner: show, shouldShowList: show, shouldPlaySound: show, shouldSetBadge: false }; },});
Notifications.addNotificationResponseReceivedListener((r) => { const t = r.notification.request.trigger; Concierge.handleNotification(t && 'payload' in t ? t.payload : r.notification.request.content.data);});On Android, getDevicePushTokenAsync() returns the FCM token only when the app is set up with Firebase (android.googleServicesFile in app.json); without it, it throws. On iOS it returns the APNs token once the app has the Push Notifications capability, which the config plugin adds.
handleNotification returns true when the push came from Concierge, updates the badge and, by default, opens the chat. isConciergeNotification(payload) checks a payload without side effects. Server setup is in Push notifications.
Cards and events
Section titled “Cards and events”Your team or the AI can attach a card to a message: review opens your store review page, paywall calls onPaywallCard, and offer is an Offer Engine offer that the SDK redeems on the server for this device.
import Concierge, { type ConciergeOfferRedemption } from '@conciergesdk/react-native';
Concierge.onPaywallCard((paywallId) => navigation.navigate('Paywall', { paywallId }));
// Optional: run offer purchases yourself (RevenueCat, Superwall, your own billing)Concierge.onOfferCard(async (redemption: ConciergeOfferRedemption) => { await Purchases.setAttributes({ concierge_card: redemption.accountToken }); const m = redemption.mechanism; switch (m.kind) { case 'product': case 'win_back': return buyProduct(m.productId); case 'promotional': return buyWithPromoOffer(m.productId, m.offerId, m.signature); case 'android_offer': return buyPlayOffer(m.productId, m.basePlanId, m.offerId); case 'offer_code': return Linking.openURL(m.redeemUrl); case 'paywall': return showPaywall(m.paywallId); case 'comp': return; // granted on the server, a confirmation message follows in the chat }});ConciergeOfferRedemption is { cardId, offerId, mechanism, accountToken, expiresAt }. Bind the purchase to the card with accountToken so the sale is attributed to the conversation. Without onOfferCard, the SDK runs the default: StoreKit 2 for store offers on iOS, the redeem link for offer codes, onPaywallCard for paywalls, and “Unlocking…” for comps. Android has no billing dependency, so store offers there need onOfferCard; pass null to get the default back. usePaywallCard(fn) and useOfferCard(fn) set the same callbacks while a component is mounted.
const sub = Concierge.addEventListener((event) => { if (event.type === 'identityRejected') console.warn('Identity token rejected');});// later: sub.remove();Events: chatOpened, chatClosed, messageSent, messageReceived, unreadCountChanged, cardShown, cardTapped, reviewOpened, paywallOpened, paywallUnhandled, offerRedeemed, offerFailed, offerUnhandled, purchaseCompleted, purchaseFailed, identityRejected, error.
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. The simplest route is RevenueCat with the app user id set to the same id you sign into the identity token (Purchases.logIn(user.id)). Otherwise pass the customer’s account token on your own purchases (it is derived from the verified user id, see Offers). The uuid package’s v3 mixes in a namespace and gives a different value, so compute it with any MD5 that returns bytes, for example js-md5:
import { md5 } from 'js-md5';import { requestPurchase } from 'react-native-iap';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function conciergeAccountToken(userId: string): string { if (UUID_RE.test(userId)) return userId.toLowerCase(); const b = md5.array(userId); // MD5 of the UTF-8 bytes b[6] = (b[6] & 0x0f) | 0x30; b[8] = (b[8] & 0x3f) | 0x80; const h = b.map((x) => x.toString(16).padStart(2, '0')).join(''); return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;}
// react-native-iap 17const token = conciergeAccountToken(user.id);await requestPurchase({ type: 'in-app', request: { apple: { sku, appAccountToken: token }, google: { skus: [sku], obfuscatedAccountId: token }, },});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, react-native-iap, expo-iap 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.
import { Linking } from 'react-native';import { router } from 'expo-router';import Concierge from '@conciergesdk/react-native';
Concierge.onPaywallCard((paywallId) => router.push({ pathname: '/paywall', params: { placement: paywallId } }));
Concierge.onOfferCard(async (r) => { const m = r.mechanism; if (m.kind === 'paywall') return router.push({ pathname: '/paywall', params: { placement: m.paywallId } }); if (m.kind === 'offer_code') return Linking.openURL(m.redeemUrl); if (m.kind === 'comp') return; // granted on the server if (m.kind === 'product' || m.kind === 'promotional' || m.kind === 'win_back' || m.kind === 'android_offer') { return purchase(m, r.accountToken); // your purchase code } router.push('/paywall');});
// After a purchase in your own flowConcierge.setContext({ plan: productId });await Concierge.maybeRequestReview('purchase_completed');Set the handlers next to configure, outside any component (Expo Router’s router works there). With RevenueCat, call Purchases.setAttributes({ concierge_card: accountToken }) first, buy with Purchases.purchaseStoreProduct, promotional offers with Purchases.getPromotionalOffer and Purchases.purchaseDiscountedProduct, Play offers with Purchases.purchaseSubscriptionOption, and present RevenueCat Paywalls with RevenueCatUI.presentPaywall({ offering }). Replace StoreReview.requestReview() with Concierge.maybeRequestReview. Test in a development build: Expo Go has no native purchases.
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.
Appearance
Section titled “Appearance”Dashboard settings apply without an app update. Local overrides win:
Concierge.setAppearance({ accentLight: '#4F46E5', accentDark: '#A5B4FC', title: 'Help', welcome: 'Hi! How can we help?' });showsCloseButton is iOS only and colorScheme is Android only. See Appearance.
Language
Section titled “Language”The chat ships in 34 languages and follows the device language. To pin one, for example a language picked inside your app, pass a BCP 47 tag, or null to follow the device again. An open chat updates in place.
Concierge.setLocale('ar'); // Arabic, laid out right to leftConcierge.setLocale('pt-BR');Concierge.setLocale(null);Concierge.getLocale(); // the override, or nullconfigure({ locale }) sets it at startup. Arabic, Hebrew and Persian lay the chat out right to left, and unsupported languages fall back to English. Text you set in the dashboard (title, welcome message) is shown as written. See Localization.
Reviews, cancel flow and paywall help
Section titled “Reviews, cancel flow and paywall help”const result = await Concierge.maybeRequestReview('milestone');// result.shown, result.reason
Concierge.presentCancelFlow({ productId: 'pro_yearly' });
const session = await Concierge.paywallHelp({ paywallId: 'main', products: [{ id: 'pro_yearly', price: '$59.99', period: 'year' }] });maybeRequestReview(moment)asks the system review API only when the native SDK’s local rules and the server agree, the same rules as on iOS and Android. Moments aremilestone,purchase_completed,content_createdor your own name.shownmeans the system API was called;reasonsays why not otherwise. Expo Go resolvesunsupported_platform.reviewPromptAfterSupport: falseinconfigureturns off the automatic ask after a chat with praise.presentCancelFlow()shows the in-app cancel flow from your own “Cancel subscription” button: why are you leaving, then the best retention step for that reason. “Continue to cancel” is on every screen and goes to the store’s subscription page, so cancelling is never blocked.paywallHelp()is for a “Questions?” button on your paywall. It sends what the paywall shows so answers fit it, then opens the chat, and returns aPaywallHelpSession(pass itsaccountTokento the purchase) ornullwhen it could not start.
Expo Go fallback
Section titled “Expo Go fallback”Expo Go can’t load third party native code, so the SDK logs one dev warning and runs a small JS client against the same API: text, photos (library or camera), history paging, typing, Delivered and Read, review, paywall and offer cards, client tools (consent is a React Native Alert) and a persistent outbox that survives an app kill and never sends twice. isNativeAvailable() returns false there.
Install the optional packages it uses. All of them ship in Expo Go:
npx expo install @react-native-async-storage/async-storage expo-secure-store expo-image-picker expo-image-manipulatorWithout AsyncStorage every reload is a new anonymous customer; without expo-secure-store the device token stays in AsyncStorage instead of the Keychain or Keystore. The fallback has no billing, so store offers need onOfferCard. For the real native chat, use a development build.
How it behaves
Section titled “How it behaves”- Native on both platforms: the module links the prebuilt native SDKs (
Concierge.xcframeworkon iOS, shipped inside the package, and thecom.conciergesdk:conciergeAAR from Maven Central on Android), so the chat, offline outbox, cached history and photo handling are exactly the native SDKs’. - Same protocol everywhere: one inbox covers iOS, Android, React Native, Flutter and the web widget, and history follows a verified user across all of them.
- Realtime only while needed: the WebSocket opens while the chat is visible, and in the foreground once the chat has been opened (for live unread and tool calls). It closes in the background.
Example app
Section titled “Example app”sdk/react-native/example is an Expo app pointed at a local API (http://localhost:4000, http://10.0.2.2:4000 on the Android emulator) with the seeded demo key app_demo000000000000000000. It has a Support button with an unread badge, an identify toggle, an embedded <ConciergeChatView /> and an event log.
cd sdk/react-native && npm installcd example && npm installnpx expo prebuildnpx expo run:ios # or: npx expo run:android