Skip to content

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.

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.swift
.package(url: "https://github.com/conciergesdk/concierge-ios.git", from: "0.1.1")
// target dependency
.product(name: "Concierge", package: "concierge-ios")
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:

Button("Contact Support") { Concierge.present() }
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.

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()
  • 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 for the server side.

// "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.
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.

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

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.

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

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)")
case .paywallUnhandled(let paywallId): print("No onPaywallCard for \(paywallId)")
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.

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

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.

Your app already has a paywall, products and maybe offers and a review prompt. Concierge cards should open those, not a new screen. The setup prompt does this for you: it finds what the app has (RevenueCat, including RevenueCat Paywalls, Superwall, Adapty, Qonversion, native StoreKit 2 or a custom paywall), wires the handlers below and prefills your account. By hand:

  • Paywall cards call onPaywallCard(paywallId). Map the id to your existing paywall, placement or offering, and open the main paywall for any id you don’t recognize: the dashboard sends default unless you type another id.
  • Offer cards call onOfferCard(redemption) after the server redeems them. Run your existing purchase flow for redemption.mechanism and pass redemption.accountToken to the store so the sale is attributed to the card.
  • Review cards open the store review page by themselves, but only when the app’s store listing is set: the App Store id and Google Play package under Settings, General in the dashboard (or update_app_settings on the MCP server).
  • Review prompts: replace your own native review request with maybeRequestReview at the same moment, so the two don’t compete for the system’s yearly limit.
  • After a purchase in your own flow, set the plan in the context and ask for a review at that moment.
  • Purchase identity: see Link purchases to customers.
Concierge.onPaywallCard { paywallId in
Paywall.present(placement: paywallId) // your presenter: RevenueCat PaywallView(offering:), Superwall.shared.register(placement:), Adapty, your own screen
}
Concierge.onOfferCard { redemption in
Task {
let token = redemption.appAccountToken!
switch redemption.mechanism {
case .product(let id):
try await Store.buy(id, options: [.appAccountToken(token)])
case .promotional(let id, let offerId, let signature?):
try await Store.buy(id, options: Set([.appAccountToken(token)]).union(Product.PurchaseOption.promotionalOffer(offerId, compactJWS: signature)))
case .winBack(let id, let offerId):
try await Store.buyWinBack(id, offerId: offerId, token: token) // .winBackOffer(_:) on iOS 18
case .offerCode(_, let url?):
await UIApplication.shared.open(url)
case .paywall(let id):
Paywall.present(placement: id)
case .comp:
break // granted on the server, a message follows in the chat
default:
Paywall.present(placement: "default")
}
}
}
// After a purchase in your own flow
Concierge.setContext(["plan": product.id])
Task { await Concierge.maybeRequestReview(moment: .purchaseCompleted) }

With RevenueCat, buy through Purchases.shared instead (promotional offers: promotionalOffer(forProductDiscount:product:), then purchase(product:promotionalOffer:)) and first set Purchases.shared.attribution.setAttributes(["concierge_card": redemption.accountToken]). A plain StoreKit 2 app can skip onOfferCard: the SDK then buys with StoreKit 2 and the card’s token, and you refresh entitlements on the .purchaseCompleted event. To test in the Simulator, set a StoreKit configuration in the scheme (Edit Scheme, Run, Options). The Simulator has no App Store, so review cards only open on a device.

The SDK reports which handlers are set (SDK 0.1.1 or later), so the dashboard’s Connect page, the verify_install MCP tool and npx @conciergesdk/cli doctor show whether paywall and offer cards are wired and whether review cards have a link. A card with no handler logs a warning and emits .paywallUnhandled` or `.offerUnhandled.

Dashboard settings apply without an app update. Anything you set in code wins. See Appearance.

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

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