Your own UI
Draw the agent yourself on any platform. The SDK keeps the call — WebRTC, the microphone, reconnects and recovery — and tells you what to draw.
Every SDK draws a voice bar in the design chosen in the dashboard, and most apps should use it. When your design system needs its own call UI, keep the SDK for the call and draw everything yourself. The two are separate on every platform: the SDK places the call and reports its state, your code decides what the customer sees.
WebRTC is a dashboard setting, not code. Choose High quality (WebRTC) under Agent → Embed & Widgets → Appearance → Setup → Call audio, and every SDK carries the call over WebRTC — your own UI included. If WebRTC cannot connect, the call moves to the standard path by itself; nothing in your UI changes.
What the SDK still does
Drawing your own UI replaces the bar, not the engine. The SDK still:
- carries the audio over WebRTC — Opus, a jitter buffer, the device's own echo canceller — with a relay (TURN) for mobile networks, and falls back to the standard path when WebRTC cannot connect
- asks for the microphone when a call starts without it — in Flutter through
your
requestMicrophone— and reports a refusal - loads the engine ahead of the tap and keeps it registered, so a tap is a call away, not a page load away
- recovers a connection the phone's network dropped while the app sat idle: it checks the line as the customer taps, replaces a dead one at once, tries once more on a fresh connection, and only then switches transport
- holds a call through a short network drop (
reconnecting) and keeps the transcript - carries the screen, your actions and the agent's components, exactly as with the bar
- writes the connection's own rows to Execution Logs — transport, how long the connect took, every recovery
What becomes yours: drawing every state below, the copy, accessibility, and matching the brand's design if you want to (its tokens are available on every platform — see Your design).
The call's state, on every platform
The same call, in each SDK's words. Draw from these; they change as the call does.
React Native — the launcher's api | Android — graine.voice | iOS — GraineInApp.shared.voice | Flutter — GraineInApp.instance.voice | Website — page messages | |
|---|---|---|---|---|---|
| Place, hang up, mute | start() end() setMuted(m) | start() end() setMuted(m) | start() end() setMuted(_:) | start() end() setMuted(m) | send graine:start-call, graine:end-call, graine:set-muted |
| On a call, or placing one | connected, connecting | connected, connecting | connected, connecting | connected, connecting | graine:call { connected, connecting } |
| One word to draw from | phase | phase (CallPhase) | phase (GrainePhase) | from connected, agentSpeaking | from graine:call and graine:caption |
| Where a connect is | connectStage, slowConnect, callFailed | connectStage, slowConnect, callFailed | connectStage, slowConnect, callFailed | connectStage, slowConnect, callFailed | graine:progress { stage } |
| The line | quality, network, reconnecting | quality, online, reconnecting | quality, online, reconnecting | quality, online, reconnecting | graine:quality, graine:network, graine:reconnecting |
| What was said | captions, caption | onCaption(role, text, live) | captions | captions, caption | graine:caption { role, text, live } |
| Microphone refused | micDenied | onMicDenied(reason) | onMicDenied(reason:) | micDenied | graine:mic-denied { reason } |
| A setting must be fixed | error | error | onError(_:) | error | graine:transport with reason: "not_configured" |
| The engine loaded | ready, connectivity, retry() | ready, connectivity, retry() | ready, connectivity, retry() | ready, connectivity, retry() | graine:ready |
| The call is over | onEnded prop | onEnded() | onEnded() | onEnded() | graine:ended |
Names in onX(…) form arrive on a listener — addListener on Android, iOS and
Flutter; the rest are properties you read. connectStage, slowConnect and
callFailed need React Native 0.40.0 or Android, iOS and Flutter 0.5.0.
What to draw, moment by moment
| Moment | What it is | Say |
|---|---|---|
| Not ready | The engine is still loading (ready false). A tap is held and placed when it is | "Connecting…" |
| Placing | connecting | "Connecting…", with a loader — and keep the end button working |
| Taking long | slowConnect: the customer has waited 4 s | "Still connecting…" |
| A fresh line | connectStage is retrying or switching: the first try went unanswered — on mobile data, usually a line the phone's network dropped — and a new one is being made | "Reconnecting…" |
| Could not connect | callFailed: nothing could carry the call | "Couldn’t connect · tap to retry" — your call button starts a new one |
| Microphone refused | micDenied | Why you need it, and the way to the phone's settings. Do not start again on your own |
| Listening, thinking, speaking | phase while connected | A level meter while listening; a loader between the customer's last word and the agent's first |
| Weak line | quality is poor | "Weak connection" — otherwise the customer blames the agent for the network |
| Offline | network / online is offline | "No connection" |
| Dropped, coming back | reconnecting | "Reconnecting…", with the transcript kept |
| Over | onEnded / graine:ended | Close or reset about a second later, so the goodbye finishes playing |
Two things make a call UI feel reliable: something on screen within a tenth of a second of the tap, and words that change when the wait does. A spinner that says "Connecting…" for ten seconds reads as broken even when the call is recovering correctly.
For accessibility, announce state changes — connecting, connected, ended, a refusal — through a live region or the platform's announcement API, not every caption; label the call, mute and end controls by what they do now ("End the call", not "Button"); and stop any looping animation when the device asks for reduced motion.
React Native
Mount the launcher as with the bar, and draw from its render prop instead of
GraineVoiceBar. The provider and the launcher are unchanged.
The full API, including timing and onTransport, is in
React Native.
Android
Mount the engine once and leave out GraineAgentBarView. Draw from
graine.voice, and listen for the moments that are events rather than state.
Listeners are called on the main thread. The rest of the API is in Android → Reference.
iOS
GraineVoice is an ObservableObject: SwiftUI redraws from it. Attach the
engine once a window exists, and add a listener for the moments that are
events.
Add NSMicrophoneUsageDescription to Info.plist. The rest of the API is in
iOS → Reference.
Flutter
GraineEngine still goes once around the navigator — it is the call — but the
bar becomes optional. Rebuild your own from GraineInApp.instance.voice, a
ChangeNotifier.
The microphone is asked for through requestMicrophone in GraineConfig; see
Flutter → Quickstart.
Website
On a website the engine is a page Graine serves in an iframe — the same engine every SDK hosts. Keep it out of sight, drive it with messages, and draw your own button and status. The Graine loader does all of this for you when you want the standard launcher; do it yourself only for your own UI.
Allow your site. Under Agent → Embed & Widgets, add your domain to
Allowed domains (example.com, or .example.com for every subdomain).
Never add Graine's own domain: anyone could then use your key.
Ask which agent answers, then load the engine idle. The session names the call's agent and the audio setting from the dashboard.
Answer graine:need-ticket. The engine is Graine's page, so its requests
carry Graine's address, not yours. When WebRTC needs to know which site it is
on, the engine asks your page; your page mints a ticket — from your own origin,
which is the one Allowed domains checks — and hands it over. Without an answer,
WebRTC refuses and the call runs on the standard path.
Warm it on graine:ready, start it from your button, and draw from the
messages. Check event.origin on every one.
Every message, in both directions, is in
WebView hosts → The messages; a
website's page is the same host on a different window. Your page's screen and
actions travel the same way: graine:app-context in, graine:app-action out.
Calls like WhatsApp
A full-screen call with your agent — the customer calling it, or your agent
ringing the customer — runs on the SDK's native call engine, which brings its own
ring, notifications and call screen. To draw the call screen yourself, set
callScreen: "app" (React Native, Flutter), name your activity in
callActivity (Android) or present your own view (iOS), and draw it from the
call's state. Unlike the bar, a call does not reconnect when the network drops:
it ends. The whole build is In-app calling, and every
field is in the calling reference.
Components and actions
Your own call UI changes nothing about what the agent can do in the app.
- Actions run in the handlers you registered, exactly as with the bar.
- Components need somewhere to appear. The bar draws them for you in React
Native, Android and Flutter; on iOS, and without a bar anywhere, mount the
SDK's component view where they belong —
GraineWidgetin React Native,GraineWidgetViewon Android, iOS and Flutter — or draw them yourself from the widget events. On a website, the engine postsgraine:widget, and your page answers withgraine:widget-result. See Components → Drawing them yourself.
Check it before you ship
- Each state once, on purpose: deny the microphone; turn the network off
mid-call (
reconnecting, then the call returns); place a call on a slow network (Still connecting…); end from both sides. - The record: a test call's Execution Logs show which transport carried it
(
transport: WebRTC), how long the connect took (connect: warm · 0.6s (setup 0.1s, answer 0.5s)), and any recovery on the way (recovery: …). - A website: with your domain in Allowed domains and
graine:need-ticketanswered, the record saystransport: WebRTC. If it saystransport: fell back to WebSocket (not_configured), read themessageof thegraine:transportyour page received: when it says the page around the frame did not vouch for its site, your page did not answer the ticket request.
The whole path, from the dashboard to a monitored launch, is in Going live.

