Kindly iOS SDK
Integrate the Kindly Chat SDK into an iOS app — a fully-featured UIKit-based chat widget with real-time messaging, push notifications, custom theming, authentication, deep links, and an event stream.
Invoke This Skill When
- User asks to "add Kindly", "add chat support", "add customer support chat", or "add a chat widget" to an iOS app
- User mentions KindlySDK, KindlyChatClient, kindly-ai, sdk-chat-ios, or the iOS Kindly SDK
- User wants to launch an in-app chat bot, connect to a Kindly workspace via bot key, or integrate handover-to-human support
Note: This skill reflects the public API as of iOS SDK 2.1.x. Always verify against the iOS SDK docs if behavior differs.
Phase 1: Detect
Run these commands to understand the project before making changes:
grep -Ei "kindly|KindlySDK|KindlyChatClient" Package.swift Podfile Cartfile 2>/dev/null
grep -rE "@main|struct.*App.*:.*App" --include="*.swift" . 2>/dev/null | head -5
grep -rE "AppDelegate|UIApplicationMain" --include="*.swift" . 2>/dev/null | head -5
grep -E "platforms:|\.iOS" Package.swift 2>/dev/null
grep -E "platform :ios" Podfile 2>/dev/null
grep -rE "UNUserNotificationCenter|registerForRemoteNotifications" --include="*.swift" . 2>/dev/null | head -3
grep -rE "didReceiveRegistrationToken|Messaging\\.messaging" --include="*.swift" . 2>/dev/null | head -3What to note:
Question | Why it matters |
|---|---|
Kindly already in Package.swift or Podfile? | If yes, skip install and jump to initialization. |
SwiftUI (@main App) or UIKit (AppDelegate)? | Determines where KindlySDK.start(...) goes. |
Deployment target? | Kindly requires iOS 15.0+. |
Push notifications already wired? | Decides whether we set up APNS delegate integration. |
Phase 2: Install
Kindly is distributed as an XCFramework via Swift Package Manager or CocoaPods. It has no third-party dependencies: it reports its own errors to Kindly (opt-out available) without bundling or requiring the Sentry SDK, so it works alongside your app's own Sentry.
Option A — Swift Package Manager (recommended)
In Xcode: File → Add Package Dependencies… and enter:
https://github.com/kindly-ai/sdk-chat-iosSelect the latest major (2.x). Add the KindlySDK product to your app target.
Or in Package.swift:
dependencies: [
.package(url: "https://github.com/kindly-ai/sdk-chat-ios", from: "3.0.14"),
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "KindlySDK", package: "sdk-chat-ios"),
]
),
],Option B — CocoaPods
platform :ios, '15.0'
use_frameworks!
target 'YourApp' do
pod 'Kindly', '~> 2.1'
endThen:
pod installBuild issue (Xcode 14+): If you see rsync.samba sandbox errors, go to Target Settings → Build Settings → Enable User Script Sandbox → NO.
Phase 3: Initialize
Call KindlySDK.start(botKey:market:) once at app launch. The market slug is required (see Markets). It's idempotent and self-healing — safe to call multiple times with the same bot key, and automatically tears down and re-connects if the bot key changes.
UIKit — AppDelegate
import UIKit
import Kindly
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
KindlySDK.start(
botKey: "YOUR_BOT_KEY",
languageCode: "en",
market: "YOUR_MARKET" // required — see Markets
)
return true
}
}SwiftUI — App struct
import SwiftUI
import Kindly
@main
struct MyApp: App {
init() {
KindlySDK.start(
botKey: "YOUR_BOT_KEY",
languageCode: "en",
market: "YOUR_MARKET" // required — see Markets
)
}
var body: some Scene {
WindowGroup { ContentView() }
}
}start() guards internally. Calling any other KindlySDK.* method before start() logs an error and no-ops — it never crashes.
market is required — pass the slug configured for the bot (see Markets under Common Tasks):
KindlySDK.start(botKey: "YOUR_BOT_KEY", languageCode: "en", market: "eu")Phase 4: Launch the Chat
Display the chat UI from anywhere after start() has been called:
KindlySDK.displayChat()Optionally launch straight into a specific dialogue:
KindlySDK.displayChat(triggerDialogueId: "welcome_flow")Dismiss the UI without ending the session (messages are preserved):
KindlySDK.closeChat()Common Tasks
Authentication
Provide a callback that supplies a fresh JWT when the SDK needs one:
KindlySDK.start(botKey: "YOUR_BOT_KEY", market: "YOUR_MARKET") { chatId, promise in
// Fetch a JWT for chatId, then:
promise.fulfill("YOUR_JWT_TOKEN")
// Or on failure:
// promise.reject(error)
}Alternatively, set the callback on the config object after start():
KindlySDK.start(botKey: "YOUR_BOT_KEY", market: "YOUR_MARKET")
KindlySDK.config.getAuthToken = { chatId, promise in
promise.fulfill("YOUR_JWT_TOKEN")
}Manually save a token (e.g., one you already have):
let saved: Bool = KindlySDK.saveAuthToken("YOUR_JWT_TOKEN")Identity providers that issue more than one token. Return a KindlyAuthCredentials instead of a string when you have a companion access token, an issuer, or an opaque token whose expiry only you know:
KindlySDK.start(botKey: "YOUR_BOT_KEY", market: "YOUR_MARKET") { chatId, promise in
promise.fulfill(
KindlyAuthCredentials(
idToken: idToken, // optional, the OIDC id token if you get one
accessToken: accessToken, // optional, the access token if you get one
issuer: "https://idp.example.com/", // optional, sent as X-Auth-Issuer
expiresAtUnixSeconds: expiresAt // optional, supply it for opaque tokens
)
)
}Both tokens are optional and at least one has to be set. Authorization is mandatory on Kindly's auth route, so whichever token you have becomes the bearer: idToken first, then accessToken. A provider that issues only an access token is a normal supported case, not a workaround. X-Access-Token is sent only when an access token accompanies a different id token, because Kindly checks the companion against the id token's at_hash and a duplicate would fail an auth that would otherwise pass.
Three read-only accessors say what the SDK will actually send, so you never re-derive the rules:
credentials.bearerToken // idToken ?? accessToken, nil if neither is set
credentials.hasToken // bearerToken != nil, the real "can this authenticate?" check
credentials.companionAccessToken // what goes in X-Access-Token, or nil when nothing willA single-JWT integration never needs this type. Keep returning a string.
Refresh and expiry. The SDK refreshes 30 seconds before the credentials expire, taking the earliest of three sources: the expires_at Kindly returns, the expiresAt you supplied, and the exp claim of the bearer when it is a JWT. If none of the three is known the credentials count as non-expiring and are never proactively refreshed. Correct for a JWT with no exp, a trap for an opaque token: there is no claim to read, so supply expiresAt or the SDK keeps using it until Kindly rejects it.
Re-authenticating mid-session. The SDK refreshes on expiry by itself. These two are for when the identity changes rather than the token:
KindlySDK.authenticate() // user signed in, or switched account; re-binds in place
KindlySDK.deauthenticate() // user signed out; chat carries on anonymously, history intactauthenticate() asks your callback for fresh credentials and binds them to the chat that is already open, socket and history intact. deauthenticate() invalidates the identity on the backend, then clears the stored credentials. It is a sign-out, not an endChat(), and it is safe to call when the chat is already anonymous.
Markets (per-location settings)
market is required at start() — pass the slug configured for the bot:
KindlySDK.start(botKey: "YOUR_BOT_KEY", languageCode: "en", market: "eu")The slug is whatever is configured in the Kindly platform (e.g. eu, us, general). The SDK loads that market's settings (/settings/{botKey}/{market}.json) and tells /connect which market the host picked, so greeting and bot responses are localized to it. market is independent of languageCode.
No fallback: if the requested market's settings can't be loaded, settings loading fails and the chat won't load. There is no fallback to another market and no base/per-bot settings file outside of markets. Find market slugs in the Kindly platform (Settings → General → Details & markets).
Context (per-message metadata)
Attach key/value context to the next message or button click request:
KindlySDK.setNewContext([
"userId": "12345",
"plan": "premium"
])Clear pending context:
KindlySDK.clearNewContext()Theming
Apply a custom theme — nil colors fall back to the API theme or default:
KindlySDK.setCustomTheme(
background: .white,
botMessageBackground: UIColor(red: 0.95, green: 0.95, blue: 0.95, alpha: 1),
botMessageText: .black,
buttonBackground: .systemBlue,
buttonText: .white,
navBarBackground: .systemBlue,
navBarText: .white,
userMessageBackground: .systemBlue,
userMessageText: .white
)Or pass a full CustomTheme object:
let theme = CustomTheme(background: .white, buttonBackground: .systemBlue, /* ... */)
KindlySDK.setCustomTheme(theme)Revert to the API/default theme:
KindlySDK.clearCustomTheme()Themes apply live: calling setCustomTheme(...) or clearCustomTheme() while the chat is on screen updates the page background, navigation bar, message bubbles, system messages, typing indicator, attachments, image cells, form cells, action buttons, file-upload labels, the input field, and the toolbar in place. No need to dismiss and re-display the chat. This makes it natural for host apps to swap themes in response to system appearance changes (light/dark mode, custom toggles, etc.) by calling setCustomTheme(...) again with the new colors.
Change Language at Runtime
Returns a Promise<Void> that resolves when the new language is applied:
KindlySDK.setLanguage("fr").then {
print("Language changed")
}.catch { error in
print("Language change failed: \(error)")
}Trigger a Specific Dialogue
Requires an active connection:
KindlySDK.triggerDialogue(id: "welcome_flow")Send a Message Programmatically
Drive the chat from the host app — send a message as the user without going through the input field:
KindlySDK.sendMessage("Hi, I have a question about my order")With per-call context (overrides any setNewContext(_:) value for this call only):
KindlySDK.sendMessage(
"Where is my order?",
newContext: ["orderId": "12345"]
)Returns Promise<ExternalChatMessage> so you can observe success/failure:
KindlySDK.sendMessage("Hello").then { (message: ExternalChatMessage) in
print("Sent — server id: \(message.id)")
}.catch { error in
print("Send failed: \(error)")
}The SDK renders the bubble locally and reconciles with the server-assigned id on response. Do not also display the message yourself. Rejects with KindlySDKError.chatNotConnected if the chat is not connected, and KindlySDKError.invalidData for empty input.
Deep Links
Kindly handles URLs with scheme kindly://chat/dialogue/{id}. In your SceneDelegate or AppDelegate:
func scene(_ scene: UIScene, openURLContexts contexts: Set<UIOpenURLContext>) {
for ctx in contexts {
if KindlySDK.handleURL(ctx.url) { return }
}
}handleURL(_:) returns true if the URL was a Kindly deep link and was handled.
Push Notifications (APNS)
Forward your APNS token to Kindly:
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
KindlySDK.setAPNSDeviceToken(deviceToken)
}To let the SDK route Kindly notifications automatically, set its notification delegate:
UNUserNotificationCenter.current().delegate = KindlySDK.notificationDelegateOr handle notifications manually:
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
if KindlySDK.isKindlyNotification(response.notification.request.content.userInfo) {
KindlySDK.notificationResponseReceived(response)
}
completionHandler()
}Notification Service Extension (recommended, SDK 3.0.12+). Add a Notification Service Extension target whose class is final class NotificationService: KindlyNotificationServiceExtension {}, link Kindly into it, and give both targets the same Keychain Sharing group (your app's bundle id). That is all: the SDK detects the extension and tells Kindly this app can take visible pushes, so they become visible, the extension decrypts them before iOS shows them, in every app state including when the app was killed, and the iOS silent-push rate limit no longer applies. KindlySDK.notificationMode overrides the detection. Full steps in PUSH.md Section 4.
Link Interception (Delegate)
Intercept links tapped inside the chat (e.g., to open in-app instead of Safari):
class ChatLinkHandler: NSObject, KindlyChatClientDelegate {
func shouldHandleLink(url: URL) -> Bool {
// Return true to let Kindly open it. Return false to take over.
if url.host == "example.com" {
UIApplication.shared.open(url)
return false
}
return true
}
}
let handler = ChatLinkHandler()
KindlySDK.delegate = handlerNotification Interception (Delegate)
The SDK decrypts every Kindly push before anything is shown (silent pushes in the app, visible pushes in the Notification Service Extension or, without one, in the app). The display rule in the foreground:
- Background → always show
- Foreground on the chat conversation → suppress (the user can already see the message)
- Foreground anywhere else (settings, language, host-app screens) → show
For every Kindly push the app process sees in plaintext the SDK calls shouldHandleNotification(notification:) on the delegate before any display gate. The callback fires regardless of app state — use it to run analytics or to take over the presentation entirely:
extension AppDelegate: KindlyChatClientDelegate {
func shouldHandleNotification(notification: ExternalNotification) -> Bool {
Analytics.track("kindly_notification", id: notification.id)
// Take over with custom UI:
if isShowingCustomInbox {
customInbox.append(notification)
return false // SDK does not show its banner
}
return true // let the SDK show the banner
}
}The ExternalNotification value has id, title, body (all decrypted) and userInfo (the original APNs dictionary, including aps and any extra fields your backend attached). Same polarity as shouldHandleLink: true (default) = SDK handles, false = host handles.
Events & State (Combine)
Subscribe to a unified event stream (matches the web widget API):
import Combine
var cancellables = Set<AnyCancellable>()
KindlySDK.events
.sink { event in
switch event.detail {
case .load(let settings):
print("Loaded bot: \(settings.name)")
case .message(let newMessage, _):
print("Message: \(newMessage.text ?? "")")
case .buttonClick(let button, _, _, _):
print("Button clicked: \(button.label)")
case .ui(let action):
print("UI action: \(action.type.rawValue)")
case .state(let state):
print("Connected: \(state.isConnected)")
case .globalIcon:
break
}
}
.store(in: &cancellables)State-only stream:
KindlySDK.state
.sink { state in
print("isConnected: \(state.isConnected), isChatDisplayed: \(state.isChatDisplayed)")
}
.store(in: &cancellables)End a Chat Session
Disconnect and dismiss the UI, but keep the SDK initialized for re-use:
KindlySDK.endChat().then {
print("Chat ended")
}Kill the SDK Entirely
Fully tear down — after this, start() must be called again before any other API:
KindlySDK.kill().then {
print("SDK killed")
}Configuration Reference
Core Public Surface
API | Type | Purpose |
|---|---|---|
KindlySDK.start(botKey:languageCode:market:authTokenCallback:) | class func | Initialize the SDK. Idempotent. market is required (Markets feature, no fallback). |
KindlySDK.start(botKey:languageCode:market:authCredentialsCallback:) | class func | Same, but the callback returns KindlyAuthCredentials rather than a token string. |
KindlySDK.displayChat(languageCode:triggerDialogueId:) | class func | Show the chat UI. |
KindlySDK.closeChat() | class func | Dismiss the UI, keep the session. |
KindlySDK.endChat() | -> Promise<Void> | End session, dismiss UI, keep SDK initialized. |
KindlySDK.kill() | -> Promise<Void> | Tear down SDK fully. |
KindlySDK.setLanguage(_:) | -> Promise<Void> | Change language at runtime. |
KindlySDK.setNewContext(_:) / .clearNewContext() | class func | Attach context to next request. |
KindlySDK.sendMessage(_:newContext:) | -> Promise<ExternalChatMessage> | Send a message as the user, programmatically. SDK renders the bubble — host must not also display it. |
KindlySDK.triggerDialogue(id:) | class func | Run a specific dialogue. |
KindlySDK.handleURL(_:) | -> Bool | Handle kindly://chat/dialogue/{id} deep links. |
KindlySDK.setCustomTheme(...) / .clearCustomTheme() | class func | Theme the chat UI. |
KindlySDK.saveAuthToken(_:) | -> Bool | Manually save a JWT to Keychain. |
KindlySDK.saveAuthCredentials(_:) | -> Bool | Manually save full KindlyAuthCredentials to Keychain. |
KindlyAuthCredentials(idToken:accessToken:issuer:expiresAt:) | struct | Credentials for the multi-token strategies. Every field optional, at least one of the two tokens required. expiresAtUnixSeconds: is the convenience init. |
KindlyAuthCredentials.bearerToken / .hasToken / .companionAccessToken | derived | What the SDK will send: idToken ?? accessToken as the bearer, and X-Access-Token only next to a different id token. |
KindlySDK.authenticate() | -> Promise<Void> | Re-authenticate the live chat with fresh credentials. For a sign-in mid-session. |
KindlySDK.deauthenticate() | -> Promise<Void> | Sign out of the chat without ending it. |
KindlySDK.setAPNSDeviceToken(_:) | class func | Register APNS token. |
KindlySDK.notificationDelegate | NotificationDelegate | SDK-managed UNUserNotificationCenter delegate. |
KindlySDK.isKindlyNotification(_:) | -> Bool | Detect Kindly push payloads. Works before start(). |
KindlySDK.notificationWillPresent(_:completionHandler:) | class func | The SDK's presentation decision for your own willPresent. |
KindlySDK.openChatOnNotificationTap | Bool | Tapping a Kindly banner opens the chat (default true). |
KindlySDK.notificationMode | KindlyNotificationMode | .automatic (default: visible pushes when the app ships a Kindly extension, silent otherwise), .alert or .silent. |
KindlyNotificationServiceExtension | open class | Base class for the host's Notification Service Extension. |
KindlyPushDecryption | enum | isKindlyNotification, decrypt(userInfo:), apply(_:to:), isDecrypted(_:) for hosts with their own extension. |
KindlySDK.events | AnyPublisher<KindlyEvent, Never> | Unified event stream. |
KindlySDK.state | AnyPublisher<KindlyEventState, Never> | State change stream. |
KindlySDK.delegate | KindlyChatClientDelegate? | Link interception and other callbacks. |
Configurable Properties
Property | Default | Purpose |
|---|---|---|
KindlySDK.enableCrashReporting | true | Send the SDK's own error reports to Kindly. Set to false to opt out. |
KindlySDK.verboseLogging | false | Verbose logs in Xcode console. |
KindlySDK.config.getAuthToken | nil | Alternative way to set authTokenCallback. |
KindlySDK.config.getAuthCredentials | nil | Alternative way to set authCredentialsCallback. Wins over getAuthToken when both are set. |
Event Types
KindlyEventType (rawValue shown):
- load → "kindly:load" — settings/configuration loaded
- message → "kindly:message" — message sent or received
- buttonClick → "kindly:buttonclick" — user tapped a button
- ui → "kindly:ui" — UI action (open/close/endChat/changeLanguage/…)
- globalIcon → "kindly:globalicon" — defined but not currently emitted on mobile
- state → "kindly:state" — connection/display state changed
Deep Link Format
Scheme: kindly://chat/dialogue/{dialogueId}
Passing such a URL to KindlySDK.handleURL(_:) either triggers the dialogue (if already connected) or opens the chat and triggers it.
Verification
After wiring everything up, sanity-check the integration:
KindlySDK.start(botKey: "YOUR_BOT_KEY", market: "YOUR_MARKET")
KindlySDK.state
.sink { state in
print("isStarted: \(state.isStarted), isSocketConnected: \(state.isSocketConnected)")
}
.store(in: &cancellables)
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
KindlySDK.displayChat()
}Expected:
- isStarted becomes true within ~1s.
- isSocketConnected becomes true shortly after.
- Chat UI appears when displayChat() is called.
- Sending a message produces a .message event in the stream.
If events don't flow:
- Enable KindlySDK.verboseLogging = true and check the Xcode console.
- Confirm the bot key is correct (wrong keys fail silently on the server).
- Confirm the device has network access.
Troubleshooting
Issue | Fix |
|---|---|
"called before start()" logs | Ensure KindlySDK.start(botKey:) runs before any other KindlySDK.* call. |
Chat UI doesn't appear | start() must have completed and the app must have a key window; call displayChat() from the main thread. |
Messages don't send | Check KindlySDK.state for isSocketConnected == true. Network issues, wrong bot key, or auth failures will keep it false. |
Re-initialization with same bot key does nothing | That's intentional — start() is idempotent when already connected to the same bot. Call kill() first if you need a full reset. |
Bot key switch drops messages | When the bot key changes, the SDK disconnects and reconnects. Handle .state events to know when the new session is ready. |
Turning off the SDK's error reports | enableCrashReporting defaults to true. Set KindlySDK.enableCrashReporting = false to disable. They go to Kindly and never reach your own Sentry project. |
Links from chat open in Safari instead of your app | Implement KindlyChatClientDelegate.shouldHandleLink(url:) and assign to KindlySDK.delegate. Return false to take over. |
Push notifications not arriving | Verify APNS token is forwarded via setAPNSDeviceToken(_:); confirm UNUserNotificationCenter.current().delegate is set (either to KindlySDK.notificationDelegate or to your own delegate that calls KindlySDK.notificationReceived(_:)). |
Deep link doesn't open chat | Ensure KindlySDK.handleURL(_:) is called from your SceneDelegate/AppDelegate URL handler. Scheme must be exactly kindly://chat/dialogue/{id}. |
Custom theme only partially applied | Only non-nil colors override; nil colors inherit. Use the CustomTheme init with explicit nil for every slot you don't want to override. |
WebView chat not available | WebView behavior is temporarily disabled across the SDK to comply with App Store policies around JS injection. Only .native behavior is supported. |
endChat() promise hangs | A 2-second internal timeout force-resolves it even if disconnect stalls — check for reject handlers if you rely on errors. |
Deep Reference
When a task needs deeper detail than this file covers, fetch the matching companion. Each is a self-contained reference designed to be loaded only when relevant:
Topic | Fetch with |
|---|---|
Full event payload shapes (every KindlyEventDetail case + supporting types) | Use curl to download, read and follow: https://docs.kindly.ai/sdk/ios-sdk-skill-events.md |
Every theme color slot, light/dark patterns, full Theme protocol reference | Use curl to download, read and follow: https://docs.kindly.ai/sdk/ios-sdk-skill-theming.md |
AuthTokenCallback and KindlyAuthCredentials deep dive, dual-token and opaque-token setups, refresh and expiry, manual token save, end-to-end auth patterns | Use curl to download, read and follow: https://docs.kindly.ai/sdk/ios-sdk-skill-auth.md |
kindly:// URL scheme handling + KindlyChatClientDelegate link interception | Use curl to download, read and follow: https://docs.kindly.ai/sdk/ios-sdk-skill-deeplinks.md |
APNS token registration, notification routing, coexisting with other providers, Notification Service Extension setup (mutable-content + shared keychain) | Use curl to download, read and follow: https://docs.kindly.ai/sdk/ios-sdk-skill-push.md |