How calls work
The architecture of in-app calls — what runs where, the two engines, each call step by step, its states, how a ring reaches a locked phone on iOS and Android, audio over WebRTC or the WebSocket, networks, and what data goes where.
This page explains the moving parts behind In-app calling, for the engineers who build on it and run it. Set-up steps are on that page; the server side is in Your calling server.
What runs where
The parts of an agent's call. The numbers follow one call; a call the customer places starts at your app's startCall instead of a push.
- Your server decides to call and sends a push —
type: agent_call, yourcall_id, areason— to the customer's device through Apple's push service (APNs) or Firebase Cloud Messaging (FCM). - The push wakes your app and the SDK rings, even when the app was closed and the phone locked.
- The customer answers. The SDK connects them to your agent: audio both ways, with what your app already tells the agent — who the customer is, your prompt variables, the current screen — and the agent's requests for your actions.
- The SDK tells your app what happened:
answered, thenended— ordeclined,missedorfailed— each with thecall_id. - Your app reports it to your server, which keeps the call's record.
- The conversation lands in Call History, under the customer's name: the transcript, the outcome, the actions the agent took.
With your own push server, Graine is in steps 3 and 6 only. Your server and your app own the decision to call, the push, the device tokens and the record — no customer data has to be copied to Graine to ring someone. With hosted ringing, Graine takes steps 1, 2 and 5 as well: a booked call falls due, Graine sends the push with your credentials, and the SDK reports each outcome to Graine instead of your server — while still telling your app.
Two engines, one agent
The assistant bar and a call are two engines in the same SDK, talking to the same agent.
The assistant bar runs on the in-app engine: voice and text, with components drawn on your screens. A call runs on the native call engine, built into the SDK: the call's audio runs in your app's own process, through the platform's microphone, speaker and echo canceller. That is what lets a call ring through CallKit on iOS and a full-screen notification on Android, and keep going with the screen off.
Both talk to the same agent, with the same prompt, and a call carries what your
app already tells the agent. On iOS and Android that happens by itself: who the
customer is, your prompt variables and the current screen go with the call when
it connects, screen changes keep reaching it while it lasts, and the agent's
requests run your registered actions. Variables are read once, at the start, so a
change made during a call reaches the next one. React Native and Flutter hand the
same things to a call through attach().
A call is voice only: components the agent shows are not drawn during a call.
Only one engine runs at a time:
| A call rings | A call is answered or placed | |
|---|---|---|
| iOS | Ends a conversation in the bar | Ends it |
| Android | — | Ends it |
| Flutter | Ends it | Ends it |
| React Native | Ends it, with attach() and the in-app SDK 0.40.2 | Ends it, likewise |
While a call is live, starting the bar is refused with an error — "A call is
already in progress." — on every platform (React Native with attach()).
A call, step by step
The agent calls the customer
An agent's call, from your server's push to the closed record. Solid arrows happen on the call's path; dashed ones are reports.
The outcome reports are the only way your server hears about the call, which is why the record has timers for the reports that never come.
The customer calls the agent
A customer's call: no push, no ring — startCall places it at once.
startCall returns the call's id straight away, or nothing while another call is
live. The agent hears who_called = "customer" and your reason, so one prompt can
handle both directions.
The call's states
Every state a call passes through, and the outcome each ending reports.
| State | Means |
|---|---|
idle | No call |
ringing | An agent's call is ringing on this device (incoming calls only) |
connecting | Answered, or placed by the customer; the line is opening |
active | The agent and the customer can hear each other |
ended | Over. Reported once, then back to idle |
A screen of your own should treat ended and idle alike: on Android a call can
go from active straight to idle, and the outcome says how it ended. The SDK
holds one call at a time on a device. A ring that arrives during a call is
ended at once and reported missed, without touching the call in progress, and
startCall returns nothing while a call is live.
How a ring reaches a locked phone
The path from a push to a connected call, on each platform.
iOS
- A VoIP push wakes your app, or launches it if it was closed. iOS then
requires the app to report a call to CallKit before it finishes handling the
push, and stops delivering VoIP pushes to apps that do not. The SDK reports
every one — even a malformed push, or one that arrives during a call, which it
ends at once. This is why
enableVoIPPushesruns at every launch, indidFinishLaunching: iOS delivers a push only to an app that has registered for it in that launch. - CallKit draws the ring — on the lock screen, and as a banner when the phone is in use — and the call screen while the phone is locked or your app is in the background.
- The system turns the audio on. Under CallKit the SDK does not start audio itself: iOS activates it when the call is answered, and the SDK starts the microphone and speaker at that moment.
- What the ring shows is your configured name, or the push's
caller_name, with the system ringtone unless you name a sound file, and a template icon on the system's call screen if you give one. Calls stay out of the phone's Recents unless you setincludesCallsInRecents. - The phone's settings come first. If Do Not Disturb, a Focus or the block
list stops CallKit from ringing, the SDK reports
missedat once.
Android
- A high-priority FCM data message reaches your app even when it was closed.
The SDK's own messaging service hands its data to
handlePush— or your app's, when it has one of its own (which then receives every message): it callshandlePushfirst. For a call push the SDK takes over and returnstrue. - The SDK posts a call notification with Answer and Decline and a ringtone. On a locked phone it opens the SDK's call screen full screen — from Android 14 that needs the full-screen-intent permission, without which the ring is a heads-up notification that still rings. On Android 12 and later it is the system's call-style notification.
- Notification channels. Your app gets two, Incoming calls and Ongoing calls. A customer who turns Incoming calls off gets no ring.
- Notifications must be allowed. On Android 13 and later a ring is a
notification, so without the notification permission nothing rings — unless
your app is open, when the SDK opens its call screen straight away — and the
call times out as
missed. Ask for it during onboarding; the SDK does not. - The microphone service starts from the call screen. Android does not let an
app start using the microphone from the background, so the SDK starts its
microphone service when the customer answers on the call screen, or when your
app calls
startCall. The service keeps the call going when the screen locks or another app opens, with an ongoing notification and Hang up. - Battery savers. Some manufacturers' battery savers stop background work beyond Android's own rules. If calls drop on a particular phone, the customer may need to exempt your app in its battery settings.
Audio
- Routes. A call starts on the earpiece, like a phone call;
setSpeaker(true)moves it to the loudspeaker. - Headsets. A call goes to a Bluetooth headset or car kit when one is connected, else a wired headset, else the earpiece — as a phone call does — and follows when a headset is switched on or off mid-call. Turning the loudspeaker off brings the call back to the headset. (Android from SDK 0.6.1.)
- Echo. The platform's echo canceller runs on every call. On the WebSocket the SDK adds its own check so the agent does not hear itself on the loudspeaker and interrupt itself; on WebRTC the echo canceller does that alone.
- Mute stops sending the customer's audio; the agent goes on speaking. On iOS it is the system call's mute, so the system's call screen shows it too.
- Interruptions. On iOS, a phone call that arrives during an agent's call lets the customer end one and take the other — an agent's call cannot be put on hold — and if iOS interrupts the audio, the SDK rebuilds it when the interruption ends, or ends the call as failed if it cannot. On Android, a phone call ringing or another app taking the audio puts the call on hold — nothing is sent or played until the audio comes back — and answering the phone call ends the agent's call. A notification sound changes nothing.
WebRTC or the WebSocket
A call's audio travels one of two ways — the same for a call the customer places and a call Graine rang:
| WebRTC | The WebSocket | |
|---|---|---|
| What your app needs | The SDK's WebRTC module, added to the app (from 0.9.0) | Nothing: every build has it |
| A weak or changing network | A jitter buffer and loss concealment keep the agent's voice steady through a moment's loss | A lossy network sounds lossy: the voice stalls, then catches up |
| Echo | The platform's echo canceller | The platform's echo canceller, and the SDK's own check on the loudspeaker |
| The agent's voice | Telephone bandwidth today | Wider on a good network |
| A dropped connection | One chance to recover, then the call ends | The call ends |
Everything else is the same on both: the ring, the call screen, the states, the outcomes, who the customer is, memory, variables, screen context, actions and Call History.
Which one a call takes is decided when it starts — at the tap, or when a ring arrives:
- The agent's In-app call audio setting (agent → Calls & scheduling): Standard keeps every call on the WebSocket, whatever the app asked, from each app's next start. WebRTC (recommended) leaves the choice to the app.
- The app's
callTransport:auto(the default) andwebrtcask for WebRTC;websocketkeeps the WebSocket. - WebRTC needs the module in the app. Without it the call takes the WebSocket.
Prepared early. When a call will use WebRTC, the SDK sets it up while the ring is still showing, or the moment the customer taps Call, so answering connects as fast as before. A declined, missed or expired ring lets go of what was prepared: nothing was ever dialled. On iOS the audio starts only once the system call hands it over, so the agent's first words are never clipped.
When WebRTC cannot carry a call before any audio flows — a network that blocks it, a refusal, no answer — the call continues on the WebSocket inside the same call: no second ring, no new call screen, no error. WebRTC gets 12 seconds in all; the customer sees Connecting… a moment longer. A call the agent cannot take at all — no credit left, the agent switched off — ends with that reason, as it does on the WebSocket. Once the audio flows a call never changes road.
Which one ran is on the call: transport (webrtc or websocket) and, for a
call that tried WebRTC and runs on the WebSocket, transportFallbackReason —
mint_failed, register_failed, no_response, no_answer, ice_timeout or
error (what each means).
Adding the module: Calls over WebRTC.
Networks
- Opening the line must succeed within 15 seconds, or the call is reported
failed. - A dropped connection ends the call. The SDK does not move a call to a new
connection: if the network drops, the call ends —
ended, withfailed— so offer a call back. On WebRTC a connection that drops gets one chance to recover first; if it is not back within about 10 seconds, the call ends. - A call needs a data connection on the phone for its whole length, like any internet call.
What data goes where
| Data | Goes to | When |
|---|---|---|
The push (call_id, reason, variables) | Your server → APNs or FCM → the phone | When you ring |
The push, with hosted ringing (call_id, the call name and the agent's id only) | Graine → APNs or FCM → the phone | When a booked call is due |
| Push tokens | The phone → your server; with hosted ringing, the phone → Graine, stored encrypted | At every launch |
| The call's audio | The phone ↔ your agent, over WebRTC or the WebSocket — encrypted either way | Only while a call is connected |
| Who the customer is, prompt variables, the current screen | The phone → your agent | When the call connects, and as the screen changes |
| Action requests and results | Your agent ↔ your app | During the call |
| Outcomes | The SDK → your app → your server; with hosted ringing, the SDK → Graine as well | As they happen |
| Transcript and outcome | Call History | After the call |
Screen context is masked on the phone before it is sent, exactly as for the
assistant bar, and so are the variables a push or startCall brings — card
numbers, email addresses, phone numbers and other personal numbers. The reason
is sent as it is, which is one more reason to make it an identifier. See
Privacy and security.
Limits
| Calls at once | One per device |
| Ring timeout | 30 seconds unless you set another (at least 1 second on iOS) |
| Opening the line | 15 seconds |
| Setting a call up on WebRTC | 12 seconds, then the call continues on the WebSocket |
| An action's answer | Within 6 seconds, or the agent hears that it did not finish in time |
| A call's variables | Up to 12, who_called and call_reason included; names up to 40 characters, values up to 200, about 1 KB in all — the call's own first, then your app's while they fit |
| Media | Voice only |

