Graine AI

In-app calling

Calls with your agent that work like a WhatsApp call — the customer calls it from your app, or your agent rings the customer, answered from the lock screen and going on with the screen off. Everything an engineer needs, from the prerequisites to a working server.

A call with your agent can work like a WhatsApp call. The customer taps Call in your app — or your agent rings them, on the lock screen, with the system's own incoming-call screen — and the conversation carries on with the screen off, over Bluetooth, while they open other apps.

The iOS and Android SDKs do this with a native call engine: the call's audio runs in your app's own process with the platform's echo canceller, so it rings through CallKit and VoIP pushes on iOS and a full-screen call notification on Android. React Native and Flutter apps use the same engine, through @graineai/inapp-react-native-calls and graine_inapp. You add a few lines to your app, and your server sends one push when it wants the agent to call someone.

Two engines, one agent. The assistant bar inside your app runs on the in-app engine (WebRTC, components drawn on screen). A call runs on the native call engine. Both talk to the same agent, with the same screen context and actions, and only one runs at a time — which ends which. Calls are voice only.

How it works

The customer calls the agent

  1. The customer taps your call button; your app calls startCall(reason:).
  2. The SDK places the call — the system's call screen on iOS, the SDK's call screen on Android — and connects to the agent. The agent hears who_called = "customer" and the reason.
  3. When it ends, the SDK reports the outcome to your app.

The agent calls the customer

  1. Your server decides to call (a payment is due, a delivery slipped, a callback the customer booked) and sends a push to the customer's devices — or, with hosted ringing, Graine rings them for a call the agent, the customer or your team booked.
  2. The SDK rings — CallKit on iOS, a call notification on Android — even when the app is closed or the phone is locked.
  3. The customer answers; the SDK connects to the agent with who_called = "agent", the reason and any variables from the push.
  4. The SDK reports every outcome to your app — answered, declined, missed, ended or failed — and your app tells your server.
The customer's phoneYour serverdecides to callstores device tokensrecords outcomesPush serviceAPNs (iOS)FCM (Android)Your appyour Call buttonwho, screen, actionsreports outcomesGraine SDKnative call enginethe ring and the call screenmicrophone and speakerecho cancelling, Bluetoothlock screen, screen offthe line to your agentYour agenthears and speaksruns your actionsCall Historytranscript, outcomestartCall123456

The parts of an agent's call, numbered in order. A call the customer places starts at your app's startCall instead of a push. In depth: How calls work.

PartWho builds it
The ring, answering, the call screen, the lock screen, Bluetooth, the screen offThe SDK
The conversation, your screen context and actions, the record in Call HistoryThe SDK and Graine
Who the customer is, and why you are callingYour app — identify() and the call's reason
Deciding to call someone, and whenYour server — or, with hosted ringing, a booking: by the agent, the customer or your team
Sending the pushYour server — APNs on iOS, Firebase Cloud Messaging on Android. With hosted ringing, Graine, with your credentials
Storing devices, outcomes and the calls listYour server. With hosted ringing, Graine

There are two ways to ring. Your own push server: Graine holds no push tokens and rings nothing — your server knows your customers, their devices and when they agreed to be called, and it sends the push and keeps the record. Hosted ringing: you upload your APNs key and Firebase service account once, the SDK registers each device with Graine, and Graine rings it when a booked call is due and records every outcome. Tokens and credentials are stored encrypted; a dead token is deleted, and a device unseen for 90 days with it. Hosted ringing

This page sets calls up, platform by platform.

Before you start

What you need
GraineAn agent for the app, its publishable key (Agent → Embed & Widgets), and app:// in its Allowed domains. A prompt that handles the variables who_called and call_reason (below)
iOSSDK 0.7.0+, iOS 15+, an Apple Developer account, the Push Notifications capability, an APNs auth key (a .p8 file, its Key ID and your Team ID) for your server or for hosted ringing, and a real iPhone — CallKit and VoIP pushes do not run in the simulator
AndroidSDK 0.7.0+, Android 7 (API 24)+, a Firebase project with Cloud Messaging added to the app (google-services.json), and a service account for your server
React Native@graineai/inapp-react-native-calls 0.2.0+ with @graineai/inapp-react-native 0.41.0+, React Native 0.71+, a native build (not Expo Go), Firebase in the Android app for pushes (google-services.json; the package brings Firebase Cloud Messaging — below), and the iOS and Android requirements above
Fluttergraine_inapp 0.7.0+, Flutter 3.19+, Firebase Cloud Messaging in the Android app for pushes (google-services.json, its Gradle plugin and firebase-messaging), and the iOS and Android requirements above
Your serverOnly when you ring through your own push server: anywhere you can make an HTTPS request from. The example below is Node.js 18+. With hosted ringing, none
WebRTC (optional)For calls whose audio rides WebRTC, from 0.9.0: the iOS package GraineInAppRTC, the Android module ai.graine:inapp-android-rtc — each at the same version as its SDK — or, in React Native and Flutter, a build with WebRTC switched on. Without it every call runs on the WebSocket, as before

The prompt. The SDK sets who_called on every call, and call_reason whenever the call has a reason. A variable nothing sets is left out of the prompt, so write it for both directions, and for a call with no reason:

If {who_called} is "agent", you are calling the customer: greet them by name,
say you are calling from Acme, and ask whether now is a good time. The reason
for the call, if there is one: {call_reason}. If {who_called} is "customer",
they called you: ask how you can help.

iOS

Install — Xcode → File → Add Package Dependencies → https://github.com/GraineAI/inapp-ios, version 0.7.0 or later, the GraineInApp library.

Capabilities. Signing & Capabilities → Push Notifications, and Background Modes → Audio, AirPlay, and Picture in Picture and Voice over IP. Add the microphone reason to Info.plist:

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

Ask for the microphone before the first call — during onboarding, say, with await calls.requestCallPermissions() — so that a call answered on the lock screen has it. If any of this is missing, the SDK says so at calls.configure: see Permissions and the setup check.

Set up at launch, every launch. iOS wakes a closed app for a VoIP push and delivers it only once enableVoIPPushes has run, so this goes in didFinishLaunching — not behind a sign-in or a screen:

import SwiftUI
import GraineInApp
 
@main
struct AcmeApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
    var body: some Scene { WindowGroup { ContentView() } }
}
 
final class AppDelegate: NSObject, UIApplicationDelegate, GraineCallsListener {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        let graine = GraineInApp.configure(GraineConfig(
            baseUrl: "https://www.graine.ai",
            publishableKey: "pk_live_…"                   // Agent → Embed & Widgets
        ))
        graine.calls.configure(GraineCallsConfig(displayName: "Acme Assistant"))
        graine.calls.addListener(self)                     // held weakly; the app delegate lives for the app's life
        graine.calls.enableVoIPPushes { token in           // hex; nil when iOS invalidates it
            guard let token = token else { return }
            Backend.saveDevice(platform: "ios", token: token)
        }
        return true
    }
 
    // What became of each call — tell your server.
    func onCallOutcome(_ outcome: GraineCallOutcome, callId: String) {
        Backend.reportOutcome(callId: callId, outcome: outcome.wire, endReason: outcome.endReason?.rawValue)
    }
}

Backend is your own networking code. After sign-in, tell the agent who the customer is: GraineInApp.shared.identify(["name": user.firstName]).

Let the customer call, and show the call while your app is open — the system's call screen covers the lock screen and the background:

struct ContentView: View {
    @ObservedObject private var calls = GraineInApp.shared.calls
 
    var body: some View {
        Button("Call the assistant") {
            calls.startCall(reason: "support")             // the call's id; nil while another call is live
        }
        .fullScreenCover(isPresented: .constant(calls.state != .idle)) {   // up until idle, to show how it ended
            GraineCallView()                               // or your own screen, drawn from `calls`
        }
    }
}

Ring the device from your server with the token from enableVoIPPushes — see Your server. Answering connects the call; your onCallOutcome hears answered, then ended.

For a screen of your own, GraineCalls is an ObservableObject: state (idle, ringing, connecting, active, ended), callId, callerName, muted, speakerOn, agentSpeaking, caption and connectedAt, with answer(), end(), setMuted(_:) and setSpeaker(_:) (the earpiece by default, like a phone call). The full list is in the iOS reference.

Every VoIP push rings. iOS requires each VoIP push to be reported to CallKit at once and stops delivering them to apps that do not — so the SDK reports every one. A push that arrives during a call is ended at once and reported missed; one that is not an agent call is ended at once too, and reported failed if it carries a call_id. Send VoIP pushes only for calls. An app with its own PKPushRegistry hands the payload to calls.reportIncomingCall(payload:completion:) instead. Apple does not allow CallKit in apps on the App Store in China: leave calls off there.

Calls over WebRTC (optional)

Add the GraineInAppRTC package — Xcode → File → Add Package Dependencies → https://github.com/GraineAI/inapp-ios-rtc, the same version as GraineInApp, its GraineInAppRTC library in your app's target (CocoaPods: pod 'GraineInAppRTC', :git => 'https://github.com/GraineAI/inapp-ios-rtc.git', :tag => '0.10.3') — and calls in both directions carry their audio over WebRTC. No code, no capability: the calls find it by themselves. One Info.plist entry, for the App Store: 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. About 12 MB more installed. The choice, the dashboard switch and the fallback: WebRTC or the WebSocket.

Android

Install — the SDK and Firebase Cloud Messaging:

// app/build.gradle.kts
dependencies {
    implementation("ai.graine:inapp-android:0.7.0")
    implementation(platform("com.google.firebase:firebase-bom:<latest>"))
    implementation("com.google.firebase:firebase-messaging")
}

Add Firebase to the app as Firebase's own setup describes (google-services.json and its Gradle plugin). The SDK's manifest merges in everything a call needs — the microphone, notifications, the full-screen ring, a microphone foreground service, the call screen and its receiver, and a Firebase messaging service of its own — so there is nothing to add for the SDK itself.

Set up in your Application:

class AcmeApp : Application() {
    override fun onCreate() {
        super.onCreate()
        val graine = GraineInApp.init(this, GraineConfig(
            baseUrl = "https://www.graine.ai",
            publishableKey = "pk_live_…",                  // Agent → Embed & Widgets
        ))
        graine.calls.configure(GraineCallsConfig(
            displayName = "Acme Assistant",
            smallIcon = R.drawable.ic_stat_call,           // monochrome; 0 → the platform's call icon
        ))
        graine.calls.addListener(object : GraineCallListener {
            override fun onCallOutcome(callId: String, outcome: GraineCallOutcome) {
                // answered · declined · missed · ended · failed — tell your server, off the main thread
                Backend.reportOutcome(callId, outcome.wire, outcome.endReason?.wire)
            }
        })
    }
}

Pushes reach the SDK by itself. With Firebase in the app, the SDK's own messaging service rings every call push — even when the app is closed — and the SDK reads the push token when it starts, so for hosted ringing it registers the device itself. No code.

Ringing from your own server, or already have your own FirebaseMessagingService? Your server needs each device's token, so keep a messaging service of your own. Android delivers each message to one service, and yours outranks the SDK's: hand Graine's pushes and the token to the SDK first, two lines:

class AcmeMessagingService : FirebaseMessagingService() {
    override fun onNewToken(token: String) {
        GraineCalls.onNewToken(this, token)
        Backend.saveDevice(platform = "android", token = token)
    }
 
    override fun onMessageReceived(message: RemoteMessage) {
        if (GraineCalls.handlePush(this, message.data)) return
        // …your app's other pushes
    }
}
<!-- AndroidManifest.xml, inside <application> -->
<service android:name=".AcmeMessagingService" android:exported="false">
    <intent-filter><action android:name="com.google.firebase.MESSAGING_EVENT" /></intent-filter>
</service>

Ask for the permissions with your onboarding, so a ring is never answered into a permission prompt. One call asks for the microphone and, on Android 13+, notifications — from a transparent screen of the SDK's own, so nothing forwards a result:

fun askForCallPermissions() {
    val calls = GraineInApp.get().calls
    calls.requestCallPermissions(this) { p ->
        // GRANTED · DENIED (not yet) · BLOCKED (only Settings can change it) · NOT_NEEDED
        if (p.fullScreen == GraineCallPermissionStatus.BLOCKED) calls.openCallSettings(GraineCallPermission.FULL_SCREEN)
    }
}

Let the customer call. startCall opens the call screen and connects:

callButton.setOnClickListener {
    GraineInApp.get().calls.startCall(this, reason = "support")   // the call's id; null while another call is live
}

Ring the device from your server with the FCM token — see Your server.

The call screen. GraineCallActivity rings (Answer, Decline) and then shows the call (status, the line being said, mute, speaker, end), over the lock screen. The call itself runs in a foreground service, so locking the phone or opening another app does not end it; the ongoing notification has Hang up.

For your own screen, name it in GraineCallsConfig(callActivity = …) and draw it from GraineInApp.get().calls (state, call, muted, speaker, agentSpeaking, caption, connectedAt) and GraineCallListener (onCallState, onCallCaption, onAgentSpeaking, onCallControls), with answer(), decline(), end(), setMuted() and setSpeaker(). The SDK opens your activity for a ring and for Answer, which gives it three more jobs:

  • Show over the lock screen — setShowWhenLocked(true) and setTurnScreenOn(true) — as the SDK's own screen does.
  • Forward onRequestPermissionsResult to GraineInApp.get().calls: the SDK asks for the microphone from the call screen.
  • Pass new intents on if the activity is singleTop or singleTask: call setIntent(intent) in onNewIntent, because Answer can arrive as a new intent.

The full list is in the Android reference and the calling reference.

The full screen is a permission on Android 14 and later. Google Play grants USE_FULL_SCREEN_INTENT by default only to apps whose main purpose is calling or alarms. Without it the ring is a heads-up notification — still ringing, with Answer and Decline. calls.callPermissions.fullScreen tells you which you have, and calls.openCallSettings(GraineCallPermission.FULL_SCREEN) opens the page where the customer allows it.

Calls over WebRTC (optional)

Add the WebRTC module beside the SDK, at the same version, and calls in both directions carry their audio over WebRTC:

implementation("ai.graine:inapp-android-rtc:0.10.3")   // the same version as ai.graine:inapp-android

Nothing else changes in your code. The module's manifest adds ACCESS_NETWORK_STATE (a normal permission: nothing is asked) and its R8 rules ship inside it. About 7.4 MB more installed on a 64-bit phone. The choice, the dashboard switch and the fallback: WebRTC or the WebSocket.

Permissions and the setup check

What the SDK does by itself: the call itself — CallKit and VoIP pushes on iOS; on Android the ring, the call screen and the microphone service, every manifest entry merged — the call going on with the screen off, the microphone asked for when a call starts without it, and every outcome reported.

What your app adds: the iOS capabilities and Info.plist entries (an SDK cannot add them; with Expo, the React Native calls package's config plugin does), Firebase in the app on Android (the SDK's own messaging service does the rest), and one call to ask for the permissions at a moment you choose — the SDK never asks on its own at launch:

AskReadOpen Settings
iOSawait calls.requestCallPermissions()calls.callPermissionscalls.openCallSettings(.microphone)
Androidcalls.requestCallPermissions(activity) { p -> }calls.callPermissionscalls.openCallSettings(GraineCallPermission.FULL_SCREEN)
React Nativeawait GraineCalls.requestCallPermissions()await GraineCalls.callPermissions()GraineCalls.openCallSettings("fullScreen")
Flutterawait GraineCalls.instance.requestCallPermissions()await GraineCalls.instance.callPermissions()GraineCalls.instance.openCallSettings(GraineCallPermission.fullScreen)

Each permission — microphone, notifications (Android), fullScreen (Android 14+) — is granted, denied (not yet), blocked (only Settings can change it) or notNeeded (iOS's notifications and full screen: CallKit).

What is still missing. At configure the SDK checks the setup — on iOS the usage string, the audio and voip background modes, the push entitlement, enableVoIPPushes and the VoIP token; on Android the manifest, the push token, notifications and the full-screen ring; on both the microphone and whether Graine can ring this phone — and logs each problem once with the exact fix. callSetupProblems returns the same list as codes and sentences. Every code: Permissions → What is still missing.

Your server

Three jobs: remember each customer's devices, ring them, and record what happened.

1. Store devices. Your app sends its token after launch (saveDevice above): an iOS VoIP token or an Android FCM token, per customer and device.

2. Ring. Create a call record (call_id, the customer, the reason, status ringing), then push to each of their devices. The push carries string values:

Key
typerequiredagent_call, exactly
call_idrequiredYour id for this call — every outcome carries it back
reasonoptionalBecomes the prompt's call_reason
caller_nameoptionalWhat the ring shows, over the configured displayName
variablesoptionalA JSON object as a string: more prompt variables for this call. If it cannot be read, it is ignored and the call still rings

Send iOS pushes as VoIP pushes through APNs (apns-push-type: voip, topic <bundle id>.voip, priority 10, expiration 0) and Android pushes as high-priority, data-only FCM messages with a 30-second lifetime. The request details, the error codes, a reference implementation in Node.js and how to send a test push are in Your calling server.

3. Record what happened. Your app reports each outcome with the call_id (reportOutcome above). Mark the call missed if no answered arrives within about 45 seconds of the push — a missed report alone decides nothing, since another device or a repeated push can send one for a call that was answered; show missed calls in your app with a Call back that is startCall with the same reason; and ring again only by your own rules — once, at a sensible hour, is plenty. The conversation itself (transcript, outcome, the actions the agent took) is in Call History under the customer's name from identify().

Call states and outcomes

StateMeans
idleNo call
ringingAn agent's call is ringing on this device (incoming only)
connectingAnswered, or placed by the customer; the line is opening
activeThe agent and the customer can hear each other
endedOver; reported once, then back to idle
OutcomeWhenWhat your server does
answeredThe customer picked up an incoming callMark it answered; an ended or failed follows
declinedThey declined itDon't ring again soon
missedNobody answered within the ring timeout (30 s), another call was live, or (iOS) Do Not Disturb, a Focus or the block list stopped the ringShow a missed call; retry by your rules
endedA call that connected is over — local (the customer hung up), remote (the agent did) or failed (the line broke or, on Android, the microphone stopped working; calls do not reconnect)Close the record
failedIt never connected — the microphone was refused, the line did not open within 15 seconds, or the agent could not be reachedShow "Couldn't connect"; offer to call back

One call at a time: a ring that arrives during a call is reported missed without disturbing the call, and startCall returns nothing while a call is live.

Test it

  1. Place a call from the app on a real device. The agent should open with the reason you sent; in Call History the conversation appears under the customer's name.
  2. Ring the device from your server, three ways: with the app open, in the background, and closed (swiped away). Each must ring — the system's incoming call on iOS, the full-screen ring on a locked Android phone.
  3. Answer from the lock screen, lock it again and keep talking; switch to another app mid-call.
  4. Decline one and let one ring out: declined and missed reach your server with the right call_id, and nothing rings twice.
  5. Ring during a live call: it is reported missed; the call is untouched.
  6. Hang up from both sides; the system's call screen (iOS) and the ongoing notification (Android) go away with the call.
  7. A Bluetooth headset: the call follows it, as a phone call does.

Turn on debug in GraineConfig while testing on iOS and Android: the call's main steps go to the Xcode console or logcat (tag Graine) — in React Native and Flutter, set debug: true in the calls options — and audio, messages and variable values are never logged. The full test plan, the App Store and Google Play requirements and a go-live checklist are in Test and release.

Troubleshooting

What you seeWhy, and what to do
Anything belowFirst read callSetupProblems (or the log at configure): it names what is missing, with the fix
iOS: a VoIP push does not ringIt needs apns-push-type: voip and apns-topic: <bundle id>.voip, sent to the token from enableVoIPPushes (not an FCM token), on the right APNs host — development builds use the sandbox
iOS: VoIP pushes stopped arriving altogetheriOS stops delivering to an app that does not report each push to CallKit. Call enableVoIPPushes at every launch, and send VoIP pushes only for calls
iOS: a call answered on the lock screen is silentBackground Modes must include Audio and Voice over IP
Android: no ring when the app is closedSend a data-only message (no notification block) at high priority. An app with its own messaging service calls GraineCalls.handlePush(this, message.data) first in its onMessageReceived
Android: a heads-up notification instead of the full screenAndroid 14+ without USE_FULL_SCREEN_INTENT — see above; it still rings. Offer calls.openCallSettings(GraineCallPermission.FULL_SCREEN)
Android: the call stops soon after the screen locksSome phones' battery savers stop background work: ask the customer to allow the app in battery settings
The call connects but the agent does not know why it calledSend reason (and variables) in the push, and use {call_reason} in the prompt
Every call failed at onceThe key is wrong, or app:// is missing from the agent's Allowed domains
Calls never use WebRTC: transport is websocket, with no reasonThe WebRTC module is not in this build (React Native and Flutter: the build switch is off), or callTransport is websocket
transportFallbackReason is dashboard (Android), or every install is on the WebSocketThe agent's In-app call audio is Standard. It applies from each app's next start
ice_timeout on some networksThose networks block WebRTC. The calls still connect, on the WebSocket. If most of your customers' networks do, choose Standard
Some test calls fall back with mint_failedA burst of calls from one network — more than about 10 a minute — is limited; the calls still connect, on the WebSocket

React Native and Flutter

React Native and Flutter apps get the same native call engine as the iOS and Android SDKs: the same ring, call screens, CallKit and VoIP pushes, the same push from your server and the same outcomes. Your Dart or JavaScript configures it, starts calls and answers the agent's actions.

Both are native code, so adding them needs a new store build, not an over-the-air update. On iOS, add the capabilities and Info.plist keys from iOS, step 2 — with Expo, the calls package's config plugin adds them: "plugins": ["@graineai/inapp-react-native-calls"] in app.json. On Android there is nothing to add to your manifest. Both ask for the permissions with requestCallPermissions() and say what is missing with callSetupProblems() (above).

WebRTC is a build switch in both: ENV["GRAINE_WEBRTC"] = "1" at the top of ios/Podfile (then pod install) and graineWebRtc=true in android/gradle.properties — or GRAINE_WEBRTC=1 in the build's environment on a build server. Then calls in both directions carry their audio over WebRTC (callTransport in the options chooses; transport in the call's state says which ran). Calls over WebRTC

A call screen of your own should treat ended and idle alike: on Android the state can go straight to idle from ringing, connecting or active, and the outcome says how the call ended. A call ends a conversation in the assistant bar, and the bar waits until it is over — in React Native once you attach the in-app SDK's client (0.40.2 or later), below.

Who draws the call while your app is open is callScreen. "sdk" (the default) shows the SDK's call screen: the full-screen call activity on Android, and a full-screen view over your app on iOS. "app" leaves it to your own screen, drawn from the call's state. A locked iPhone always shows the system's call screen.

React Native

npm install @graineai/inapp-react-native-calls
cd ios && pod install

Android pushes reach the engine by itself. Add Firebase to the Android app. With Expo, name your google-services.json in the app config (android.googleServicesFile) and add the package's plugin — it brings Firebase Cloud Messaging. In a bare app, add google-services.json and the Google services Gradle plugin, and graineFirebaseMessaging=true to android/gradle.properties. The package's own messaging service then rings each call push, even when the app was closed, and the engine reads the push token at launch — no JavaScript.

Already use @react-native-firebase/messaging? Its messaging service receives every message instead, so hand the engine its pushes first — in index.js, and while the app is open — and refreshed tokens:

// index.js — before AppRegistry.registerComponent
import messaging from "@react-native-firebase/messaging";
import { GraineCalls } from "@graineai/inapp-react-native-calls";
 
messaging().setBackgroundMessageHandler(async (message) => {
  if (await GraineCalls.handlePush(message.data ?? {})) return;   // a Graine call: it rings
  // …your other pushes
});
messaging().onMessage((m) => GraineCalls.handlePush(m.data ?? {}));                    // app open
messaging().onTokenRefresh((token) => GraineCalls.registerPushToken({ token }));

Configure it once, at launch. The options are kept natively, so a VoIP push that wakes a closed iPhone rings before any JavaScript has run.

import { GraineCalls } from "@graineai/inapp-react-native-calls";
 
export async function setUpCalls() {
  await GraineCalls.configure({
    publishableKey: "pk_live_…",
    displayName: "Acme Assistant",   // the ring and the call screen
  });
  GraineCalls.enableVoIPPushes((token) => myServer.saveVoipToken(token));   // iOS; null when invalidated
  GraineCalls.onOutcome((o) => myServer.reportCall(o.callId, o.outcome)); // answered · declined · missed · ended · failed
}

Give the call your screen context and actions. With the in-app SDK (@graineai/inapp-react-native 0.40.2+), attach its client once inside GraineProvider: who the customer is, your prompt variables, the current screen and your useGraineAction handlers reach the call and stay in step. A call also ends a conversation in the bar, and the bar waits until the call is over.

import { useEffect } from "react";
import { useGraineClient } from "@graineai/inapp-react-native";
import { GraineCalls } from "@graineai/inapp-react-native-calls";
 
export function CallsBridge() {
  const client = useGraineClient();
  useEffect(() => GraineCalls.attach(client), [client]);
  return null;
}

The customer calls the agent from your call button:

const id = await GraineCalls.startCall({ reason: "card_blocked" });   // null while another call is live

To offer a call from the assistant bar, ride a button on it: accessory={<GraineBarAction label="Call Asha" onPress={…} />} on GraineVoiceBar draws one in the brand's design (GraineBarAction).

With callScreen: "app", draw the call from useGraineCall() — state, callId, direction, reason, callerName, muted, speakerOn, agentSpeaking, caption, connectedAt and error — with answer(), decline(), end(), setMuted() and setSpeaker().

Flutter

dependencies:
  graine_inapp: ^0.7.0

Android pushes reach the SDK by itself. Add Firebase to the Android app: google-services.json in android/app, the Google services Gradle plugin, and Firebase Cloud Messaging in android/app/build.gradle.kts:

dependencies {
    implementation(platform("com.google.firebase:firebase-bom:<latest>"))
    implementation("com.google.firebase:firebase-messaging")
}

The SDK's own messaging service then rings each call push natively, even when the app was closed, and the SDK reads the push token at launch — no Dart.

Already use firebase_messaging? Its messaging service receives every message instead, so hand the SDK its pushes first, and refreshed tokens:

@pragma('vm:entry-point')
Future<void> onBackgroundPush(RemoteMessage message) async {
  await GraineCalls.instance.handlePush(message.data);   // rings, even when the app was closed
}
 
// in main(), after Firebase.initializeApp():
FirebaseMessaging.onBackgroundMessage(onBackgroundPush);
FirebaseMessaging.onMessage.listen((m) => GraineCalls.instance.handlePush(m.data));   // app open
FirebaseMessaging.instance.onTokenRefresh.listen(GraineCalls.instance.registerPushToken);
import 'package:graine_inapp/graine_inapp.dart';
 
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  GraineInApp.init(const GraineConfig(baseUrl: 'https://www.graine.ai', publishableKey: 'pk_live_…'));
  final calls = GraineCalls.instance;
  await calls.configure(const GraineCallsOptions(
    publishableKey: 'pk_live_…',
    displayName: 'Acme Assistant',   // the ring and the call screen
  ));
  calls.enableVoIPPushes((token) => myServer.saveVoipToken(token));   // iOS; null when invalidated
  calls.attach();                                                      // your screen context and actions
  calls.outcomes.listen((o) => myServer.reportCall(o.callId, o.wire));
  runApp(const MyApp());
}

attach() hands the call what your app already tells the agent through GraineInApp — identify, setVariables, setScreen and registerAction — and keeps it in step. The customer calls the agent with GraineCalls.instance.startCall(reason: 'card_blocked'), which returns the call's id, or null while another call is live.

With callScreen: GraineCallScreen.app, draw the call yourself: GraineCalls.instance is a ValueListenable<GraineCallSnapshot>, so a ValueListenableBuilder redraws your screen as the call moves, with answer(), decline(), end(), setMuted() and setSpeaker().

Talking with the screen off

On the native call engine, the call keeps going with the screen off by itself. An app that cannot add a native module (an Expo Go build, a release shipped only over the air) can still hold a call on the in-app engine, with two differences: the ring and the call screen are yours (a notification; not CallKit, because a web view cannot keep its microphone during a CallKit call), and the call must be told to outlive the screen — endOnBackground={false} on GraineProvider (React Native, honoured for calls from 0.40.1) or GraineConfig(endOnBackground: false) (Flutter), with a microphone foreground service on Android. On iOS, whether a web view keeps its microphone in the background has changed between iOS versions: test it on the versions you support, and keep endOnBackground on where it mutes, so the call ends cleanly.

On this page