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
and add the GraineInApp library to your app target — that repository is the
package's source and tests. In a Package.swift:
Add the microphone reason to Info.plist — your app is the one asking:
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:
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
2. Add the bar
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
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
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
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
| Option | Default | |
|---|---|---|
baseUrl | — | https://www.graine.ai |
publishableKey | — | Selects the agent. Public by design |
variables | empty | Prompt variables known at build time |
transport | the dashboard's | Force WebRTC or WebSocket, for testing |
appVersion | — | Lands on the call record |
endOnBackground | true | End a call two seconds after the app leaves the foreground |
timing | Load watchdog and retry ladder | |
friction | — | Your layer of the stuck-customer rules, over the dashboard's |
debug | false | Bridge traffic and the engine's console in the log, values redacted |
chatSize | the dashboard's | How big the chat window opens: .compact, .large or .full; chat.open(size:) wins for one open — Window size |
GraineInApp
| Member | |
|---|---|
configure(_:), shared | Set 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 |
voice | The in-app engine's call — the bar |
calls | Native 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, ready | Call state |
quality, online, reconnecting, connectivity | The connection, in the same words on every platform |
captions | The 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 |
phase | idle, connecting, reconnecting, thinking, speaking, listening — one thing to draw from. 0.3.0 |
connectStage, slowConnect, callFailed | Where 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) |
state | idle, ringing, connecting, active, ended(reason) — .local, .remote, .failed, .declined or .missed; ended lasts a second before idle |
callId, direction, callerName, connectedAt | The call in progress |
muted, speakerOn, agentSpeaking, caption | What to draw |
transport, transportFallbackReason | What 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 | |
|---|---|
initialOffset | Restore a position you saved |
onPlacementChange | The 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
| Symptom | Check |
|---|---|
| Nothing happens, and the log says 403 | app:// is missing from the agent's Allowed domains |
| No sound with the ringer switch off | The 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 screen | setScreen 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 ring | The 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 altogether | iOS 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 silent | Background Modes must include Audio and Voice over IP; the SDK starts the audio when CallKit activates it |
| Calls never use WebRTC | GraineInAppRTC 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 GraineInAppRTC | Another package pins the same WebRTC build at a different version: align the versions, or leave the module out — calls stay on the WebSocket |

