The complete UIKit reference app on top of 05-Handoff — every feature from 02–05 folded into one production-shaped flow (connect → loading → chat → error), plus the lifecycle plumbing the lighter levels skip: resume-or-start, in-place start-new, recoverable error routing, delayed "Sending…" labels, and child-view-controller containment driven entirely by SDK streams.
- Interface: programmatic (no storyboard).
SceneDelegatewraps aRootViewControllerin aUINavigationController. - Lifecycle:
AppDelegate(@main) +SceneDelegate.
open FullReferenceUIKit.xcodeproj # from this folder
# Cmd+R on an iPhone simulatorSet your connector token in AppDelegate.swift (currently "YOUR_CONNECTOR_TOKEN").
- A screen-state machine (
enum Screen { case connect, loading, chat, error }) driving child-VC containment - Resume-or-start picker driven by
PolyMessaging.hasResumableSession()+chat()vsstart() - Loading → chat / error transitions driven by
client.events,client.connectionStatus, andclient.sessionState— tied to the client, not the session, so they survive in-place start-new - A recoverable error screen with a "Go Back" route to connect (not a latched terminal flag)
- Nav-bar split: chevron pauses to connect without ending; xmark ends with a confirm alert
- In-place start-new via
session.clearChat()+session.client.startNewSession()— no screen change - Destructive
session.end()followed by a return to connect - Delayed "Sending…" label (~500ms debounce) so fast confirmations never flash it
- Retry that calls
session.removeMessage(draftId:)before re-sending, so failed bubbles never linger - New-message banners (local workaround) — a notification when the agent replies; foreground + a brief background grace window (no remote push yet — coming soon) (
Components/NewMessageNotifier.swift)
The SDK invariants behind each pattern are in the root README's Integration guide; this example shows them as one concrete multi-screen app.
Each subsection leads with the SDK call (one or a few lines — the actual API), then shows how it's wired into a view controller.
The root owns the single ChatSession and a Screen enum, swapping one child view controller in at a time. Connect / loading / chat / error never each talk to the SDK directly — only the root does — so the session survives every screen transition.
The SDK calls:
PolyMessaging.initialize(_:) // once, in AppDelegate — connection details
PolyMessaging.hasResumableSession() // side-effect-free probe of the on-disk session store
PolyMessaging.chat() // resume the persisted session if valid, else start fresh
PolyMessaging.start() // always discard any stored session and start freshIn a view controller:
final class RootViewController: UIViewController {
private enum Screen { case connect, loading, chat, error }
private var session: ChatSession?
private var wasResumed = false
private var screen: Screen = .connect
private var current: UIViewController?
/// Tied to the current client (not the ChatSession) so it keeps working
/// across in-place start-new on the same client. Re-armed only when a
/// brand-new client is created.
private var lifecycleTasks: [Task<Void, Never>] = []
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.backButtonDisplayMode = .minimal
showConnect()
}
deinit { lifecycleTasks.forEach { $0.cancel() } }
private func transition(to child: UIViewController) {
if let current {
current.willMove(toParent: nil)
current.view.removeFromSuperview()
current.removeFromParent()
}
addChild(child)
child.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(child.view)
NSLayoutConstraint.activate([
child.view.topAnchor.constraint(equalTo: view.topAnchor),
child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
child.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
child.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
child.didMove(toParent: self)
current = child
updateNavItems()
}
}Under the hood: initialize stashes the connector token and environment process-wide — no network happens yet. The no-arg facade calls (chat(), start(), hasResumableSession()) reuse that config from any view controller. chat() resumes the persisted session when it's still valid; start() always discards. hasResumableSession() is a pure on-disk probe with no side effects.
See Integration guide › Quick start and Integration guide › Session lifecycle.
Don't poll for "is it connected yet" — subscribe to the three lifecycle streams on client. The tasks are tied to the client, not the session, so they keep working across in-place start-new on the same client:
The SDK signals:
client.events // AsyncStream<MessagingEvent> — .sessionStart, .disconnected, etc.
client.connectionStatus // AsyncStream<ConnectionStatus> — .idle / .connecting / .open / .closing / .closed / .reconnecting / .failed
client.sessionState // AsyncStream<SessionState> — .isReady flips true when the session can sendIn a view controller:
private func configureAndStart(forceFresh: Bool) {
// ...short-circuit if a live session already exists...
let s = forceFresh ? PolyMessaging.start() : PolyMessaging.chat()
session = s
wasResumed = false
showLoading()
subscribeLifecycle(to: s.client)
}
private func subscribeLifecycle(to client: PolyMessagingClient) {
lifecycleTasks.forEach { $0.cancel() }
lifecycleTasks = []
lifecycleTasks.append(Task { @MainActor [weak self] in
for await event in client.events {
guard let self else { return }
if case .sessionStart = event, self.screen == .loading { self.showChat() }
if case .disconnected(let err) = event, let err, self.screen == .loading {
self.showError("Couldn't connect.\n\(err)")
}
}
})
lifecycleTasks.append(Task { @MainActor [weak self] in
for await status in client.connectionStatus {
guard let self else { return }
if case .failed(let reason) = status, self.screen == .loading {
let message = reason.map { String(describing: $0) } ?? "Unknown failure"
self.showError("Connection failed.\n\(message)")
}
}
})
lifecycleTasks.append(Task { @MainActor [weak self] in
for await state in client.sessionState {
guard let self else { return }
if state.status == .restored { self.wasResumed = true }
if state.isReady, self.screen == .loading || self.screen == .error { self.showChat() }
if state.isError, self.screen == .loading {
self.showError(state.errorMessage ?? "Couldn't start the session.")
}
}
})
}Under the hood: loading → chat is gated on state.isReady (or a .sessionStart event) and only fires while still loading, so a mid-chat reconnect never throws the user back to the loading screen. Errors are routed via showError(_:) only while loading, for the same reason — a transient blip after the chat is up just flips connection and recovers itself. state.status == .restored is how you know it's a warm resume.
See Integration guide › Connection & reconnect.
06's terminal state is not a latched failure flag — it's a Screen.error set by the lifecycle subscriptions above. "Go Back" routes to .connect:
The SDK signal that fed it:
session.client.sessionState // .isError on session-creation failure
// (and .failed on connectionStatus, .disconnected(error) on events)In a view controller:
final class ErrorViewController: UIViewController {
private let message: String
private let onBack: () -> Void
init(message: String, onBack: @escaping () -> Void) {
self.message = message
self.onBack = onBack
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
// ...icon + title + multiline `message` label...
var conf = UIButton.Configuration.filled()
conf.title = "Go Back"
conf.cornerStyle = .medium
let back = UIButton(configuration: conf, primaryAction: UIAction { [weak self] _ in
self?.onBack()
})
// ...pin to the bottom safe area...
}
}The error subtitles use String(describing:) on PolyError rather than .localizedDescription, because PolyError doesn't conform to LocalizedError (the default would just say "The operation couldn't be completed").
Under the hood: routing errors only while .loading means the error screen is recoverable by design — you can go back to connect and retry. If you want a non-recoverable terminal screen instead (the 04-Resilience pattern), bind to session.failureReason directly.
See Integration guide › Terminal errors.
The connect screen picks button labels off hasResumableSession():
The SDK call:
PolyMessaging.hasResumableSession() // true if a stored session is within the timeoutIn a view controller:
// RootViewController builds the connect screen with the resume probe:
private func showConnect() {
screen = .connect
let vc = ConnectViewController(
hasActiveSession: session != nil,
canResume: PolyMessaging.hasResumableSession(),
onResume: { [weak self] in self?.configureAndStart(forceFresh: false) },
onStartNew: { [weak self] in self?.configureAndStart(forceFresh: true) }
)
transition(to: vc)
}
// ConnectViewController picks the label off `hasActiveSession || canResume` and only
// appends the secondary "Start New Chat" button when resume is offered:
let primaryShowsResume = hasActiveSession || canResume
var primaryConfig = UIButton.Configuration.filled()
primaryConfig.title = primaryShowsResume ? "Resume Chat" : "Start Chat"
primaryConfig.image = UIImage(systemName: primaryShowsResume ? "arrow.uturn.forward.circle.fill" : "bolt.fill")
let primary = UIButton(configuration: primaryConfig, primaryAction: UIAction { [weak self] _ in
self?.onResume() // resume goes through the same code path; RootViewController calls .chat()
})
stack.addArrangedSubview(primary)
if primaryShowsResume {
var secondaryConfig = UIButton.Configuration.gray()
secondaryConfig.title = "Start New Chat"
let secondary = UIButton(configuration: secondaryConfig, primaryAction: UIAction { [weak self] _ in
self?.onStartNew()
})
stack.addArrangedSubview(secondary)
}Under the hood: hasResumableSession() is a side-effect-free on-disk check (no network), so it's safe to call every time the connect screen is shown — keeping the buttons honest as the user moves between connect / chat / connect.
See Integration guide › Session lifecycle.
The root owns both nav-bar buttons. The xmark ends the session (session.end()) after a confirm alert and returns to connect; the chevron pauses to connect without ending — the session stays alive for the user to come back to.
The SDK call:
session.end() // permanent; the conversation cannot be resumed after thisIn a view controller:
private func updateNavItems() {
guard screen != .connect else {
navigationItem.leftBarButtonItem = nil
navigationItem.rightBarButtonItem = nil
return
}
navigationItem.leftBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "chevron.left"),
primaryAction: UIAction { [weak self] _ in self?.showConnect() } // pause, don't end
)
let end = UIBarButtonItem(
image: UIImage(systemName: "xmark.circle"),
primaryAction: UIAction { [weak self] _ in self?.confirmEnd() } // end for good
)
end.accessibilityLabel = "End Conversation"
navigationItem.rightBarButtonItem = end
}
private func endConversation() {
let pending = session
Task { @MainActor [weak self] in
try? await pending?.end()
self?.session = nil
self?.wasResumed = false
self?.showConnect()
}
}Under the hood: awaiting end() before clearing the local session ensures the server has acknowledged the teardown before the connect screen re-probes hasResumableSession() — otherwise you can get a phantom "Resume" button for a session that's already dead.
See Integration guide › Session lifecycle.
When the chat ends, the chat-ended footer offers a new conversation without bouncing through the connect screen:
The SDK calls:
session.clearChat() // wipe the local transcript immediately
session.client.startNewSession() // ends the current server session, starts a fresh one on the SAME clientIn a view controller:
private func startNewConversationInPlace() {
session.clearChat()
Task { try? await session.client.startNewSession() }
}Under the hood: startNewSession() reuses the existing client, so the lifecycle subscriptions in RootViewController (tied to that client) don't need re-arming — they flip the root back to .chat once the new session is ready. ChatSession detects the new session id and resets its latched flags, so the screen converges without leaving .chat.
See Integration guide › Session lifecycle.
The SDK already tracks real delivery (.pending → .sent); the ~500ms delay is purely app-side polish on showing the label, so fast confirmations never flash it:
The SDK signal:
ChatMessage.delivery // .pending / .sent / .failed (.pending on optimistic send)In a view controller:
private var sendingLabels: Set<UUID> = []
private var trackedPending: Set<UUID> = []
private func syncSendingLabels(_ messages: [ChatMessage]) {
for case .user(let u) in messages where u.delivery == .pending && !trackedPending.contains(u.id) {
trackedPending.insert(u.id)
let id = u.id
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
guard let self else { return }
// Only show the label if the draft is STILL pending after 500ms.
guard case .user(let current) = self.session.messages.first(where: { $0.id == id }),
current.delivery == .pending else { return }
self.sendingLabels.insert(id)
self.reconfigure(id: id) // re-run cellProvider on this row
}
}
// Drop ids that left .pending — keeps the sets in sync with reality.
let stillPending = Set(messages.compactMap { msg -> UUID? in
if case .user(let u) = msg, u.delivery == .pending { return u.id }
return nil
})
sendingLabels.formIntersection(stillPending)
trackedPending.formIntersection(stillPending)
}
private func reconfigure(id: UUID) {
var snapshot = dataSource.snapshot()
let item = Row.message(id)
guard snapshot.itemIdentifiers.contains(item) else { return }
snapshot.reconfigureItems([item])
dataSource.apply(snapshot, animatingDifferences: false)
}Under the hood: this only gates display — the SDK still reports .pending immediately and .sent the moment the server confirms. The bubble is in messages from the first frame either way; the label is the only thing this code controls.
See Integration guide › Delivery state & retry.
A failed optimistic message stays in messages as a real draft keyed by its draftId. Retry drops the stale bubble first, then re-sends the text:
The SDK calls:
session.removeMessage(draftId:) // drop the failed optimistic draft from messages
session.send(_:) // re-send as a fresh draft (new id)In a view controller (inside the diffable data source's cell provider):
cell.onRetry = { [weak self] text in
if let draftId = self?.draftId(for: id) {
self?.session.removeMessage(draftId: draftId)
}
Task { try? await self?.session.send(text) }
}
private func draftId(for id: UUID) -> String? {
guard case .user(let u) = session.messages.first(where: { $0.id == id }) else { return nil }
return u.draftId
}Under the hood: without removeMessage, retrying would leave a "Failed" bubble next to the new attempt — send() always creates a fresh draft id rather than mutating the old one. Dropping the failed draft first is what keeps the transcript clean.
See Integration guide › Delivery state & retry.
Streaming: agent replies grow token-by-token by default (
Configuration.streamingEnabled: true— ChatGPT-style). The chat's diffable data source usessnapshot.reconfigureItems(...)on the agent message's id every time$messagesre-publishes, so the cell re-runs with the longer text and the table animates the height change. See the root README's Streaming section and07-Playgroundfor a live toggle.
In-app new-message banners (local workaround) — Views/ChatViewController.swift, Components/NewMessageNotifier.swift
Pop a local-notification banner when the agent replies. NewMessageNotifier holds a beginBackgroundTask — through the short (~30s) background grace window after you leave the app. Once iOS suspends the app (or it's locked / force-quit) nothing arrives: real lock-screen delivery needs APNs + a server-side push integration the SDK doesn't provide yet.
The SDK signal:
session.client.events // AsyncStream<MessagingEvent> — completed-message events (full text + stable messageId)In a view controller — own one per chat surface and start it once the session exists:
private let messageNotifier = NewMessageNotifier() // Components/NewMessageNotifier.swift
override func viewDidLoad() {
super.viewDidLoad()
// ...layoutUI / configureDataSource / bind...
messageNotifier.start(observing: session)
}Under the hood.
In a nutshell — set the notification-center delegate, watch client.events for completed agent messages, skip already-shown ones, and post a local banner while foreground or in the grace window. (Full generic walkthrough in the integration guide, linked below.)
In detail, mapped to this example's code:
1. Become the foreground delegate — NewMessageNotifier (an NSObject + UNUserNotificationCenterDelegate) makes itself the delegate in start(observing:) so iOS shows the banner for the active app:
let center = UNUserNotificationCenter.current()
center.delegate = self
center.requestAuthorization(options: [.alert, .sound]) { _, _ in }2. Listen + dedupe — consume the completed-message events off session.client.events (full text + a stable messageId, not chunks) and skip anything already in the persisted (UserDefaults) store:
task = Task { [weak self] in
for await event in session.client.events { self?.handle(event) }
}
// inside handle(_:)
guard !store.contains(message.id) else { return } // persisted → resume replays don't re-fire3. Gate + post — present only while .active or inside the background grace window (a beginBackgroundTask started on didEnterBackground), then mark it shown:
let active = UIApplication.shared.applicationState == .active
guard active || bgTask != .invalid else { return }
present(id: message.id, title: message.title, body: message.body) // UNNotificationRequest, trigger: nil
store.markShown(message.id)06 resumes sessions, so the persisted dedupe is exactly what stops the replayed history firing a burst on resume; with streaming on, the bubble id is stable across chunks, so it fires once.
See Integration guide › In-app new-message alerts (local-only workaround).
- runtime configuration knobs (
DevSettings), raw transport tap, live diagnostics, event log, message timestamps →07-Playground/
- SwiftUI counterpart:
Examples/SwiftUI/06-FullReference/ - SDK reference: root README → Integration guide
- Install the package: root README → Install