# Concierge (full)
> In-app support SDK for iOS, Android, React Native and Flutter, plus a web widget for websites. Pro is one flat monthly plan with 1,000 AI replies included (Founder price $19 a month, locked while you stay subscribed, for the first 50 subscribers. Then $49 a month); no free plan and no trial. The product overview comes first, then every docs page in reading order.
## What is Concierge?
Concierge is an in-app support SDK for mobile apps. It adds a native support chat to iOS, Android, React Native and Flutter apps and to websites, answers users first with an AI agent grounded in your own docs, and lets developers run the support inbox from Claude Code, Cursor, Codex or any other MCP client through a remote MCP server. It is built for indie and small app teams: Pro is one flat monthly plan with AI replies included ($19 a month, locked while you stay subscribed, for the first 50 subscribers, then $49 a month), there is no free plan and no trial, and the SDKs ship as prebuilt native binaries.
## Key facts (checked October 2026)
- **Platforms:** iOS 16+ (SwiftUI), Android minSdk 24 (Compose), React Native and Expo (one package wrapping both native SDKs), and Flutter (one plugin wrapping both native SDKs). Web widget (script tag or npm, with a React binding).
- **Pricing:** Pro is one flat monthly plan: unlimited apps and seats, 1,000 AI replies included. Founder price $19 a month, locked while you stay subscribed, for the first 50 subscribers. Then $49 a month, same plan. Scale on request. No free plan, no trial: before you pay, test with up to 3 of your own devices. Prices exclude applicable taxes.
- **AI agent:** Answers from your knowledge docs, labeled as AI, hands off to a human on request.
- **MCP server:** Read and write tools over Streamable HTTP. Works with Claude Code, Cursor, Codex, Windsurf, VS Code with Copilot, Gemini CLI, Zed and any MCP client.
- **AI cost:** Measured average model cost per AI reply: $0.00075 (October 2026 eval), which is why replies are included, not metered.
- **License:** SDKs ship as prebuilt binaries, free to use with a Concierge account.
- **Privacy:** App servers and database in Canada (OVHcloud, Beauharnois) behind Cloudflare; attachments in private Cloudflare R2 storage in North America. Subprocessors: OVHcloud, Cloudflare, Anthropic, Resend and Stripe; error tracking is self-hosted. A DPA with SCCs covers end user data, and end user data can be exported or deleted from the dashboard, API or MCP. Cookieless website analytics.
## Pricing
- **Pro** ($19/month founder price, locked while the subscription stays active, for the first 50 paid subscriptions; then $49/month, flat): For apps with paying users. Unlimited apps and seats; iOS, Android, React Native and Flutter SDKs, plus a web widget; 1,000 AI replies a month included; MCP server for every coding agent; Refund desk, cancel moment and review drafts; Offers with proven revenue; Email support, replies by email; Two way translation (coming soon); Slack replies (coming soon); In-app analytics (coming soon).
- **Scale** (priced on request): For big apps and teams with rules. Everything in Pro; Higher AI volume; SSO and audit log; Data residency; An SLA.
**Before you pay:** There is no free plan and no trial. Before you pay you can sign up, add your apps, write help articles, follow the install steps and send test messages from up to 3 of your own devices. All AI (chat replies, the knowledge autopilot, review reply drafts and the rest) and real users start when you subscribe.
## AI automations
- **Refund desk**: Answers Apple's consumption request inside the 12 hour window from your policy, then asks the user in the chat what went wrong.
- **Locked purchase check**: "I paid but it's locked" gets the store, RevenueCat and your app compared, then Restore purchases with the user's OK.
- **Cancel moment**: Auto renew off triggers a gentle check in and an offer only if your policy allows it. Cancelling is never blocked.
- **Review replies, drafted**: New App Store and Google Play reviews are sorted and get a reply drafted in the reviewer's language. Nothing posts until you approve.
- **Billing heads ups**: A quiet receipt after purchase and a heads up before a yearly renewal or a trial converts. Fewer "I didn't mean to buy this" refunds.
- **Knowledge autopilot**: Reads your links, product notes and resolved chats, drafts help articles for your review and refreshes them when a new app version shows up.
- **Spam filter**: The first three messages of every new thread are screened, and spam or abuse is flagged in your inbox.
- **New message emails**: Held two minutes so the AI or a teammate can answer first, then sent instantly or hourly, your choice. A morning digest lists who is still waiting.
- **Refund rate alerts**: A warning when your 30 day refund rate passes the threshold you set, and an alert if a refund answer fails or nears its deadline.
- **Version spike alerts**: When one app version brings a wave of tickets about the same problem, you see it with real examples. Set one reply and every affected user gets it until you mark it fixed.
- **Email support**: Users email your app's support address (or you forward your own to it) and it lands in your inbox as a conversation. Replies from the dashboard, your coding agent or the AI go back by email. Sending from your own domain is coming soon.
## Works with every coding agent
The remote MCP server has 42 tools that read and write. It speaks Streamable HTTP, so it works with Claude Code, Cursor, Codex, Windsurf, VS Code with Copilot, Gemini CLI, Zed and any other MCP client. Setup prompt and llms.txt work in any agent.
## Numbers
- **$0.00075** average model cost of one AI reply. Measured in our October 2026 eval, tool calls included.
- **1,000** AI replies a month in the Pro plan. A hard cap, never a surprise bill.
- **12 h** Apple's window to answer a refund request. The refund desk answers inside it, from your policy.
- **0** taps counted as money kept. A save counts as proven only when a store webhook confirms the purchase.
- **91%** of test conversations passed our AI eval. 40 of 44, graded on grounding, language, safety and tone. October 2026.
- **1.4 s** median time to an AI reply. End to end in the same eval, tool calls included. 95% arrive within 3.8 s.
## Compared with other in-app support tools
| | Concierge | Typical in-app support tools |
|---|---|---|
| Native SDKs | iOS, Android, React Native and Expo (with an Expo Go fallback), Flutter and a web widget | Often iOS first, a webview wrapper, or React Native on one platform only |
| Paid plans start at | $19/mo founder price (first 50), then $49/mo flat, unlimited apps and seats | Per seat, or a flat plan that caps apps or seats |
| AI replies | Included: 1,000/mo on Pro | Billed per resolution, or included with unpublished limits |
| MCP server | Read and write, 42 tools | None, read only, or on a higher tier |
| Chat history | Per user, across devices, paged | Often per device, or capped to recent messages |
| Switching | One prompt for your coding agent | Manual rewrite |
Summarized from the public pricing pages, docs and SDKs of in-app support and helpdesk tools for mobile apps. Checked October 2026. Spot something out of date? Email us and we'll fix it.
## Switch from another in-app support tool
Paste one prompt into a coding agent and it replaces the current support SDK with Concierge. Page: https://conciergesdk.com/switch/. Guide: https://conciergesdk.com/docs/migrate.md.
```text
Replace this project's in-app support SDK with Concierge (https://conciergesdk.com).
My Concierge app key: app_YOUR_APP_KEY
1. Read https://conciergesdk.com/docs/migrate.md and the guide for this platform
(https://conciergesdk.com/llms.txt lists every page as Markdown).
2. Find the current support SDK. Search the dependency files (Package.swift, Podfile,
project.pbxproj, build.gradle or build.gradle.kts, package.json, app.json, pubspec.yaml)
and the code for its init or configure, identify or login, logout, custom attributes or
metadata, open chat, unread count, push token and notification calls. List every call site
before changing anything.
3. Install Concierge by following the install steps in the guide for this platform: add the
package, then call configure with app_YOUR_APP_KEY. Show me the diff before you write it.
For a website, add @conciergesdk/web.
4. Swap each call using the mapping table in migrate.md. Keep the same user id the old SDK used.
Custom attributes become string metadata; never put names or emails in metadata.
5. Identity: if my backend signed a user hash or JWT for the old tool, add an endpoint that signs
a Concierge identity token (HS256, sub = user id, exp at most 30 days) with the identity secret,
and pass it to identify. The secret stays on the server, never in the app or the repo.
6. Remove the old SDK completely: dependency, imports, keys, plist or manifest entries and its
push handler. Leave no dead code behind.
7. Saved replies, macros and help articles: if the Concierge MCP server is connected, add each
one with upsert_knowledge and draft: true, one article per topic. Otherwise write them to
support-export/knowledge/ as Markdown. If the old tool hosts a help center, give me its URL
so I can add it under Knowledge, Sources, Add a link.
8. Conversations: if the old tool has an export or an API, write every thread to
support-export/conversations/conversations.json in the shape migrate.md shows, add
support-export/ to .gitignore and never commit it. Import it with the import_conversations
MCP tool.
Import is part of Pro. List the customers still waiting for a reply.
9. Build, open Support in the app and send "Hello from the switch". Confirm the dashboard
shows Connected, or check it with the verify_install MCP tool.
10. Report: files changed, each old call and what replaced it, anything left as
TODO(concierge), the knowledge drafts, the imported conversations and the waiting customers.
```
## FAQ
### How much does Concierge cost?
Pro is one flat monthly plan for unlimited apps and seats, with 1,000 AI replies a month included. The first 50 paid subscriptions get the founder price, $19 a month, locked for as long as the subscription stays active (canceling ends it). After that Pro is $49 a month. Same plan, same features. Scale is priced on request. There is no free plan, and there are no per seat or per resolution fees.
### Is there a free trial?
No, and no free plan either. Before you pay you can sign up, add your apps, write help articles, follow the install steps and send test messages from up to 3 of your own devices, so you can see the chat working in your app. No AI runs before you subscribe: chat replies, the knowledge autopilot, review reply drafts and the other AI features start when you do, along with real users. Your test messages never land in the spam folder. You can cancel any time in the billing portal; Pro stays on until the end of the month you paid for.
### Which coding agents work with Concierge?
Any agent that speaks MCP over Streamable HTTP: Claude Code, Cursor, Codex, Windsurf, VS Code with GitHub Copilot, Gemini CLI, Zed and other MCP clients. Sign in with OAuth where the client supports it, or paste a staff API key as a bearer header. The setup prompt and llms.txt work in any agent, MCP or not.
### What runs on autopilot?
The AI agent answers from your docs and hands off when it should. Around it: the refund desk answers Apple's consumption request inside the 12 hour window, the locked purchase check compares store, RevenueCat and app state, the cancel moment asks what went wrong, review replies are drafted for your approval, billing heads ups go out before renewals, knowledge autopilot keeps help articles fresh, and a spam filter screens new threads. The refund desk and billing heads ups stay off until you turn them on.
### Which platforms does Concierge support?
Native SDKs for iOS (SwiftUI, iOS 16 and up, zero dependencies) and Android (Kotlin and Jetpack Compose, minSdk 24) work today, and so does the React Native and Expo package, which wraps the same native SDKs. The Flutter plugin (concierge_sdk) wraps them too. A web widget for websites and web apps (one script tag, or the @conciergesdk/web npm package with a React binding) talks to the same inbox.
### Does it work with Flutter?
Yes. The concierge_sdk plugin wraps the native iOS and Android SDKs, so a Flutter app gets the same native SwiftUI and Compose chat, push, cards and client tools, with one Dart API on both platforms. It needs iOS 16.4 or later and Android minSdk 24. Flutter web is not covered by the plugin; use the web widget there. Install it from pub.dev with flutter pub add concierge_sdk.
### Does it work in Expo Go?
Yes. The React Native package includes a pure JavaScript fallback chat that runs in Expo Go, so you can prototype before making a development build. In a development or production build you get the native SwiftUI and Compose chat, and a config plugin sets up push during expo prebuild. Install it with npx expo install @conciergesdk/react-native.
### Is there a web widget for my website?
Yes: @conciergesdk/web. Add one script tag (or install the npm package, with a React provider and hooks) and you get a launcher and chat panel rendered in a Shadow DOM, so your CSS and the chat never mix. It loads about 27 KB gzip at page load, makes no API call until someone opens the chat, and supports live replies, photos, review and offer cards, client tools with consent, identity tokens and an offline outbox. The API only answers browsers on websites you list under Allowed websites. Install it with npm install @conciergesdk/web or load it from jsDelivr.
### What does MCP native mean?
Concierge runs a remote MCP server with tools that read and write: list and read conversations with device info and breadcrumbs, reply as you, send review and offer cards, update status, search customers, check stats, verify an install and edit the AI knowledge base. It works with Claude Code, Cursor, Codex, Windsurf, VS Code with Copilot, Gemini CLI, Zed and any MCP client, so "triage support and fix the crash Ana reported" can be one prompt.
### Do my users need an account?
No. The chat works for signed out users as soon as the SDK is configured. Call identify when someone signs in to attach the chat to their account, and pass a server signed token to get their history on every device. If you want only verified users to write, turn that on per app in the dashboard.
### What happens when I'm offline?
If the AI agent is on, it answers first. Anything it can't solve waits in your inbox, and if nobody has replied after two minutes you get an email (instantly or at most hourly, your choice), plus a morning digest of who is still waiting. When you reply, from the dashboard or from your coding agent over MCP, the user gets it live and by push. If the user is the one offline, their messages queue on the device and send when they reconnect.
### Can I make the chat match my app?
Yes. Set the accent color for light and dark mode, the chat title and the welcome line in the dashboard, and they apply without an app update. Anything you set in code wins. You choose where the chat opens: any button, row or menu can call it, and an unread count is there for your badge.
### How do the AI replies work?
When you turn the agent on, it answers new messages from the knowledge docs you add (FAQs, pricing, known issues) using a Claude model. Replies are labeled as AI. When a user asks for a human it hands off, pauses on that conversation and marks it high priority. When you reply yourself it pauses too, so it never talks over you. AI usage is included in every plan.
### What data does the SDK collect, and where does it go?
The SDK makes no network call at launch. It registers the device the first time the chat opens or a push token arrives, then sends messages, photos the user picks, app version, OS, device model, language, and any metadata and breadcrumbs you set. Messages are stored on Concierge servers. When the AI agent is on, the conversation and your knowledge docs are sent to the Claude API to write the reply. Identity verification with a server signed token stops anyone from reading another user's history.
### How do I switch from my current support SDK?
Paste one prompt into your coding agent. It finds the old SDK, installs Concierge by following our platform guide (or run npx @conciergesdk/cli init yourself), swaps the configure, identify, metadata, open chat and push calls, removes the old dependency, turns your saved replies into knowledge drafts and imports your conversation history on Pro, so nobody waits. Most SDKs map almost call for call, and you can run both side by side while you test. The prompt and a full guide are at conciergesdk.com/switch.
### Is Concierge a good fit for indie apps?
Yes, if your support happens inside a mobile app. Many support tools are built for larger teams and price by seat plus a fee for every AI resolution. Concierge is a native mobile chat with one flat monthly Pro plan ($19/mo founder price for the first 50, then $49/mo), unlimited seats, AI replies included, and an MCP server that both reads and writes.
### Can I self host Concierge?
Not as a supported product today. Concierge is a hosted service. The SDKs accept a custom server URL, which is how we run them against a local API in development, but we do not ship a packaged server.
### How heavy is the SDK?
Small. The iOS SDK has zero dependencies. The Android SDK uses only Kotlin, Compose, coroutines, OkHttp and kotlinx.serialization, with no Firebase or image library. Neither makes a network call when your app launches. The web widget loads about 27 KB gzip with the page and the rest of the chat (about 7 KB) only when it opens.
---
# Getting started
> Add in-app support chat to your iOS or Android app in about five minutes.
Source: https://conciergesdk.com/docs/
Concierge is in-app support for iOS, Android, React Native and Flutter, with a web widget for your website. Your users tap Support inside your app and chat with you in a native screen. You answer from the dashboard, your phone, or Claude Code. An AI agent can answer first, from your own docs, and hand off to you the moment someone asks for a human.
## How it fits together
- **SDK** in your app: a native chat screen, an unread badge, push handling. Zero network calls at launch.
- **API** at `/v1/sdk/*`: the SDK talks to it with your public app key. You never call it yourself.
- **Dashboard**: your inbox, settings, knowledge for the AI agent, and the setup prompt.
- **MCP server**: the same inbox, inside Claude Code, Cursor or Codex. See [MCP server](https://conciergesdk.com/docs/mcp.md).
## 1. Create an app
Sign up, then add your app. You get two keys:
| Key | Looks like | Where it lives |
|---|---|---|
| App key | `app_...` | In your app. It's public, so embedding it is fine. |
| Identity secret | `ids_...` | On your server only. It signs user tokens. See [Identity verification](https://conciergesdk.com/docs/identity.md). |
## 2. Install the SDK
The fastest path: open **Setup** in the dashboard, press **Copy setup prompt**, and paste it into your coding agent. The prompt covers the install line for your platform, `configure`, a Support entry point and a test message.
Already using another in-app support SDK? [Switch in one prompt](https://conciergesdk.com/docs/migrate.md): your agent swaps the calls, keeps your user ids and moves saved replies into knowledge.
Prefer doing it by hand? Pick your platform:
- [iOS](https://conciergesdk.com/docs/ios.md): Swift Package Manager, SwiftUI, iOS 16+.
- [Android](https://conciergesdk.com/docs/android.md): Gradle, Jetpack Compose, minSdk 24.
- [React Native and Expo](https://conciergesdk.com/docs/react-native.md): one Expo module for iOS and Android, with a JS fallback for Expo Go.
- [Flutter](https://conciergesdk.com/docs/flutter.md): one pub.dev plugin for iOS and Android, wrapping the native SDKs.
- [Web widget](https://conciergesdk.com/docs/web.md): one script tag or an npm package.
The short version on iOS:
```swift
import Concierge
Concierge.configure(appKey: "app_YOUR_APP_KEY")
// Anywhere in your UI:
Button("Contact Support") { Concierge.present() }
```
And on Android:
```kotlin
Concierge.configure(this, appKey = "app_YOUR_APP_KEY")
Button(onClick = { Concierge.open(context) }) { Text("Support") }
```
## 3. Send a test message
Run the app, open Support and say hi. The dashboard's setup screen waits for that first message and flips to **Connected** the moment it lands. Reply from the inbox and watch it arrive on the device.
## 4. Make it yours
- Tell Concierge who the user is with `identify`, so their history follows them to every device. Then turn on [identity verification](https://conciergesdk.com/docs/identity.md) so nobody can pretend to be them.
- Forward push tokens so replies reach users who left the app: [Push notifications](https://conciergesdk.com/docs/push.md).
- Match your brand without an app update: [Appearance](https://conciergesdk.com/docs/appearance.md).
- Let the AI agent take the first reply: [AI agent and knowledge](https://conciergesdk.com/docs/ai-agent.md).
> **One command:** `npx @conciergesdk/cli init` detects your project (Swift, Kotlin, Expo, bare React Native, Flutter), installs the SDK, wires push and waits for your first test message. `npx @conciergesdk/cli doctor` checks the install afterwards.
---
# iOS
> Native SwiftUI support chat for iOS 16+, with zero dependencies.
Source: https://conciergesdk.com/docs/ios/
Native SwiftUI, zero dependencies, iOS 16+ and Mac Catalyst. Ships as a prebuilt binary, free to use with a Concierge account.
- Opens instantly: history is cached on the device, and `configure` makes no network calls.
- History follows the user across devices once you call `identify`.
- Unlimited history with paging, not a 50 message cap.
- Offline outbox: messages queue while offline and send in order when the connection is back.
- Live updates over a WebSocket only while the chat is on screen.
- Photos, typing indicator, read receipts, review and offer cards, AI replies clearly labeled.
- Dynamic Type, VoiceOver, dark mode, strings ready for localization.
## Install
Swift Package Manager. In Xcode: **File > Add Package Dependencies**, enter `https://github.com/conciergesdk/concierge-ios`, choose **Up to Next Major Version** from 0.1.0 and add the `Concierge` product to your app target.
The package is a prebuilt `Concierge.xcframework` (device, Simulator and Mac Catalyst) built with library evolution, so it works with your Xcode and Swift version without compiling SDK sources. Strings and the privacy manifest (`PrivacyInfo.xcprivacy`) are inside it, so Xcode's privacy report picks the SDK up on its own. No Info.plist keys are needed: photos come from the system picker, which asks for no permission.
```swift
// Package.swift
.package(url: "https://github.com/conciergesdk/concierge-ios.git", from: "0.1.0")
// target dependency
.product(name: "Concierge", package: "concierge-ios")
```
## Quick start
```swift
import Concierge
import SwiftUI
@main
struct MyApp: App {
init() {
Concierge.configure(appKey: "app_YOUR_APP_KEY")
}
var body: some Scene {
WindowGroup {
ContentView()
.conciergeSupportButton() // floating Support button with an unread badge
}
}
}
```
`configure` takes an optional `baseURL` for a self-hosted or local API, for example `Concierge.configure(appKey: "app_YOUR_APP_KEY", baseURL: URL(string: "http://localhost:4000")!)`. It must be HTTPS; plain HTTP is accepted only for `localhost` and `127.0.0.1`.
Or open it from your own control:
```swift
Button("Contact Support") { Concierge.present() }
```
## Entry points
| API | Use it for |
|---|---|
| `Concierge.present()` | Presents the chat as a sheet over the top view controller. |
| `ConciergeChatView()` | Embeds the chat anywhere (sheet, tab). Has its own navigation stack and close button. |
| `ConciergeChatView(embedInNavigationStack: false)` | Push it onto your own `NavigationStack`. |
| `.conciergeSupportButton(alignment:title:)` | A floating capsule button that opens the chat. |
| `ConciergeSupportButton()` | The same button, placed wherever you like. |
| `ConciergeUnreadBadge()` | A red count capsule for any row or button. Hidden at zero. |
## Unread count
`Concierge.shared` is an `ObservableObject`:
```swift
@ObservedObject private var concierge = Concierge.shared
Text("Support").badge(concierge.unreadCount)
```
The count updates after the chat closes, when the app returns to the foreground, and when a Concierge push arrives. `await Concierge.refreshUnreadCount()` fetches it on demand. Nothing is fetched at cold launch.
## Users
```swift
// After sign in (and on every launch while signed in):
Concierge.identify(userId: user.id, name: user.name, email: user.email)
// With identity verification: a JWT your server signs with the app's identity secret (HS256).
Concierge.identify(userId: user.id, token: try await api.conciergeToken())
// On sign out:
Concierge.logout()
```
- `identify` is staged locally and sent with the next chat request, so it costs nothing at launch.
- The server merges this device into the customer for `userId`, so their history shows up on every device they sign in on.
- The token is kept in memory only. Fetch a fresh one every launch.
- `logout` is a no-op for guests. Never call it at launch for signed-out users.
- Account switch: `logout()` then `identify(...)` with no `await` in between.
See [Identity verification](https://conciergesdk.com/docs/identity.md) for the server side.
## Reset and suspend
```swift
// "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 appearance, the outbox, seen cards and handled tool calls. It closes the chat. Tools and handlers 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 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 call `suspend()` again on the next launch if needed.
## Context
```swift
Concierge.setMetadata(["plan": "Pro", "signup": "2026-09-01"]) // replaces the previous dictionary
Concierge.breadcrumb("tapped_restore") // the last 50 are kept
Concierge.setContext(["plan": "pro", "screen": "Paywall", "appState": ["syncing": true]]) // merges; NSNull() removes a key
Concierge.captureError(error) // the latest error, shown to the team
```
App version, OS, device model and language are reported automatically. Don't put names or emails in metadata; use `identify`.
## Screen links
Register the screens support answers may link to, once at launch. See [Knowledge autopilot](https://conciergesdk.com/docs/knowledge.md).
```swift
Concierge.registerScreen("settings.backup", title: "Backup settings", deepLink: URL(string: "myapp://settings/backup")!)
Concierge.unregisterScreen("settings.backup") // its buttons stop showing
```
Tapping the button closes the chat and opens the deep link with `UIApplication.shared.open`. To route in app instead (SwiftUI `NavigationPath`, coordinators), set a handler:
```swift
Concierge.onOpenScreen { id, url in
router.open(url)
}
```
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 never asks for notification permission. Forward the raw APNs token and route taps. A SwiftUI app gets these callbacks from an app delegate attached with `@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate` in your `App`. Full guide: [Push notifications](https://conciergesdk.com/docs/push.md).
```swift
import Concierge
import UIKit
import UserNotifications
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
UNUserNotificationCenter.current().delegate = self
return true
}
func application(_ app: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
Concierge.registerPushToken(token) // sandbox or production is detected for you
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
if Concierge.handleNotification(notification.request.content.userInfo, openChat: false) {
return Concierge.shared.isChatVisible ? [] : [.banner, .sound]
}
return [.banner, .sound]
}
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
Concierge.handleNotification(response.notification.request.content.userInfo) // opens the chat
}
}
```
Keep the class on the main actor (the default for new Xcode app targets, or mark it `@MainActor`); don't mark the delegate methods `nonisolated`. You still request permission and call `registerForRemoteNotifications()` yourself, and the target needs the Push Notifications capability.
## Cards and events
```swift
Concierge.onPaywallCard { paywallId in
paywallPresenter.show(paywallId) // RevenueCat, Superwall, your own StoreKit screen
}
// Optional: run offer purchases yourself. Without it, store offers buy through StoreKit 2.
Concierge.onOfferCard { redemption in
purchaser.buy(redemption.mechanism, appAccountToken: redemption.appAccountToken)
}
Concierge.onEvent { event in
switch event {
case .chatOpened, .chatClosed, .messageSent, .messageReceived, .reviewOpened: break
case .paywallOpened(let paywallId): analytics.log("support_paywall", paywallId)
case .purchaseCompleted(let productId): entitlements.refresh(productId)
case .identityRejected(let error): print("Identity token rejected: \(error)")
default: break
}
}
```
Review cards open their URL (for example `itms-apps://...?action=write-review`). Paywall cards call `onPaywallCard`. Offer cards are redeemed on the server, then handed to `onOfferCard` or bought with StoreKit 2. See [Offers](https://conciergesdk.com/docs/offers.md).
### 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 `appAccountToken` on your own purchases (it is derived from the verified user id, see [Offers](https://conciergesdk.com/docs/offers.md#link-purchases-to-customers)):
```swift
import CryptoKit
import Foundation
func conciergeAccountToken(_ userId: String) -> UUID {
if let uuid = UUID(uuidString: userId) { return uuid }
var b = Array(Insecure.MD5.hash(data: Data(userId.utf8)))
b[6] = (b[6] & 0x0f) | 0x30
b[8] = (b[8] & 0x3f) | 0x80
return UUID(uuid: (b[0],b[1],b[2],b[3],b[4],b[5],b[6],b[7],b[8],b[9],b[10],b[11],b[12],b[13],b[14],b[15]))
}
let result = try await product.purchase(options: [.appAccountToken(conciergeAccountToken(user.id))])
```
With RevenueCat, `Purchases.shared.logIn(user.id)` with the same id you sign into the identity token does the job instead. Offer card purchases keep using `redemption.appAccountToken`.
## Review prompts
```swift
let result = await Concierge.maybeRequestReview(moment: .milestone) // .purchaseCompleted, .contentCreated, .custom("first_export")
Concierge.reviewPromptAfterSupport = false // default true
```
The SDK calls StoreKit's `AppStore.requestReview(in:)` only when every rule passes: not in the first 3 days of the install, at most 3 attempts per 365 days, 90 days apart, once per app version, never over the chat, and the server agrees (no recent complaint, negative sentiment or open conversation). `result.reason` says why not. With `reviewPromptAfterSupport` on, the SDK also checks right after the chat closes, and the server allows that only after praise or a positive resolution.
## Appearance
Dashboard settings apply without an app update. Anything you set in code wins. See [Appearance](https://conciergesdk.com/docs/appearance.md).
## Localization
The chat ships in 34 languages and follows the user's preferred languages (including the per app language from Settings), even when your app itself is not localized. Arabic, Hebrew and Persian lay the chat out right to left. To pin a language, for example one picked inside your app:
```swift
Concierge.locale = "ar" // a BCP 47 tag: "ja", "pt-BR", "zh-Hant"; nil follows the system again
```
An open chat updates in place. Unsupported languages fall back to English. Text you set in the dashboard (title, welcome message) is shown as written.
## How it behaves
- **Network:** none at `configure`. The device registers lazily on the first chat open or when a push token is registered. The device token is stored in the Keychain (this device only).
- **Cache:** the latest 300 messages, the outbox and the remote config live in Application Support, excluded from backups.
- **Realtime:** a WebSocket is open only while the chat is visible and the app is in the foreground. It reconnects with exponential backoff (1s up to 30s, with jitter) and resumes from the last message it saw.
- **Outbox:** each message has a client generated `clientId`, so retries never create duplicates. Transient failures (offline, timeouts, 5xx, 429) retry on their own and survive app restarts. Rejected messages show "Not delivered" with Try Again and Delete.
- **Photos:** up to 4 per message, picked with the system photo picker (no permission prompt), downscaled to 2048px and compressed to JPEG before upload.
---
# Android
> Native Jetpack Compose support chat for Android, minSdk 24.
Source: https://conciergesdk.com/docs/android/
Kotlin, 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
```kotlin
// settings.gradle.kts
dependencyResolutionManagement {
repositories { google(); mavenCentral() }
}
// app/build.gradle.kts
android {
compileSdk = 37 // required: the SDK and its Compose dependencies compile against Android SDK 37
defaultConfig {
minSdk = 24
}
}
dependencies {
implementation("com.conciergesdk:concierge:0.1.0")
}
```
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
Call once in `Application.onCreate`. This makes no network calls. The device registers lazily, the first time chat opens or a push token arrives.
```kotlin
import android.app.Application
import 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`:
```xml
```
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:
```xml
10.0.2.2
```
Without it the chat stays offline and the SDK reports `CLEARTEXT communication to 10.0.2.2 not permitted`.
## Open the chat
```kotlin
// val context = LocalContext.current
Button(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):
```kotlin
import com.conciergesdk.ui.ConciergeChat
ConciergeChat(modifier = Modifier.fillMaxSize(), onClose = { navController.popBackStack() })
```
## Unread badge
```kotlin
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
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](https://conciergesdk.com/docs/identity.md).
```kotlin
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
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.
```kotlin
Concierge.locale = "ar" // Arabic, laid out right to left
Concierge.locale = "de"
Concierge.locale = null
```
Arabic, Hebrew and Persian lay the chat out right to left even when the app doesn't declare `android:supportsRtl`. See [Localization](https://conciergesdk.com/docs/localization.md).
## Review prompt
```kotlin
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
Register the screens support answers may link to, once at launch. See [Knowledge autopilot](https://conciergesdk.com/docs/knowledge.md).
```kotlin
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 an `ACTION_VIEW` intent limited to your own app's package. To route in app instead (Navigation Compose), set a handler:
```kotlin
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](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 (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](https://conciergesdk.com/docs/push.md).
```kotlin
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.
}
```
## Cards
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](https://conciergesdk.com/docs/offers.md)):
```kotlin
Concierge.onPaywallCard { paywallId -> showPaywall(paywallId) }
Concierge.onOfferCard { redemption ->
billing.launch(redemption.mechanism, obfuscatedAccountId = redemption.accountToken)
}
```
### 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](https://conciergesdk.com/docs/offers.md#link-purchases-to-customers)):
```kotlin
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`.
## Events
```kotlin
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`, `OfferRedeemed`, `OfferUnhandled`, `IdentityRejected`, `AvailabilityChanged`, `ReviewPromptShown`, `Error`.
## 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](https://conciergesdk.com/docs/appearance.md).
```kotlin
Concierge.appearance = ConciergeAppearance(
accentLight = Color(0xFF0A7AFF),
accentDark = Color(0xFF4DA3FF),
title = "Help",
colorScheme = ConciergeColorScheme.System, // or Light / Dark
)
```
## Reset and suspend
```kotlin
// "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 appearance, the outbox, seen cards and handled tool calls. It also forgets the screens from `registerScreen` and the `onOpenScreen` handler, so register them again after a reset. It closes the chat. Tools, the other handlers, event listeners and `Concierge.appearance` 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 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 call `suspend()` again on the next launch if needed.
## 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 `showAsAi` setting 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
`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.
---
# 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 (
);
}
function SupportButton() {
const unread = useUnreadCount();
return
` on every page that should show the chat:
```html
```
It configures itself from the data attributes and exposes `window.Concierge`:
| Attribute | Default | What it does |
|---|---|---|
| `data-app-key` | (required) | Your public app key. |
| `data-base-url` | `https://api.conciergesdk.com` | API URL, for a local API in development. |
| `data-launcher` | `true` | `false` hides the floating button; open the chat from your own. |
| `data-position` | `right` | `left` puts the launcher and panel in the bottom left corner. |
| `data-accent` | from the dashboard | `#RRGGBB` accent, so the launcher has your color before the first open. |
| `data-accent-dark` | `data-accent` | `#RRGGBB` accent for dark mode. |
| `data-capture-errors` | `false` | `true` reports uncaught errors with `captureError`. |
| `data-locale` | browser languages | Any BCP 47 tag (`ja`, `pt-BR`, `zh-Hant`). 34 languages ship; see [Localization](https://conciergesdk.com/docs/localization.md). |
| `data-debug` | `false` | Shows the debug overlay. |
Calls made before the async script finishes loading can wait in a queue. Put this above the script tag (in `
` is fine):
```html
```
The queued calls run right after the widget configures itself. From then on `window.Concierge` is the SDK itself and has no `q`, so code that runs later calls it directly (`Concierge.open()`). The `concierge:ready` event fires on `window` once it is ready.
`concierge.js` is a few hundred bytes that import the module build next to it. If your Content Security Policy or hosting setup can't load modules from the CDN, point the same tag at `https://cdn.jsdelivr.net/npm/@conciergesdk/web@0.1/dist/concierge.bundle.js` instead: the same widget as one classic file, with every language built in (about 80 KB gzip).
### npm
```bash
npm install @conciergesdk/web
```
```ts
import { Concierge } from '@conciergesdk/web';
Concierge.configure({ appKey: 'app_YOUR_APP_KEY' });
// From your Help link
document.querySelector('#help')?.addEventListener('click', () => Concierge.open());
```
Call `configure` once, in your client entry (for example `src/main.ts`). It shows the floating launcher, like the script tag. It takes the same settings as the data attributes: `baseUrl`, `launcher`, `position`, `captureErrors`, `locale` and `debug`, plus `zIndex` (stacking order, default `2147483000`). For a local API in development:
```ts
Concierge.configure({ appKey: 'app_YOUR_APP_KEY', baseUrl: 'http://localhost:4000' });
```
`configure` is safe to import during server side rendering (Next.js, Remix, Astro): it does nothing until it runs in a browser.
### React
```tsx
import { ConciergeProvider, useUnreadCount, useConcierge } from '@conciergesdk/web/react';
export function App() {
return (
);
}
export function HelpButton() {
const unread = useUnreadCount();
const concierge = useConcierge();
return ;
}
```
`session` stands for your own auth state, with a token your server signed (see [Identify your users](#identify-your-users)). Use the provider instead of calling `configure` yourself: it takes the same options as props (`baseUrl`, `launcher`, `locale`...), configures once, identifies `user` (and logs out when it goes from a user to `null`), and renders only its children. Hooks: `useUnreadCount`, `useChatVisible`, `useConciergeEnabled`, `useConciergeEvent`, `usePaywallCard`, `useOfferCard`, `useOpenScreen`, `useScreen`.
## Identify your users
```ts
Concierge.identify(user.id, { name: user.name, email: user.email, token });
```
Sign `token` on your server with the identity secret (see [Identity verification](https://conciergesdk.com/docs/identity.md)). With a valid token the conversation follows the user across browsers and devices, mobile apps included. New apps require verified users, so an `identify` without a token is refused (`401 identity_verification_required`, the `identityRejected` event). Call `identify` on every page load while signed in, and `Concierge.logout()` when they sign out (never on load for signed out visitors).
## Reset and suspend
```ts
// "Clear my data": wipe Concierge in this browser.
Concierge.reset();
// Hide Concierge completely (for example in a kiosk or guest mode).
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 `localStorage`, the identity, context, cached history and appearance, the outbox, seen cards and handled tool calls. It also forgets screens from `registerScreen` and the `onOpenScreen` handler. It closes the chat. Tools, other handlers and appearance 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 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 page reloads, so call `suspend()` again on the next launch if needed.
## Open, close and the unread badge
```ts
Concierge.open();
Concierge.dismiss();
Concierge.toggle();
Concierge.setLauncherVisible(false);
const sub = Concierge.onUnreadCountChange((count) => updateBadge(count));
Concierge.getUnreadCount(); // the last known count
sub.remove(); // stop listening
```
The unread count stays live while the tab is visible for visitors who have chatted before, the same rule as the mobile SDKs.
## Context, breadcrumbs and errors
```ts
Concierge.setContext({ plan: 'pro', screen: 'billing' });
Concierge.breadcrumb('clicked_upgrade');
Concierge.captureError(error);
Concierge.setMetadata({ signup: '2026-03-01' });
```
The team sees all of it next to the conversation. Context rides along with the next message, so nothing extra is sent while the chat is closed. `configure({ captureErrors: true })` reports uncaught errors and unhandled rejections for you.
## Client tools
Let the AI agent or your team ask the page to do something, with the visitor's consent:
```ts
Concierge.registerTool(
'export_invoices',
'Export the invoices as a CSV download',
{ type: 'object', properties: { year: { type: 'number' } } },
async ({ year }) => ({ file: await exportInvoices(Number(year)) }),
{ title: 'Export your invoices?' },
);
```
The consent prompt shows your `title` and always names the tool. Its text never comes from the server, and server fields can't turn consent off. **Allow** runs the handler (30 second limit), **Not now** declines.
## Screen links
Register the pages support answers may link to. See [Knowledge autopilot](https://conciergesdk.com/docs/knowledge.md). Links must be on the page's own origin, relative or absolute; links to other origins are refused with a console warning.
```ts
Concierge.registerScreen('settings.backup', 'Backup settings', '/settings/backup');
Concierge.unregisterScreen('settings.backup'); // its buttons stop showing
```
Tapping the button closes the chat and navigates with `location.assign`. In a single page app, route it yourself; the handler gets the screen id and the absolute same-origin URL:
```ts
Concierge.onOpenScreen((_id, url) => router.push(new URL(url).pathname));
```
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. Safe to call before `configure`.
## Cards
Review, paywall and offer cards render in the thread with the same design as the mobile SDKs, including the offer price row. On the web:
- **Review** cards open the store page in a new tab (only App Store and Google Play links are opened).
- **Paywall** cards, and offers that resolve to a paywall, call your handler:
```ts
Concierge.onPaywallCard((paywallId) => router.push(`/pricing?plan=${paywallId}`));
```
- **Offer** cards are redeemed for the web, where the server only picks `paywall` and `comp` steps from the offer's chains (store purchases can't run in a browser). Take over with `onOfferCard` to run your own checkout and pass `redemption.accountToken` along (RevenueCat: subscriber attribute `concierge_card`).
Store purchases happen in your mobile apps, so linking them to customers (refund desk, receipts) is set up there: see [Link purchases to customers](https://conciergesdk.com/docs/offers.md#link-purchases-to-customers).
## Language
The widget follows the browser's preferred languages. To use your site's language instead, pass a BCP 47 tag when you configure (calling `configure` again with the same key changes nothing):
```ts
Concierge.configure({ appKey: 'app_YOUR_APP_KEY', locale: 'ar' });
```
Arabic, Hebrew and Persian lay the widget out right to left. Regions and scripts fall back sensibly (`es-MX` uses Spanish). The script tag takes `data-locale`. See [Localization](https://conciergesdk.com/docs/localization.md).
## Appearance
The accent, title and welcome text come from **Settings, Chat appearance** in the dashboard and are cached, so returning visitors see your color at once. Override locally:
```ts
Concierge.setAppearance({ accentLight: '#0A7CFF', accentDark: '#4DA3FF', title: 'Help', colorScheme: 'system' });
```
Light and dark follow `prefers-color-scheme`. Text on the accent switches to dark automatically for light accents.
## Events
```ts
Concierge.addEventListener((event) => analytics.track(`concierge_${event.type}`, event));
```
`chatOpened`, `chatClosed`, `messageSent`, `messageReceived`, `unreadCountChanged`, `cardShown`, `cardTapped`, `reviewOpened`, `paywallOpened`, `offerRedeemed`, `offerFailed`, `offerUnhandled`, `identityRejected`, `availabilityChanged`, `error`.
## How it behaves
- **Network:** nothing at page load for a first time visitor. The browser registers as a `web` device the first time the chat opens. Requests go out with `credentials: 'omit'`, so your site's cookies never reach the API.
- **Live replies:** a WebSocket while the chat is open (and for returning visitors while the tab is visible), with reconnect and backoff, catching up on anything it missed.
- **Offline:** messages you send offline wait in an outbox that survives reloads and go out, in order and never twice, when the connection is back. A rate limited send waits out `Retry-After` and reads "Sending soon".
- **Photos:** pick, paste or drop up to 4 images. They are scaled to 2048px and re-encoded as JPEG in the browser before upload.
- **Storage:** the device token, identity and outbox live in `localStorage`, queued photos in IndexedDB, all scoped to your origin.
- **Attestation:** browsers can't attest. With device checks on, a browser registers as `dev`, which `enforce` lets through only while development mode is on.
- **Browsers:** current Chrome, Edge, Firefox and Safari (Safari 15 and later).
## Debug overlay
```ts
Concierge.configure({ appKey: 'app_...', debug: true });
```
A draggable panel shows the connection, app key, user, customer and device ids, registered tools, pending tool calls and the last 20 requests. It logs a warning if you leave it on outside localhost.
---
# Switch from another support tool
> Replace your current in-app support SDK with Concierge in one prompt for your coding agent. Call mapping, identity, knowledge, conversation history and a checklist.
Source: https://conciergesdk.com/docs/migrate/
Already running another in-app chat or helpdesk SDK? Paste the prompt below into Claude Code, Cursor, Codex or any coding agent. It installs Concierge from the platform guide, swaps every SDK call, keeps identity verified, moves your saved replies into knowledge, and imports your conversation history so nobody is left waiting. The short version lives at [conciergesdk.com/switch](/switch/).
## Before you start
- A Concierge app. Sign up, add your app and copy the **app key** (`app_...`) and the **identity secret** (`ids_...`). See [Getting started](https://conciergesdk.com/docs.md).
- Optional but worth it: connect the [MCP server](https://conciergesdk.com/docs/mcp.md) to your agent, so it can write knowledge drafts for you.
- A clean git working tree, so the whole switch is one reviewable change.
There is no free plan and no trial, but you can do the whole switch before you pay: install, configure and send test messages from up to 3 of your own devices. Importing conversation history is part of Pro.
## The prompt
Replace `app_YOUR_APP_KEY` with your app key, then paste it into your agent from the root of your app's repo.
```text
Replace this project's in-app support SDK with Concierge (https://conciergesdk.com).
My Concierge app key: app_YOUR_APP_KEY
1. Read https://conciergesdk.com/docs/migrate.md and the guide for this platform
(https://conciergesdk.com/llms.txt lists every page as Markdown).
2. Find the current support SDK. Search the dependency files (Package.swift, Podfile,
project.pbxproj, build.gradle or build.gradle.kts, package.json, app.json, pubspec.yaml)
and the code for its init or configure, identify or login, logout, custom attributes or
metadata, open chat, unread count, push token and notification calls. List every call site
before changing anything.
3. Install Concierge by following the install steps in the guide for this platform: add the
package, then call configure with app_YOUR_APP_KEY. Show me the diff before you write it.
For a website, add @conciergesdk/web.
4. Swap each call using the mapping table in migrate.md. Keep the same user id the old SDK used.
Custom attributes become string metadata; never put names or emails in metadata.
5. Identity: if my backend signed a user hash or JWT for the old tool, add an endpoint that signs
a Concierge identity token (HS256, sub = user id, exp at most 30 days) with the identity secret,
and pass it to identify. The secret stays on the server, never in the app or the repo.
6. Remove the old SDK completely: dependency, imports, keys, plist or manifest entries and its
push handler. Leave no dead code behind.
7. Saved replies, macros and help articles: if the Concierge MCP server is connected, add each
one with upsert_knowledge and draft: true, one article per topic. Otherwise write them to
support-export/knowledge/ as Markdown. If the old tool hosts a help center, give me its URL
so I can add it under Knowledge, Sources, Add a link.
8. Conversations: if the old tool has an export or an API, write every thread to
support-export/conversations/conversations.json in the shape migrate.md shows, add
support-export/ to .gitignore and never commit it. Import it with the import_conversations
MCP tool.
Import is part of Pro. List the customers still waiting for a reply.
9. Build, open Support in the app and send "Hello from the switch". Confirm the dashboard
shows Connected, or check it with the verify_install MCP tool.
10. Report: files changed, each old call and what replaced it, anything left as
TODO(concierge), the knowledge drafts, the imported conversations and the waiting customers.
```
## The CLI
Prefer the terminal? `npx @conciergesdk/cli` does the same steps by hand. Each one shows the diff before it writes:
```sh
npx @conciergesdk/cli login # sign in with your browser and save a key
npx @conciergesdk/cli init # detect the app, install the SDK, configure it, wait for the first message
npx @conciergesdk/cli migrate # rewrite the old support SDK's calls where it recognizes them
npx @conciergesdk/cli doctor # check the install and config
```
Add `--dry-run` to see the changes without writing anything. The prompt still covers more ground (saved replies, help center, waiting customers), so use it when you have a coding agent at hand.
## Call mapping
Nearly every in-app support SDK has the same handful of calls. Names vary, the jobs don't.
| Job | Typical old call | iOS | Android | React Native | Flutter | Web |
|---|---|---|---|---|---|---|
| Start the SDK | init, configure, setup, start | `Concierge.configure(appKey:)` | `Concierge.configure(context, appKey)` | `Concierge.configure({ appKey })` | `Concierge.configure(appKey: ...)` | `Concierge.configure({ appKey })` |
| Sign a user in | identify, login, setUser, registerUser | `Concierge.identify(userId:name:email:token:)` | `Concierge.identify(userId, name, email, token)` | `Concierge.identify(userId, { name, email, token })` | `Concierge.identify(userId, name:, email:, token:)` | `Concierge.identify(userId, { name, email, token })` |
| Verified identity | user hash, HMAC, identity JWT | `token: HS256 JWT from your server` | `token = HS256 JWT` | `token: HS256 JWT` | `token: HS256 JWT` | `token: HS256 JWT` |
| User attributes | custom attributes, user properties, metadata | `Concierge.setMetadata([String: String])` | `Concierge.setMetadata(mapOf(...))` | `Concierge.setMetadata({ ... })` | `Concierge.setMetadata({ ... })` | `Concierge.setMetadata({ ... })` |
| Events and logs | logEvent, track, breadcrumbs | `Concierge.breadcrumb(_:)` | `Concierge.breadcrumb(...)` | `Concierge.breadcrumb(...)` | `Concierge.breadcrumb(...)` | `Concierge.breadcrumb(...)` |
| Open the chat | show, present, showMessenger, openChat | `Concierge.present() or ConciergeChatView()` | `Concierge.open(context)` | `Concierge.open()` | `Concierge.open()` | `Concierge.open()` |
| Unread badge | unreadCount, unread listener | `Concierge.shared.unreadCount` | `Concierge.unreadCount (StateFlow)` | `useUnreadCount()` | `Concierge.unreadCountStream` | `Concierge.onUnreadCountChange(...)` |
| Push token | setDeviceToken, registerPush, setFCMToken | `Concierge.registerPushToken(_:)` | `Concierge.registerPushToken(token)` | `Concierge.registerPushToken(token)` | `Concierge.registerPushToken(token)` | Not needed |
| Notification tap | handlePush, isSupportNotification | `Concierge.handleNotification(userInfo)` | `Concierge.handleNotification(data)` | `Concierge.handleNotification(data)` | `Concierge.handleNotification(data)` | Not needed |
| Sign out | logout, reset, clearUser | `Concierge.logout()` | `Concierge.logout()` | `Concierge.logout()` | `Concierge.logout()` | `Concierge.logout()` |
Metadata values are strings shown next to the conversation. Never put names or emails in metadata: pass them to `identify`.
## Before and after
The "before" snippets are a generic pattern, not any one vendor's API. Your old calls will have their own names.
### iOS
```swift
// Before
import SupportSDK
SupportSDK.configure(apiKey: "OLD_KEY")
SupportSDK.login(userId: user.id, email: user.email, userHash: hash)
SupportSDK.setCustomAttributes(["plan": "Pro"])
SupportSDK.setDeviceToken(token)
Button("Help") { SupportSDK.showMessenger() }
SupportSDK.logout()
```
```swift
// After
import Concierge
Concierge.configure(appKey: "app_YOUR_APP_KEY")
Concierge.identify(userId: user.id, name: user.name, email: user.email,
token: try await api.conciergeToken())
Concierge.setMetadata(["plan": "Pro"])
Concierge.registerPushToken(token)
Button("Help") { Concierge.present() }
Concierge.logout()
```
Full reference: [iOS guide](https://conciergesdk.com/docs/ios.md).
### Android
```kotlin
// Before
import com.example.support.SupportSDK
SupportSDK.init(this, apiKey = "OLD_KEY")
SupportSDK.login(userId = user.id, email = user.email, userHash = hash)
SupportSDK.setCustomAttributes(mapOf("plan" to "Pro"))
SupportSDK.setFcmToken(token)
Button(onClick = { SupportSDK.showMessenger(context) }) { Text("Help") }
SupportSDK.logout()
```
```kotlin
// After
import com.conciergesdk.Concierge
Concierge.configure(this, appKey = "app_YOUR_APP_KEY")
Concierge.identify(userId = user.id, name = user.name, email = user.email,
token = api.conciergeToken())
Concierge.setMetadata(mapOf("plan" to "Pro"))
Concierge.registerPushToken(token)
Button(onClick = { Concierge.open(context) }) { Text("Help") }
Concierge.logout()
```
Full reference: [Android guide](https://conciergesdk.com/docs/android.md).
### React Native
```tsx
// Before
import Support from 'react-native-support-sdk';
Support.configure({ apiKey: 'OLD_KEY' });
Support.login({ userId: user.id, email: user.email, userHash: hash });
Support.setCustomAttributes({ plan: 'Pro' });
Support.setPushToken(token);