Start, switch and end a session
When the SDK connects, how the call and the chat take turns, why a session ends when the app is put away, and what to check when nothing appears.
Getting the SDK in for the first time is the Quickstart. This page is what happens around it: when it connects, how long a session lives, and what to look at when nothing shows up.
Two dashboard steps, both easy to miss
- Give the app its own agent. Clone your website agent rather than pointing the app at it — actions are declared per agent and the model is offered all of them on every surface that agent serves. (Why)
- Add
app://to the allowlist. A native app sends noOriginheader, so the allowlist has nothing to match. Without this entry the key is rejected and the launcher renders nothing at all.
Connect at launch, not on tap
GraineProvider connects when your app starts. Leave it that way.
An agent that connects only when the customer taps can never be the one to speak first — and speaking first, at the moment somebody stalls, is most of what an in-app agent is for.
Connecting is cheap and silent: nothing is shown, no microphone is opened, and no conversation is billed until there is one.
The call and the chat
Your agent can be a call, a chat, or both — chosen in Embed & Widgets → Appearance → What visitors get, alongside Opens on (Chat). The SDK reads both, so the same settings that shape your website widget shape your app, without a release.
| Setting | In the app |
|---|---|
| Call and chat | The bar opens on the call and carries a switch to the chat |
| Voice only | The call. No switch is drawn |
| Chat only | The chat. No microphone is opened and no call engine is mounted |
Exactly one of them runs at a time. They are different sockets — a chat has no speech synthesis at all — so the SDK ends the call when the customer moves to the chat, and closes the chat when they move back. Without that the agent answers twice: once aloud and once in writing.
The SDK's bar — GraineVoiceBar on a call, GraineAgentBar for chat alone —
draws the switch for you when the dashboard allows both. Drawing your own, or
opening on the chat for one screen:
The prop chooses among the surfaces the dashboard allows; it cannot add one that
was turned off. An app that hard-codes surface="voice" must not be able to put
a microphone in front of the customers of a business that chose chat only.
autoConnect={false} is the old advice for apps using GraineVoiceLauncher,
and it still works. It is no longer needed: the launcher tells the SDK it owns
the call, and the provider's own socket is opened only for the chat.
The session follows the app
One session per client, and it ends when the app leaves the foreground.
Backgrounding does not unmount a React tree, so without this the WebView stays mounted with the microphone open and the conversation still billing while nobody is listening. iOS is the worse case: it suspends the WebView's audio without telling the socket, so the call survives as a leg that can no longer hear anything and bills for silence.
It governs the call as well as the chat (the voice launcher honours it from 0.40.1; before, a call always ended). Turn it off only when your app keeps the call alive itself: a call screen of its own and, on Android, a microphone foreground service. On iOS, test it on real devices first — whether a web view keeps its microphone once the app leaves the screen varies between iOS versions, and a call that loses it carries on without the customer's voice. See In-app calling → Talking with the screen off.
It ends on background, never on inactive. iOS reports inactive for the
app switcher, a pulled-down notification, and the moment a permission dialog
appears — ending on that would mean asking for the microphone hangs up the call
that asked for it.
Chat sessions reconnect when the app returns. Voice does not auto-resume: a
call is something a person chose to start, and starting one again because they
came back is a microphone opening unasked. connected goes false — draw your UI
from that.
connect() is idempotent, so calling it twice returns the same session rather
than opening a second socket. close() sends a stop frame before dropping the
socket, so the conversation is filed as ended rather than dropped.
Bring your own UI
You rarely need to. The brand's look is chosen in the dashboard — a design, its colour, corners, typeface, launcher and motion — and the SDK's bar draws exactly that on every phone, so a brand team works in the Garage, not in your code. Replace the bar and the design chosen there stops reaching your app.
If you must, everything the SDK draws is built on the same public hooks you
have, and useGraineTheme() hands you the design's tokens to match:
The call's own UI — every state to draw, and the code for React Native, Android, iOS, Flutter and a website — is in Your own UI.
When nothing appears
Errors are logged loudly and shown to nobody: a misconfigured agent renders no launcher at all, rather than a launcher that opens onto an error.
| What you see | Why | Fix |
|---|---|---|
No launcher, no_origin in console | The allowlist has no app:// entry | Add app:// under Embed & Widgets |
No launcher, no_domains_configured | The allowlist is empty, which denies everything | Add app://, plus any web origins that agent serves |
| No launcher, error in console | The key was rejected | Copy the key again from Embed & Widgets |
| The agent offers things the app cannot do | The website agent is being reused | Clone it and give the app its own |
| Launcher, but no reply | The agent has no web-call variant | Re-save the agent to create one |
| Replies in text during a voice session | Connected without voice | Add the voice prop, or use GraineVoiceLauncher |
| No microphone, and no way to get one | The agent is set to chat only | Embed & Widgets → Appearance → What visitors get |
| The switch is missing | The agent is one surface, not both | Same setting — choose "Call and chat" |
| Nothing at all, no console output | onError was not passed | Pass it — this is the only way some failures surface |
A conversation that worked appears in Call history in the dashboard within a few seconds.

