The smallest possible chat in UIKit + Storyboard — initialize the SDK, render messages in a table, send one. The UIKit twin of Examples/SwiftUI/01-Hello/.
- Interface: Storyboard (
Main.storyboard) - Lifecycle:
AppDelegate(@main) +SceneDelegate(scene-based, generated by Xcode)
open HelloUIKit.xcodeproj # from this folder
# Cmd+R on an iPhone simulatorSet your connector token in AppDelegate.swift (currently "YOUR_CONNECTOR_TOKEN").
PolyMessaging.initialize(_:)inapplication(_:didFinishLaunchingWithOptions:)PolyMessaging.chat()for a session, bound to aUITableViewDiffableDataSourcevia Combine- Re-render on every
session.$messagesemission - Auto-scroll as the agent's reply streams in (
dataSource.applycompletion +scrollToRow) - Send with
try? await session.send(text) - Surface terminal failures (invalid token) via a
UIAlertControllerdriven bysession.$failureReason
The SDK invariants behind each pattern are in the root README's Integration guide; this example shows them as one concrete UIViewController.
Each subsection leads with the SDK call (one line — the actual API), then shows how it's wired into a view controller.
Configure the SDK once at launch:
PolyMessaging.initialize(.init(
apiKey: "YOUR_CONNECTOR_TOKEN" // from Agent Studio → Connector Settings
// environment defaults to .us — add .uk / .euw / .cluster("dev") / .custom(...) only if needed
))In AppDelegate:
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
PolyMessaging.initialize(.init(
apiKey: "YOUR_CONNECTOR_TOKEN"
))
return true
}
}After this, PolyMessaging.chat() works from any view controller with no arguments.
Under the hood: initialize just stashes your connector token and environment process-wide — no network happens yet. The work starts when you call chat().
See Quick start.
Create a session + subscribe to its messages:
let session = PolyMessaging.chat() // Resume the previous conversation if one exists within the
// session timeout (default 10 min), else start a fresh one.
// — use `start()` instead to always start fresh.
session.$messages // Combine publisher of [ChatMessage] — the whole transcript. Cases:
// .user(UserMessage) / .agent(AgentMessage) / .system(SystemMessage)
session.isReady // Bool — false until WebSocket + agent-join completeIn a view controller:
final class ChatViewController: UIViewController {
// tableView, inputField, sendButton are @IBOutlets wired in Main.storyboard.
@IBOutlet weak var tableView: UITableView!
@IBOutlet weak var inputField: UITextField!
@IBOutlet weak var sendButton: UIButton!
private var session: ChatSession!
private var bag = Set<AnyCancellable>()
private var dataSource: UITableViewDiffableDataSource<Int, UUID>!
override func viewDidLoad() {
super.viewDidLoad()
session = PolyMessaging.chat()
configureDataSource()
bind()
}
private func bind() {
session.$messages
.receive(on: RunLoop.main)
.sink { [weak self] messages in self?.render(messages) }
.store(in: &bag)
}
}Streaming is on by default — Configuration.streamingEnabled defaults to true, so agent replies grow token-by-token (ChatGPT-style). render(_:) (below) calls reconfigureItems on existing IDs so the cell re-renders as the agent message's text grows. To switch to complete-message bubbles instead, set streamingEnabled: false in AppDelegate.swift. See the root README's Streaming section.
Under the hood: chat() runs the whole REST + WebSocket handshake, agent-join, and resume-or-create for you; isReady flips true once it's connected. messages is the SDK-maintained transcript (.user / .agent / .system) that republishes on every change, so each .sink just hands you the full list to render.
See Integration guide › The core pattern.
Signal that triggers an auto-scroll:
session.$messages // Combine publisher of [ChatMessage] — every emission triggers scroll:
// inserts (new bubble), reconfigureItems (last bubble's text grew during streaming)In a view controller:
private func render(_ messages: [ChatMessage]) {
var snapshot = NSDiffableDataSourceSnapshot<Int, UUID>()
snapshot.appendSections([0])
let ids = messages.map(\.id)
snapshot.appendItems(ids)
let existing = dataSource.snapshot().itemIdentifiers
let toReconfigure = ids.filter { existing.contains($0) }
if !toReconfigure.isEmpty {
snapshot.reconfigureItems(toReconfigure)
}
// Completion fires for every apply — including the reconfigureItems-only
// case that streaming hits — so the table follows the growing bubble.
dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in
self?.scrollToBottom(animated: true)
}
}
private func scrollToBottom(animated: Bool) {
// layoutIfNeeded lets the just-reconfigured cell expand to its new
// height before we ask for the bottom row's position.
tableView.layoutIfNeeded()
let count = tableView.numberOfRows(inSection: 0)
guard count > 0 else { return }
tableView.scrollToRow(at: IndexPath(row: count - 1, section: 0),
at: .bottom, animated: animated)
}Streaming grows the last agent message's text in place. The diffable snapshot uses the same UUIDs but the new text — so dataSource.apply calls the completion handler, and reconfigureItems re-runs the cell provider with the longer text.
Under the hood: with streamingEnabled: true (the default), ChatSession extends the last .agent message's text on every chunk and re-publishes messages. The Combine sink calls render(_:), which builds the same-IDs snapshot plus a reconfigureItems pass — the apply's completion always fires, so the table tracks the reply as it grows.
See Integration guide › Streaming.
Send a user message (optimistic):
try? await session.send(text) // throws PolyError; the bubble appears in `messages`
// immediately as .pending, then settles into .sent or .failedIn a view controller:
@IBAction func sendTapped(_ sender: Any) {
guard let text = inputField.text, !text.isEmpty else { return }
inputField.text = ""
Task { try? await session.send(text) }
}sendTapped(_:) is hooked to the send button's Touch Up Inside in Main.storyboard.
Under the hood: send(text) is optimistic — the bubble appears in messages immediately while the SDK manages delivery and the server echo behind the scenes. ChatSession is @MainActor, so call it from the main thread.
See Integration guide › The core pattern.
Detect a terminal failure + offer retry:
session.$failureReason // Combine publisher of PolyError? — non-nil when the chat can't auto-recover:
// invalid apiKey (initial connect 401/403),
// reconnect budget exhausted,
// session expired (idle past sessionTimeoutSeconds, default 10 min)
try await session.client.resume() // manually re-attempt the connectionIn a view controller:
private var isPresentingFailureAlert = false
private func bind() {
// ...messages sink (above)...
session.$failureReason
.receive(on: RunLoop.main)
.compactMap { $0 }
.sink { [weak self] reason in self?.presentFailureAlert(reason: reason) }
.store(in: &bag)
}
private func presentFailureAlert(reason: PolyError) {
guard !isPresentingFailureAlert, presentedViewController == nil else { return }
isPresentingFailureAlert = true
let alert = UIAlertController(
title: "Couldn't connect",
message: String(describing: reason),
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Try Again", style: .default) { [weak self] _ in
self?.isPresentingFailureAlert = false
Task { try? await self?.session.client.resume() }
})
present(alert, animated: true)
}String(describing:) is intentional — PolyError doesn't conform to LocalizedError, so .localizedDescription is the generic "The operation couldn't be completed". String(describing:) gives the case name (auth(unauthorized)) which is far more useful.
Under the hood: failureReason is fed by both client.connectionStatus.failed (reconnect budget exhausted, session expired) and the initial-connect path that catches an unauthorized REST response and flags sessionState.hasInvalidApiKey. Either way you get a single source of truth for "the chat can't recover from this".
See Integration guide › Terminal errors.
Main.storyboard hard-codes customModule="HelloUIKit" on the view controller. If you rename the Xcode target, open Main.storyboard in Interface Builder, select the View Controller, and update the Module field in the Identity Inspector to match — or set it to None to let UIKit resolve the class from any module.
- typing indicator, connection banner, delivery state, suggestions, end button →
02-Standard/ - attachments, URL cards, call actions →
03-RichContent/ - offline detection, full-screen terminal error →
04-Resilience/ - live agent handoff →
05-Handoff/
- SwiftUI counterpart:
Examples/SwiftUI/01-Hello/ - SDK reference: root README → Integration guide
- Install the package: root README → Install