# iOS Native
> iOS SDK Integration Guide - Voice-enabled AI agent with real-time communication capabilities (Swift / SwiftUI)
URL: /embed/integration/ios
Markdown: /embed/integration/ios.md
# iOS Integration Guide [#ios-integration-guide]
**SDK version:** 1.0.0\
**Platform:** iOS (Swift / SwiftUI)\
**Min deployment target:** iOS 16.0\
**Build system:** Xcode 15+ / Swift Package Manager
Get your API key from [app.revrag.ai → Settings → API Keys](https://app.revrag.ai).
***
## Table of Contents [#table-of-contents]
1. [Requirements](#1-requirements)
2. [How it works](#2-how-it-works)
3. [Step 1 — Add the package](#step-1--add-the-package)
4. [Step 2 — Add microphone permission](#step-2--add-microphone-permission)
5. [Step 3 — Initialize the SDK](#step-3--initialize-the-sdk)
6. [Step 4 — Identify the user](#step-4--identify-the-user)
7. [Step 5 — Add the floating button](#step-5--add-the-floating-button)
8. [Routing scenarios](#routing-scenarios)
9. [Button visibility control](#button-visibility-control)
10. [Events](#events)
11. [Analytics helpers](#analytics-helpers)
12. [Cleanup on logout](#cleanup-on-logout)
13. [Configuration reference](#configuration-reference)
14. [Troubleshooting](#troubleshooting)
15. [Pre-ship checklist](#pre-ship-checklist)
***
## 1. Requirements [#1-requirements]
| Requirement | Value |
| ------------ | --------------- |
| Min OS | iOS 16.0 |
| Language | Swift 5.9+ |
| UI framework | SwiftUI |
| Build system | Xcode 15+ / SPM |
***
## Step 1 — Add the package [#step-1--add-the-package]
### Swift Package Manager (recommended) [#swift-package-manager-recommended]
In Xcode: **File → Add Package Dependencies**
Paste the URL:
```
https://github.com/RevRag-ai/embed-native
```
Select `Up to Next Major Version` from `1.0.0` → select `RevragEmbed` → click **Add Package**.
### CocoaPods alternative [#cocoapods-alternative]
```ruby
# Podfile
pod 'RevragEmbed', '~> 1.0'
```
```bash
pod install
open YourApp.xcworkspace
```
***
## Step 2 — Add microphone permission [#step-2--add-microphone-permission]
**Required — your app will crash at runtime without this.**
Without `NSMicrophoneUsageDescription`, iOS terminates the process the moment the SDK requests microphone access. This step cannot be skipped.
Add to `Info.plist`:
```xml
NSMicrophoneUsageDescription
This app uses the microphone to talk with the AI agent.
```
**In Xcode:** Target → Info tab → `+` button → `Privacy - Microphone Usage Description` → enter a description.
***
## Step 3 — Initialize the SDK [#step-3--initialize-the-sdk]
Call `EmbedSDK.shared.initialize()` **once**, as early as possible.
### SwiftUI App entry point [#swiftui-app-entry-point]
```swift
// YourApp.swift
import SwiftUI
import RevragEmbed
@main
struct YourApp: App {
init() {
Task {
await EmbedSDK.shared.initialize(apiKey: "YOUR_REVRAG_API_KEY")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
```
### UIKit AppDelegate [#uikit-appdelegate]
```swift
// AppDelegate.swift
import UIKit
import RevragEmbed
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
Task {
await EmbedSDK.shared.initialize(apiKey: "YOUR_REVRAG_API_KEY")
}
return true
}
}
```
**What `initialize()` does:**
1. Calls `GET /embedded-agent/initialize` with your API key
2. Parses and stores your widget configuration (colors, agent name, avatar)
3. Pre-warms the Lottie animation cache
4. Installs `ClickEventTracker` for automatic rage-click detection
5. Sets `EmbedSDK.shared.isInitialized = true` on the main thread — the button appears automatically once true
Console logs are prefixed `[RevragEmbed]` — check them if initialization fails.
***
## Step 4 — Identify the user [#step-4--identify-the-user]
Call this right after your login flow completes:
```swift
EmbedSDK.shared.event(.userData, data: [
"app_user_id": "user_123", // required
"name": "Jane Doe", // optional
"email": "jane@email.com" // optional
])
```
Send `USER_DATA` **before** the user taps the call button. Without `app_user_id`, the agent cannot identify the user and conversation context will not be attributed.
***
## Step 5 — Add the floating button [#step-5--add-the-floating-button]
Apply the `.embedProvider()` modifier to your **root view**. This overlays the draggable button on top of all your existing content.
```swift
// ContentView.swift
import SwiftUI
import RevragEmbed
struct ContentView: View {
var body: some View {
NavigationStack {
HomeView()
}
.embedProvider(
appUserId: "user_123",
appVersion: Bundle.main.releaseVersionNumber ?? "1.0"
)
}
}
```
### UIKit alternative [#uikit-alternative]
Add `EmbedButton` as an overlay view in your root `UIViewController`:
```swift
import RevragEmbed
import SwiftUI
// In viewDidLoad of your root UIViewController
let embedView = UIHostingController(rootView:
EmbedButton(appUserId: "user_123")
)
embedView.view.backgroundColor = .clear
addChild(embedView)
view.addSubview(embedView.view)
embedView.view.frame = view.bounds
embedView.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
embedView.didMove(toParent: self)
```
### `.embedProvider()` modifier props [#embedprovider-modifier-props]
| Parameter | Type | Default | Description |
| ---------------------- | ----------------------------- | --------------- | --------------------------------------- |
| `appUserId` | `String` | `""` | Identifies the logged-in user |
| `appVersion` | `String` | `"1.0.0"` | Your app's version string |
| `accentColor` | `Color` | `#6C63FF` | Fallback gradient color |
| `visibilityConfig` | `EmbedButtonVisibilityConfig` | show everywhere | Screen allow/exclude list and groups |
| `navigationController` | `UINavigationController?` | `nil` | Enables automatic UIKit screen tracking |
***
## Routing scenarios [#routing-scenarios]
Screen tracking lets the agent know which screen the user is on. Choose the scenario that matches your navigation setup.
***
### Scenario A — NavigationStack (most common) [#scenario-a--navigationstack-most-common]
Post `EmbedViewDidAppear` from `.onAppear` on each screen. The SDK listens for this notification and updates the current screen name.
```swift
struct HomeView: View {
var body: some View {
List { /* content */ }
.onAppear {
NotificationCenter.default.post(
name: NSNotification.Name("EmbedViewDidAppear"),
object: nil,
userInfo: ["screen": "Home"]
)
}
}
}
struct ProductView: View {
let productId: String
var body: some View {
ScrollView { /* content */ }
.onAppear {
NotificationCenter.default.post(
name: NSNotification.Name("EmbedViewDidAppear"),
object: nil,
userInfo: ["screen": "ProductDetail"]
)
}
}
}
```
Apply the provider to the root:
```swift
struct ContentView: View {
var body: some View {
NavigationStack {
HomeView()
}
.embedProvider(appUserId: "user_123", appVersion: "1.0")
}
}
```
`.onAppear` fires again when navigating back to a screen — this is expected and correct behavior.
***
### Scenario B — TabView with multiple stacks [#scenario-b--tabview-with-multiple-stacks]
Apply `.embedProvider()` **outside** the `TabView` so the button floats above all tabs. Each tab's screens post `EmbedViewDidAppear` from `.onAppear` as shown in Scenario A.
```swift
struct ContentView: View {
var body: some View {
TabView {
NavigationStack {
HomeView()
}
.tabItem { Label("Home", systemImage: "house") }
NavigationStack {
ExploreView()
}
.tabItem { Label("Explore", systemImage: "magnifyingglass") }
NavigationStack {
ProfileView()
}
.tabItem { Label("Profile", systemImage: "person") }
}
// Provider goes here — outside TabView — so button floats over all tabs
.embedProvider(appUserId: "user_123", appVersion: "1.0")
}
}
```
Do **not** place `.embedProvider()` inside a tab. If it's scoped to one tab the button will disappear when switching to other tabs.
***
### Scenario C — No NavigationStack (flat views / custom transitions) [#scenario-c--no-navigationstack-flat-views--custom-transitions]
Fire the notification manually when your view becomes visible. You can also post `EmbedViewDidDisappear` on exit.
```swift
struct CheckoutView: View {
var body: some View {
VStack { /* content */ }
.onAppear {
NotificationCenter.default.post(
name: NSNotification.Name("EmbedViewDidAppear"),
object: nil,
userInfo: ["screen": "Checkout"]
)
}
.onDisappear {
// Optional — fires SCREEN_STATE with action: "exit"
NotificationCenter.default.post(
name: NSNotification.Name("EmbedViewDidDisappear"),
object: nil,
userInfo: ["screen": "Checkout"]
)
}
}
}
```
***
### Scenario D — UIKit UINavigationController [#scenario-d--uikit-uinavigationcontroller]
Pass the `UINavigationController` to `.embedProvider()` for automatic tracking:
```swift
struct ContentView: View {
let navController: UINavigationController
var body: some View {
UIKitNavigationWrapper(navController: navController)
.embedProvider(
appUserId: "user_123",
appVersion: "1.0",
navigationController: navController // ← automatic tracking
)
}
}
```
The SDK observes `UINavigationControllerDelegate` and fires `SCREEN_STATE` events automatically — no `.onAppear` notifications needed.
***
## Button visibility control [#button-visibility-control]
By default the button shows on **every screen**. Use `EmbedButtonVisibilityConfig` to control this.
### Show only on specific screens [#show-only-on-specific-screens]
```swift
ContentView()
.embedProvider(
appUserId: "user_123",
appVersion: "1.0",
visibilityConfig: EmbedButtonVisibilityConfig(
allowedScreens: ["Home", "Product", "Cart"],
excludedScreens: ["Login", "Splash", "Onboarding"]
)
)
```
### Keep button visible across a flow (e.g. checkout) [#keep-button-visible-across-a-flow-eg-checkout]
Use a group with `.continuous` continuity so the button doesn't flash between screens:
```swift
visibilityConfig: EmbedButtonVisibilityConfig(
groups: [
EmbedButtonGroupConfig(
id: "checkout-flow",
screens: ["Cart", "Payment", "Confirmation"],
continuity: .continuous,
delayMs: 0.5,
delayPolicy: .oncePerGroupEntry
)
]
)
```
### Visibility rules (evaluated in priority order) [#visibility-rules-evaluated-in-priority-order]
```
1. Screen is in excludedScreens → always HIDDEN (strongest rule)
2. allowedScreens and groups are both empty → always VISIBLE
3. Screen is in allowedScreens or any group → VISIBLE
4. Otherwise → HIDDEN
```
***
## Events [#events]
### EventKeys reference [#eventkeys-reference]
| Key | Value | Auto-fired | Host-callable | Notes |
| ---------------- | ---------------- | ------------------------- | ------------- | ----------------------------------------------------------- |
| `.userData` | `user_data` | No | **Yes** | Send after login; must include `app_user_id` |
| `.screenState` | `screen_state` | **Yes** — `EmbedProvider` | Yes | Fired on every screen enter and exit |
| `.analyticsData` | `analytics_data` | **Yes** — SDK UI | **Yes** | SDK fires built-in events; host may also fire custom events |
| `.customEvent` | `custom_event` | No | Yes | Free-form host-app events |
### Sending events [#sending-events]
```swift
// User identity (required before first call)
EmbedSDK.shared.event(.userData, data: ["app_user_id": "user_123"])
// Custom analytics event
EmbedSDK.shared.event(.analyticsData, data: ["event_name": "checkout_started"])
// Manual screen state
EmbedSDK.shared.event(.screenState, data: ["screen": "CheckoutScreen", "action": "enter"])
```
### Listen for agent call events [#listen-for-agent-call-events]
```swift
import RevragEmbed
class CallManager {
private var connectedId: UUID?
private var disconnectedId: UUID?
func startListening() {
connectedId = EmbedSDK.shared.onAgent(.agentConnected) { _ in
// e.g. pause background audio
print("Call started")
}
disconnectedId = EmbedSDK.shared.onAgent(.agentDisconnected) { payload in
let duration = (payload["metadata"] as? [String: Any])?["callDuration"] as? Int ?? 0
print("Call lasted \(duration)s")
}
}
func stopListening() {
if let id = connectedId { EmbedSDK.shared.offAgent(.agentConnected, id: id) }
if let id = disconnectedId { EmbedSDK.shared.offAgent(.agentDisconnected, id: id) }
}
}
```
### Agent lifecycle events reference [#agent-lifecycle-events-reference]
| Event | Payload | Use case |
| ---------------------- | ----------------------------------------------------- | ------------------------------------- |
| `.agentConnected` | `timestamp`, `metadata.callDuration: 0`, `server_url` | Pause background audio, start a timer |
| `.agentDisconnected` | `timestamp`, `metadata.callDuration` (seconds) | Resume audio, log call length |
| `.popupMessageVisible` | — | Track tooltip impressions |
***
## Analytics helpers [#analytics-helpers]
These helpers fire `analyticsData` events with standardized payloads.
```swift
// Track a custom event
EmbedSDK.shared.trackEvent("product_viewed", properties: ["product_id": "SKU-123"])
// Track a form interaction
EmbedSDK.shared.trackFormEvent(
formId: "checkout_form",
eventType: "submit",
formData: ["step": "payment"]
)
// Track a rage-click
EmbedSDK.shared.trackRageClick(
elementId: "btn_add_to_cart",
coordinates: CGPoint(x: 200, y: 450),
clickCount: 5,
elementType: "UIButton"
)
// Check microphone permission status
EmbedSDK.shared.checkMicPermission { granted in
if !granted { self.showMicEducationAlert() }
}
```
### Events auto-fired by the SDK [#events-auto-fired-by-the-sdk]
| `event_name` | Trigger |
| ----------------------------- | ------------------------------------------------------------------ |
| `agent_tap_to_open` | User taps the collapsed FAB |
| `agent_tap_to_close` | User taps avatar to close the card |
| `agent_visible` | `EmbedProvider` show-delay completes |
| `popup_message_visible` | Inactivity nudge fires the tooltip |
| `gen_tool_triggered` | First data-channel message received |
| `agent_conversation_started` | LiveKit `connect()` succeeds |
| `microphone_permission_allow` | On every call start (`status: already_granted` or `newly_granted`) |
***
## Cleanup on logout [#cleanup-on-logout]
Call this when the user logs out or switches accounts to prevent stale data from leaking into the next session.
```swift
func onUserLogout() {
EmbedSDK.shared.clearStorageCache()
}
```
***
## Configuration reference [#configuration-reference]
### `EmbedButtonVisibilityConfig` [#embedbuttonvisibilityconfig]
| Field | Type | Default | Description |
| ----------------- | -------------------------- | ------------------ | ----------------------------------------------- |
| `allowedScreens` | `[String]` | `[]` (all screens) | Button shows only on these screens |
| `excludedScreens` | `[String]` | `[]` | Button always hidden on these screens |
| `showDelay` | `TimeInterval` s | `0` | Delay before button fades in on allowed screens |
| `groups` | `[EmbedButtonGroupConfig]` | `[]` | Per-group override rules |
| `defaultInset` | `EmbedButtonInset?` | SDK default | Default edge snap position |
### `EmbedButtonGroupConfig` [#embedbuttongroupconfig]
| Field | Type | Default | Description |
| ------------- | ------------------------ | ------------ | ------------------------------------------------------------------------- |
| `id` | `String` | required | Unique group identifier |
| `screens` | `[String]` | required | Screens that belong to this group |
| `continuity` | `EmbedButtonContinuity` | `.perScreen` | `.continuous` keeps the button mounted across group screens without flash |
| `inset` | `EmbedButtonInset?` | `nil` | Per-group snap position override |
| `delayMs` | `TimeInterval` s | `0` | Show delay for this group |
| `delayPolicy` | `EmbedButtonDelayPolicy` | `.perScreen` | Controls when the delay resets |
### `EmbedButtonDelayPolicy` [#embedbuttondelaypolicy]
| Value | Behaviour |
| -------------------- | -------------------------------------------------- |
| `.perScreen` | Delay applies every time the screen becomes active |
| `.oncePerGroupEntry` | Delay only on the first entry into a group |
| `.oncePerAppSession` | Delay only on the very first view of the session |
### `EmbedButton` direct props [#embedbutton-direct-props]
| Parameter | Type | Default | Description |
| ------------- | ------------------ | ------------------- | ----------------------------------------- |
| `appUserId` | `String` | `""` | User identifier passed to the voice agent |
| `accentColor` | `Color` | `#6C63FF` | Fallback gradient |
| `isVisible` | `Bool` | `true` | Hides the button without unmounting it |
| `inset` | `EmbedButtonInset` | right=24, bottom=80 | Snap position from screen edges |
***
## Troubleshooting [#troubleshooting]
### App crashes on first call [#app-crashes-on-first-call]
**Error:**
```
This app has crashed because it attempted to access privacy-sensitive data
without a usage description.
```
**Fix:** Add `NSMicrophoneUsageDescription` to `Info.plist`. See [Step 2](#step-2--add-microphone-permission) above.
***
### The button never appears [#the-button-never-appears]
**Cause A — `initialize()` not called or failed**
Check the Xcode console for `[RevragEmbed]` prefixed logs:
```
[RevragEmbed] 🚀 Stage 1 — initialize() called
[RevragEmbed] 🌐 Stage 3 — calling GET /embedded-agent/initialize …
[RevragEmbed] ❌ Initialization failed:
```
Fix: verify your API key has no leading/trailing spaces and the device has internet.
***
**Cause B — SDK not ready yet**
`initialize()` is async. The button is hidden until `isInitialized = true` and appears automatically — no action required. To observe readiness:
```swift
print(EmbedSDK.shared.isInitialized) // should be true
```
***
**Cause C — Screen excluded by visibility config**
Screen names are **case-sensitive** and must match exactly what you post in `EmbedViewDidAppear` notifications.
```swift
// What you post in .onAppear:
userInfo: ["screen": "ProductDetail"]
// Must match exactly in visibilityConfig:
allowedScreens: ["Home", "ProductDetail"] // ✅ correct
allowedScreens: ["Home", "productdetail"] // ❌ case mismatch
allowedScreens: ["Home", "Product Detail"] // ❌ space mismatch
```
***
### Button disappears when switching tabs [#button-disappears-when-switching-tabs]
**Cause:** `.embedProvider()` is applied inside a tab instead of outside the `TabView`.
```swift
// ❌ Wrong — provider scoped to one tab only
TabView {
HomeView()
.embedProvider(appUserId: "user_123") // hidden on all other tabs
.tabItem { ... }
}
// ✅ Correct — provider wraps the entire TabView
TabView {
HomeView().tabItem { ... }
ProfileView().tabItem { ... }
}
.embedProvider(appUserId: "user_123") // visible on all tabs
```
***
### Screen tracking not working (agent doesn't know current screen) [#screen-tracking-not-working-agent-doesnt-know-current-screen]
**Cause:** `EmbedViewDidAppear` notification not posted from `.onAppear`.
```swift
// ✅ Add this to every screen's .onAppear
.onAppear {
NotificationCenter.default.post(
name: NSNotification.Name("EmbedViewDidAppear"),
object: nil,
userInfo: ["screen": "YourScreenName"]
)
}
```
***
### Background music doesn't resume after a call [#background-music-doesnt-resume-after-a-call]
The SDK calls `AVAudioSession.setActive(false, options: .notifyOthersOnDeactivation)` automatically in `endCall()`. If you observe this issue, ensure you are on SDK version ≥ 1.0 and that the session is not being deactivated before the SDK finishes cleaning up.
***
### Analytics events missing from dashboard [#analytics-events-missing-from-dashboard]
Check in this order:
1. `USER_DATA` was sent with a valid `app_user_id` before other events
2. `initialize()` completed successfully (`isInitialized == true`)
3. Device has internet connectivity
4. The SDK rate-limits to 5 req/s — bursts are **queued, not dropped**
***
### User identity leaks between accounts [#user-identity-leaks-between-accounts]
Always call `clearStorageCache()` on logout before the next user logs in:
```swift
func onUserLogout() {
EmbedSDK.shared.clearStorageCache()
}
```
***
## Pre-ship checklist [#pre-ship-checklist]
### Basic setup [#basic-setup]
* [ ] Package added via Xcode SPM: `https://github.com/RevRag-ai/embed-native`
* [ ] `NSMicrophoneUsageDescription` added to `Info.plist`
* [ ] `await EmbedSDK.shared.initialize(apiKey:)` called in `App.init()` or `AppDelegate`
* [ ] `USER_DATA` event sent with `app_user_id` immediately after login
* [ ] `.embedProvider()` applied to the root view (outside `TabView` if tabs are used)
* [ ] `clearStorageCache()` called on logout
### Screen tracking [#screen-tracking]
* [ ] `EmbedViewDidAppear` notification posted from every screen's `.onAppear` (SwiftUI NavigationStack)
* [ ] Or `navigationController` passed to `.embedProvider()` for automatic UIKit tracking
* [ ] Screen names in notifications match `visibilityConfig` exactly (case-sensitive)
### Visibility [#visibility]
* [ ] `visibilityConfig` configured if the button should not show on all screens
* [ ] `.embedProvider()` placed outside `TabView` (if applicable)
### Production readiness [#production-readiness]
* [ ] `[RevragEmbed]` logs checked — no initialization errors
* [ ] Agent event listeners started and stopped at appropriate lifecycle points
* [ ] Tested on a physical device — microphone permission dialog does not appear in Simulator
***
## Support [#support]
* Issues: [GitHub Issues](https://github.com/RevRag-ai/embed-native/issues)
* Docs: [Revrag Documentation](https://docs.revrag.ai)
* Dashboard: [app.revrag.ai](https://app.revrag.ai)