iOS
MarkdownNative 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
configuremakes 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
Section titled “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.
.package(url: "https://github.com/conciergesdk/concierge-ios.git", from: "0.1.0")
// target dependency.product(name: "Concierge", package: "concierge-ios")Quick start
Section titled “Quick start”import Conciergeimport SwiftUI
@mainstruct 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:
Button("Contact Support") { Concierge.present() }Entry points
Section titled “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
Section titled “Unread count”Concierge.shared is an ObservableObject:
@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.
// 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()identifyis 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.
logoutis a no-op for guests. Never call it at launch for signed-out users.- Account switch:
logout()thenidentify(...)with noawaitin between.
See Identity verification for the server side.
Reset and suspend
Section titled “Reset and suspend”// "Erase all data": wipe Concierge on this device.Concierge.reset()
// A decoy or guest session: Concierge goes invisible and silent.Concierge.suspend()Concierge.resume()Concierge.isSuspendedreset()wipes Concierge on this device back to a fresh install without a network request: the device token in secure storage, the identity, context, cached history and appearance, the outbox, seen cards and handled tool calls. It 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 callsuspend()again on the next launch if needed.
Context
Section titled “Context”Concierge.setMetadata(["plan": "Pro", "signup": "2026-09-01"]) // replaces the previous dictionaryConcierge.breadcrumb("tapped_restore") // the last 50 are keptConcierge.setContext(["plan": "pro", "screen": "Paywall", "appState": ["syncing": true]]) // merges; NSNull() removes a keyConcierge.captureError(error) // the latest error, shown to the teamApp version, OS, device model and language are reported automatically. Don’t put names or emails in metadata; use identify.
Screen links
Section titled “Screen links”Register the screens support answers may link to, once at launch. See Knowledge autopilot.
Concierge.registerScreen("settings.backup", title: "Backup settings", deepLink: URL(string: "myapp://settings/backup")!)Concierge.unregisterScreen("settings.backup") // its buttons stop showingTapping 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:
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 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 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.
import Conciergeimport UIKitimport 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
Section titled “Cards and events”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.
Link purchases to customers
Section titled “Link purchases to customers”The refund desk, receipts and renewal heads ups only know a purchase is this customer’s when the store says so. Pass the customer’s account token as appAccountToken on your own purchases (it is derived from the verified user id, see Offers):
import CryptoKitimport 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
Section titled “Review prompts”let result = await Concierge.maybeRequestReview(moment: .milestone) // .purchaseCompleted, .contentCreated, .custom("first_export")Concierge.reviewPromptAfterSupport = false // default trueThe 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
Section titled “Appearance”Dashboard settings apply without an app update. Anything you set in code wins. See Appearance.
Localization
Section titled “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:
Concierge.locale = "ar" // a BCP 47 tag: "ja", "pt-BR", "zh-Hant"; nil follows the system againAn 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
Section titled “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.