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