Graine AI

iOS (Swift)

The in-app agent in a native iOS app — screen context, actions, components and voice, as a Swift package with no dependencies.

The iOS SDK is a Swift package with no dependencies, for iOS 15 and up. It hosts the same voice engine as every other Graine SDK — in a WKWebView it owns, one invisible point big — so every audio fix reaches your app without a release. Its rules (masking, connection quality, component looks, placement, friction) are tested against the answers the React Native SDK gives.

Install

In Xcode: File → Add Package Dependencies…, enter

https://github.com/GraineAI/inapp-ios

and add the GraineInApp library to your app target — that repository is the package's source and tests. In a Package.swift:

.package(url: "https://github.com/GraineAI/inapp-ios", from: "0.10.3")

Add the microphone reason to Info.plist — your app is the one asking:

<key>NSMicrophoneUsageDescription</key>
<string>Used to talk to the in-app assistant.</string>

In the dashboard, give the app its own agent and add app:// to its Allowed domains under Embed & Widgets. Without it, every session is refused.

App Store privacy. The SDK ships a privacy manifest that declares the required-reason APIs it calls — system uptime, for timing, and UserDefaults, for its call settings — and no tracking; Xcode adds it to your app's privacy report. What your app collects through the agent (the customer's voice, and what you report to it) is yours to declare in your App Store privacy details.

Calls over WebRTC (optional)

The native calls — the customer calling the agent, and a call Graine rang — carry their audio over WebRTC when you add the GraineInAppRTC package beside GraineInApp, at the same version. In Xcode, add https://github.com/GraineAI/inapp-ios-rtc and its GraineInAppRTC library to the same target; in a Package.swift:

.package(url: "https://github.com/GraineAI/inapp-ios-rtc", from: "0.10.3")

With CocoaPods, pod 'GraineInAppRTC', :git => 'https://github.com/GraineAI/inapp-ios-rtc.git', :tag => '0.10.3'. One Info.plist entry, and nothing else: NSCameraUsageDescription — WebRTC contains camera code, and App Store processing refuses a build that references the camera without it (ITMS-90683: the build never reaches TestFlight). Calls are audio only; say so, for example Calls are audio only. The camera is never turned on. No code, no capability — the calls find the module by themselves (a build that strips code nothing references can call GraineRTC.install() at launch). GraineInApp itself keeps no dependencies, and an app that does not add the module never downloads it.

  • What the customer notices: the same ring, the same call screen, the same Connecting…. On a weak or changing network the agent's voice holds up instead of stalling and catching up. Over WebRTC it arrives at telephone bandwidth today.
  • When WebRTC cannot carry a call, it continues on the WebSocket inside the same call — no second ring.
  • The dashboard decides last: the agent's In-app call audio setting (Calls & scheduling) — WebRTC (recommended) or Standard — can move every install's calls to the WebSocket from their next start, without a release.
  • Size: about 12 MB more installed on an arm64 iPhone, about 6 MB more to download. Add the package's THIRD-PARTY-NOTICES.md (WebRTC, BSD 3-Clause) to your app's open-source notices.

The choice in code, and which one ran: Calls over WebRTC.

Quickstart

1. Configure once, at launch

import GraineInApp
 
@main
struct MyApp: App {
    init() {
        GraineInApp.configure(GraineConfig(
            baseUrl: "https://www.graine.ai",
            publishableKey: "pk_live_…",     // Agent → Embed & Widgets
            appVersion: "2.4.0"
        ))
        GraineInApp.shared.registerAction("open_screen") { args in
            // Main thread. Answer within six seconds, and say what actually happened.
            router.open(args["path"] as? String)
            return .ok("Opened the screen.")
        }
    }
    var body: some Scene { WindowGroup { RootView() } }
}

2. Add the bar

RootView()
    .overlay(GraineAgentBar(
        initialOffset: savedOffset,
        onPlacementChange: { savedOffset = $0 }   // where the customer dragged it; the SDK stores nothing
    ))
    .overlay(alignment: .bottom) {
        GraineWidgetView().padding(.horizontal, 12).padding(.bottom, 96)   // the components the agent shows
    }

The bar mounts the voice engine in the key window the first time it appears, places the call, asks for the microphone, says what the network is doing, can be dragged, and rises above the keyboard. The engine lives in the window, not in a screen, so a call carries on while the customer moves through your app.

3. Tell it what is on screen

let graine = GraineInApp.shared
graine.identify(["name": user.firstName, "plan": user.plan])   // nil on sign-out
graine.setScreen(GraineScreen(
    screen: "kyc_documents",
    title: "Upload your documents",
    journey: .init(name: "KYC", step: 2, of: 4),
    fields: [GraineField(name: "pan", label: "PAN", value: pan, status: .invalid, error: "Not recognised")]
))

Report real values: PAN, Aadhaar, card, phone and email are masked on the device before anything leaves it. GraineMasking.addRule(pattern:token:) adds your own shapes.

4. Components

On iOS the bar does not draw components: GraineWidgetView() does, wherever you place it — above the bar, as in step 2. It draws every component the agent shows, in the brand's design, and answers the agent with what the customer picked. See Components.

UIKit

The bar and the component view are SwiftUI views; a UIKit app hosts them in a UIHostingController. Without the bar, mount the engine yourself — GraineInApp.shared.voice.attach(to: window), once — and drive the call with start(), end() and setMuted(_:).

A handler that takes time

graine.registerAction("upload_document", deferred: { args, done in
    upload(args) { ok in done(ok ? .ok("Uploaded.") : .error("The upload failed.")) }
})

The agent waits for done, up to six seconds; after that it is told the app did not answer.

Calls: CallKit and VoIP pushes

A WhatsApp-style call with your agent on the system's own call screen: the customer calls it, or your server rings the customer with a VoIP push — answered from the lock screen, going on with the screen off. calls runs the call's audio in your app's own process, so it works with CallKit, where a web view cannot. 0.6.0

let calls = GraineInApp.shared.calls
calls.configure(GraineCallsConfig(displayName: "Acme Assistant"))
calls.enableVoIPPushes { token in myServer.saveVoipToken(token) }   // at launch, every launch
calls.addListener(self)                                             // outcomes, for your server
calls.startCall(reason: "card_blocked")                             // the customer calls the agent

Push Notifications and Background Modes (Audio, Voice over IP) are required. The setup, the push your server sends and what to test: In-app calling. With the GraineInAppRTC package in the app, both directions carry their audio over WebRTC; GraineCallsConfig(callTransport:) chooses and calls.transport says which one ran. 0.9.0

Reference

GraineConfig

OptionDefault
baseUrl—https://www.graine.ai
publishableKey—Selects the agent. Public by design
variablesemptyPrompt variables known at build time
transportthe dashboard'sForce WebRTC or WebSocket, for testing
appVersion—Lands on the call record
endOnBackgroundtrueEnd a call two seconds after the app leaves the foreground
timingLoad watchdog and retry ladder
friction—Your layer of the stuck-customer rules, over the dashboard's
debugfalseBridge traffic and the engine's console in the log, values redacted
chatSizethe dashboard'sHow big the chat window opens: .compact, .large or .full; chat.open(size:) wins for one open — Window size

GraineInApp

Member
configure(_:), sharedSet up once; the instance
identify(_:), setVariables(_:), currentVariables()Who the customer is, and prompt variables from the app's own data
setScreen(_:), track(_:data:)What is on screen; events that inform without interrupting
registerAction(_:handler:), registerAction(_:deferred:)One handler per declared action. Each returns an unregister closure
checkActions()Which declared actions this build handles
noteInteraction()The customer did something that is not a field edit
addListener(_:), removeListener(_:)Events. Listeners are held weakly
answerComponent(widgetId:data:summary:)Answer a component from a UI of your own, on the conversation that is live. 0.9.0
voiceThe in-app engine's call — the bar
callsNative calls on CallKit and VoIP pushes, below. 0.6.0

GraineVoice

An ObservableObject, so your own SwiftUI can draw from it — a full example, with every state to draw, is in Your own UI.

Member
attach(to:)Mount the engine in a window, once. The bar does this for you
start(), end(), setMuted(_:), retry()Place, hang up, mute, reload the engine
connected, connecting, muted, readyCall state
quality, online, reconnecting, connectivityThe connection, in the same words on every platform
captionsThe transcript
captionsOn, setCaptionsOn(_:), clearCaptions()The captions switch: on by default; off, the bar hides its caption while captions keeps what was said; clearCaptions() forgets it without ending the call. 0.9.0
viewOpened(), viewClosed()A voice UI of your own on screen counts as the bar does: a slow connect is reported only while one is open. 0.9.0
phaseidle, connecting, reconnecting, thinking, speaking, listening — one thing to draw from. 0.3.0
connectStage, slowConnect, callFailedWhere a call being placed is (registering, dialling, retrying, switching), whether the customer has waited past Timing.slowConnectMs (4 s), and whether nothing could carry the call — start() tries again. The bar already says all three. 0.5.0

GraineCalls

An ObservableObject, main thread. 0.6.0

Member
configure(_:)How calls ring and what they show (GraineCallsConfig). Once, at launch
enableVoIPPushes(onToken:)Receive your agent's calls as VoIP pushes; onToken gets the hex token (nil when invalidated). At launch, every launch
reportIncomingCall(payload:completion:)For an app with its own PKPushRegistry: hand over the push, reported to CallKit at once
startCall(reason:variables:)The customer calls the agent. Returns the call's id, nil while a call is live
answer(), end()Answer a ringing call from your own screen; hang up or decline
setMuted(_:), setSpeaker(_:)Mute; the loudspeaker (the earpiece by default)
stateidle, ringing, connecting, active, ended(reason) — .local, .remote, .failed, .declined or .missed; ended lasts a second before idle
callId, direction, callerName, connectedAtThe call in progress
muted, speakerOn, agentSpeaking, captionWhat to draw
transport, transportFallbackReasonWhat carries the call's audio — .webrtc or .websocket — and why a call that tried WebRTC runs on the WebSocket. 0.9.0
addListener(_:), removeListener(_:)GraineCallsListener, held weakly

GraineCallsConfig(displayName:ringtoneSound:iconTemplateImageData:includesCallsInRecents:ringTimeout:callTransport:) — the name the call shows (your app's name if you leave it empty; a push's caller_name wins), a ring sound in your bundle, a template icon for the system call screen, whether calls appear in Recents (off by default), the seconds before an unanswered ring is missed (30, and at least 1), and what carries the calls' audio: .auto (the default — WebRTC when GraineInAppRTC is in the app), .websocket or .webrtc.

GraineCallsListener — onIncomingCall(_:), onCallState(_:callId:) and onCallOutcome(_:callId:): .answered, .declined, .missed, .ended(.local | .remote | .failed) or .failed(message), the message saying why, for your logs. Every method is optional.

GraineCallView() is a ready call screen for while your app is open.

GraineAgentBar

Parameter
initialOffsetRestore a position you saved
onPlacementChangeThe customer let go somewhere new: save it
.accessory { … }Anything that should ride on the bar, above it
onClose:The end button was pressed with no call — take the bar away if your app shows it on demand. 0.3.0

The bar is drawn in the brand's design, exactly as the dashboard's Garage previews it: the design's surface, corners and shadow (a hard offset for Neo-Brutalist, frosted for Liquid Glass), the accent avatar with the launcher glyph, the name and status in the design's typeface with a meter that moves at the design's pace, the microphone, the hang-up and the caption card — and the dashboard's loader while the call connects, reconnects or the agent thinks. The SDK downloads the design's typeface from Graine and registers it itself; there is nothing to bundle and nothing to configure. 0.3.0 — Your design

From 0.7.0 the bar carries the chat too: where the dashboard offers both, it shows a switch and opens the chat window in your design. To place the chat yourself, use GraineChatView (SwiftUI) or GraineChatViewController (UIKit) — Chat. To draw a chat of your own, GraineChatRows.build(turns) lays the conversation out as the SDK's window does — a bubble per message, one Voice call row per earlier call, a day line where the day changes — and chat.clear() empties the chat on screen (only the screen: the conversation goes on). 0.9.0

GraineListener has the same events as on Android — session ready, call state, captions, components, ended, microphone denied, quality, network, reconnecting, transport, actions, friction and errors — each with a default, so you implement only what you use.

Troubleshooting

SymptomCheck
Nothing happens, and the log says 403app:// is missing from the agent's Allowed domains
No sound with the ringer switch offThe SDK sets a .playAndRecord audio session during a call; an app that sets its own session category afterwards overrides it
The agent does not know the screensetScreen is not called on that screen, or not on every change
"Sorry, I can't do that here"The action is not declared in the dashboard, or the name differs
What went over the bridge?debug: true logs every message both ways, values redacted
A VoIP push does not ringThe push needs apns-push-type: voip and apns-topic: <bundle id>.voip, sent to the token from enableVoIPPushes — not an FCM token
VoIP pushes stopped arriving altogetheriOS stops delivering to an app that does not report each one to CallKit. Call enableVoIPPushes at every launch (or reportIncomingCall from your own registry) and send VoIP pushes only for calls
A call answered on the lock screen is silentBackground Modes must include Audio and Voice over IP; the SDK starts the audio when CallKit activates it
Calls never use WebRTCGraineInAppRTC is not linked into the app target (a build that strips unreferenced code: call GraineRTC.install() at launch), callTransport is .websocket, or the agent's In-app call audio is Standard. calls.transportFallbackReason says why a call that tried WebRTC did not use it
Xcode cannot resolve the packages after adding GraineInAppRTCAnother package pins the same WebRTC build at a different version: align the versions, or leave the module out — calls stay on the WebSocket

Next steps

On this page