# React Native and Expo

> One package for iOS and Android, wrapping the native SDKs, with a JS fallback for Expo Go.

Source: https://conciergesdk.com/docs/react-native/

`@conciergesdk/react-native` is an Expo module that wraps the native [iOS](https://conciergesdk.com/docs/ios.md) and [Android](https://conciergesdk.com/docs/android.md) 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.
- `configure` makes 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 `identify` with a signed token.

## Install

```sh
npx expo install @conciergesdk/react-native
```

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

```json
{
  "expo": {
    "plugins": [["@conciergesdk/react-native", { "push": true }]]
  }
}
```

```sh
npx expo prebuild
npx 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

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

- `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. With `onClose` it shows a close button.
- `useConciergeEnabled()` (or `isEnabled()`) is `false` while the app is turned off in the dashboard. While off `open()` does nothing, so hide your Support button.

## Unread count

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

## Users

```ts
// After sign in, and on every launch while signed in
Concierge.identify(user.id, { name: user.name, email: user.email, token });

// On sign out
Concierge.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](https://conciergesdk.com/docs/identity.md). 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

```ts
// "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 from `registerScreen` and the `onOpenScreen` handler. It closes the chat. Tools, other handlers and `setAppearance` overrides 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 while `isSuspended()` is true. 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 call `suspend()` again on the next launch if needed.


## Context

```ts
Concierge.setMetadata({ plan: 'Pro' });                 // string fields next to the conversation
Concierge.setContext({ plan: 'pro', screen: 'Paywall' }); // structured context, merged
Concierge.breadcrumb('tapped_restore');                  // last 50 are kept
Concierge.captureError(error);                            // newest error, shown next to the conversation
```

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

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.

```ts
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. `schema` is a JSON Schema object for the arguments.
- The consent sheet text always comes from your registration, never from the server. Pass `requiresConsent: false` for 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

Register the screens support answers may link to, once at launch. See [Knowledge autopilot](https://conciergesdk.com/docs/knowledge.md).

```ts
Concierge.registerScreen('settings.backup', 'Backup settings', 'myapp://settings/backup');
Concierge.unregisterScreen('settings.backup'); // its buttons stop showing
```

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

```ts
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](https://conciergesdk.com/docs/knowledge.md) 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

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

```ts
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](https://conciergesdk.com/docs/push.md).

## 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](https://conciergesdk.com/docs/offers.md) offer that the SDK redeems on the server for this device.

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

```ts
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`, `offerRedeemed`, `offerFailed`, `offerUnhandled`, `purchaseCompleted`, `purchaseFailed`, `identityRejected`, `error`.

## 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](https://conciergesdk.com/docs/offers.md#link-purchases-to-customers)). 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`:

```ts
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 17
const 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`.

## Appearance

Dashboard settings apply without an app update. Local overrides win:

```ts
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](https://conciergesdk.com/docs/appearance.md).

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

```ts
Concierge.setLocale('ar');    // Arabic, laid out right to left
Concierge.setLocale('pt-BR');
Concierge.setLocale(null);
Concierge.getLocale();        // the override, or null
```

`configure({ 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](https://conciergesdk.com/docs/localization.md).

## Reviews, cancel flow and paywall help

```ts
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](https://conciergesdk.com/docs/ios.md) and [Android](https://conciergesdk.com/docs/android.md). Moments are `milestone`, `purchase_completed`, `content_created` or your own name. `shown` means the system API was called; `reason` says why not otherwise. Expo Go resolves `unsupported_platform`. `reviewPromptAfterSupport: false` in `configure` turns 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 a `PaywallHelpSession` (pass its `accountToken` to the purchase) or `null` when it could not start.

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

```sh
npx expo install @react-native-async-storage/async-storage expo-secure-store expo-image-picker expo-image-manipulator
```

Without 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

- **Native on both platforms:** the module links the prebuilt native SDKs (`Concierge.xcframework` on iOS, shipped inside the package, and the `com.conciergesdk:concierge` AAR 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

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

```sh
cd sdk/react-native && npm install
cd example && npm install
npx expo prebuild
npx expo run:ios       # or: npx expo run:android
```
