React Native
Step-by-step guide to integrate the Graine in-app agent — voice, provider, screen context, actions, events and configuration.
Follow this in order the first time. There is no native setup step — that is the part most in-app voice SDKs spend an afternoon on, and the reason this one does not is explained under Why there is no LiveKit step.
Prerequisites
| Requirement | Notes |
|---|---|
| Node.js | 18+ |
| React Native | 0.68 or higher |
| React | 17 or higher |
| iOS | 13+ |
| Android | API 21+ |
| Navigation | @react-navigation/native — optional, but it is what makes the agent screen-aware without per-screen code |
| Package | @graineai/inapp-react-native |
| Peer install | react-native-webview — autolinks, no setup code |
You will also need a publishable key, from Agent → Embed & Widgets in the dashboard. It is the same key the website embed uses, and it is public by design.
Installation
Then declare microphone permission. It is your app asking, so it is your manifest:
That is the whole native setup. No MainApplication.kt edit, no
AppDelegate.swift edit, no ProGuard rules, no babel plugin ordering, no
clean rebuild of either platform.
Step 1 — Wrap your app
You now have a working voice agent. Everything below makes it useful.
navigationRef must be the same ref you pass to NavigationContainer, and
GraineProvider must wrap it. Without that the SDK cannot observe route
changes, the agent has no idea which screen anyone is on, and launcher
visibility rules will not match what you configured.
Colour, title and logo come from the dashboard's Appearance section, not from props — so a brand change reaches every app without a release.
Or let the provider mount the button
If you do not want to draw anything yourself, hand the provider the WebView and it mounts a floating button in the corner the dashboard configures — accent and logo from Embed & Widgets, shown only where your visibility rules allow, opening the conversation on tap and folding back when the call ends:
If your app already uses React Navigation's native stack, pass
overlay: FullWindowOverlay (from react-native-screens, which you already
have) so the button draws above native screens and modals. The SDK never
imports it — a native module must come from your app, not from a JS package
you can update over the air:
With fab set, every tap in the app is tracked automatically — no
per-button wrapping — while a screen has been reported and the button may
show. Taps reach the agent as context, feed the rage-tap rule, and appear in
Call History as "tapped the screen". Turn it on without the button with
captureTaps, or off with captureTaps={false}. Named taps via
useGraineTap stay the precise version.
Step 2 — Register the user
Traits become prompt variables, so a prompt can say greet {name} or
the customer is on the {plan} plan. Everything is masked before it leaves the
device.
Nothing else is gated on this. Skip it and every other feature still works — the agent just does not know their name.
Step 3 — Report what is on the screen
The route gives the agent a screen name. This gives it the contents.
status is what makes proactive help work. The SDK runs a 45-second timer
per screen; when it fires it names the field that is invalid or empty, so
the agent opens with "the PAN isn't being accepted" rather than "need a hand?".
Without status it can only manage the second one.
Report the real value. Masking runs on the way out and again on arrival — a screen that pre-redacts only blinds the agent.
Step 4 — Let the agent act
Two halves, and both are required.
In your app, a handler:
In the dashboard, the declaration: Agent → Embed & Widgets → "What this agent may do in your app". The name must match exactly. Saving pushes it to the agent immediately.
Neither half works alone. A handler with no declaration is dead code; a declaration with no handler is worse, because the agent promises it and then answers "this app build does not implement it".
The runtime blocks on your handler, so return promptly. Anything slow should return immediately and report completion by updating the screen.
Step 5 — Product events that do not interrupt
Lands in the context; does not make the agent speak. When the customer asks "why was my card refused", the agent already knows. Last 10 are kept.
Step 6 — React to the conversation
Fires once, in order, at the moment. conversation_ended carries a reason so a
customer hanging up and a socket dying are not the same number in your funnel.
action_completed carries the app's status — success and refused must be
different numbers — and friction_detected is the SDK deciding the customer
looks stuck (see Friction detection).
Configuration options
GraineProvider props
| Prop | Type | Required | Description |
|---|---|---|---|
children | ReactNode | Yes | Your app, usually NavigationContainer and below |
baseUrl | string | Yes | https://www.graine.ai unless self-hosted |
publishableKey | string | Yes | From Agent → Embed & Widgets. This selects the agent — one key per agent; there is no agentId prop. |
navigationRef | ref | Recommended | Same ref as NavigationContainer, for route tracking |
includeScreens | string[] | No | Route names where the launcher may appear; omit for all |
launcherDelayMs | number | No | Delay before showing the launcher on an eligible screen |
visibility | object | No | Groups, continuity, per-group delays, insets — see Launcher visibility |
autoConnect | boolean | No | Connect at launch. Required for proactive help; default true |
voice | boolean | No | Only for the bring-your-own-audio path, not for GraineVoiceLauncher |
onProactive | (text) => void | No | The agent spoke first — draw your own nudge |
appVersion | string | Recommended | Which build the conversation happened in; lands on the call record |
friction | FrictionConfig | No | This app's friction rules, layered over the dashboard's — see Friction detection |
fab | { webView, position?, size?, accent?, label?, overlay?, prewarm? } | No | Mount the floating button for you. position is bottom-right (default) or bottom-left. prewarm (default true) loads the voice engine before the tap, so connecting takes ~400ms instead of ~6s. |
captureTaps | boolean | No | Track every tap automatically. Default on when fab is set. |
GraineVoiceLauncher props
| Prop | Type | Required | Description |
|---|---|---|---|
webView | component | Yes | WebView from react-native-webview |
autoStart | boolean | No | Start the call as soon as the page is ready |
onCaption | (c) => void | No | Each line as it is said |
onCallState | (s) => void | No | { connected, connecting } |
onEnded | () => void | No | The call is over. Fires once |
onMicDenied | (reason) => void | No | The microphone was refused or absent |
onError | (message) => void | No | Voice could not be set up — key rejected, agent not enabled for apps |
transport | "ws" | "rtc" | No | Which transport carries audio; omit and the dashboard decides |
children | function | No | Receives the voice API — see below |
active | boolean | No | Default true. Mount early with false so the engine page loads before the customer needs it (it registers nothing while idle); flip to true when your UI opens — with autoStart that places the call, without it the registration is warmed so the tap is instant. Back to false ends a live call. 0.27.5 (unconditional hangup since 0.27.6) |
The voice API (children)
| Member | |
|---|---|
connected / connecting / muted | Call state |
ready | A call can be started right now |
start() / end() / setMuted(m) | Place, hang up, mute |
captions / caption | The transcript, and the current turn — 0.28.0 |
captionsOn / setCaptionsOn(on) | The CC switch, defaulting on — 0.28.0 |
clearCaptions() | Forget the transcript without ending the call — 0.28.0 |
Hooks
| Hook | For |
|---|---|
useGraineAgent() | Connection state, transcript, mute, send a turn |
useGraineIdentify() | Who the customer is |
useGraineScreen() | What is on the screen |
useGraineField() | One input, as it changes — value, error, status |
useGraineAction() | One handler per declared action |
useGraineTrack() | Product events that inform but do not interrupt |
useGraineEvents() | Conversation lifecycle, action outcomes, friction signals |
useGraineTap() | Wrap an onPress so the tap is tracked (and rage taps detected) |
useGraineReady() | Whether the session has resolved |
useGraineInit() | The same, as { isInitialized, error } |
useGraineVoice() | Only if you bring your own native audio |
Without React Navigation
Omit navigationRef and call useGraineScreen yourself on each screen — it
carries screen as well as the fields, so nothing is lost except the automatic
part. Everything else is unchanged.
How long connecting takes
Four waits sit between the tap and the agent's first word. Three are paid before the tap on a current app; the fourth is bounded.
| Wait | Paid | Typical |
|---|---|---|
| Engine page load in the WebView | Before the tap — GraineFab mounts the engine on open; a custom bar mounts GraineVoiceLauncher at launch with active={false} and flips it on open | ~2–4s, hidden |
| Credential mint + SIP REGISTER | Before the tap with the FAB's prewarm; at the tap on a bar that auto-starts | ~1s |
| Microphone prompt | At the tap, first time only | 0 once granted |
| INVITE → answered | At the tap | ~0.3s |
The dial is bounded, not left to the browser: WebRTC gathers network candidates before dialling, and Chrome does not report gathering complete until STUN has been tried on every interface — a VPN, link-local IPv6, a cellular path — which can hold the INVITE for forty seconds. The engine dials the moment a public candidate exists, or 1.5s after gathering starts.
If "Connecting…" lasts more than about two seconds on a current app, the engine is being mounted at the tap. Mount it earlier.
Troubleshooting
| Symptom | What to check |
|---|---|
| Agent talks but does not know the screen | navigationRef must be the same ref as on NavigationContainer, and GraineProvider must wrap it |
| "Sorry, I can't help with that here" | Action not declared in the dashboard, name does not match useGraineAction exactly, or the screen did not list it. The client may only narrow the declared list, never extend it |
| No sound on iPhone | The ringer switch. Set playsInSilentModeIOS: true and allowsRecordingIOS: true while a call is live |
| Launcher never appears | Current route is not in includeScreens or any group, or a delay is still running. Omit includeScreens to test |
| Agent interrupts itself after a word | Echo. Lower the speaker volume, or use earphones to confirm |
| Agent mishears everything | Bring-your-own-audio only: the capture rate is not 16 kHz. A wrong rate produces confident nonsense, not an error |
Best practices
- One provider, at the root. Not per screen.
- Call
identify()as soon as you have a signed-in user, and again on account switch. - Give every reported field a
status. It is the difference between useful proactive help and a generic nudge. - Return a message from every action saying what actually happened — the agent repeats it, so a vague result becomes a vague promise.
- Hide the launcher on sensitive flows (auth, payment entry) with
includeScreensor groups. - Plan visibility early. Retrofitting group rules is harder than wiring them during integration — see Launcher visibility.
- Match route names exactly. They are case-sensitive.
Why there is no LiveKit step
Other in-app agent SDKs run voice over LiveKit, which means native initialisation
in MainApplication.kt and AppDelegate.swift, a pod install, a Lottie
dependency, ProGuard rules and a clean rebuild of both platforms. Their own
documentation leads its troubleshooting with the error you get when one of those
is missed.
We put the audio in a WebView instead — the same hosted page the web widget uses, which already does capture, playback, buffering, decoding and echo suppression.
The trade is real and worth stating plainly. WebRTC gets acoustic echo cancellation from the platform's audio stack; our level-based guard approximates it and gives up when the speaker is very loud. In exchange, the audio path — where nearly every fix lands — ships over the air rather than through the app stores, and integration is an npm install rather than an afternoon in Xcode.
Next steps
Launcher visibility
Route groups, delays, continuity and insets.
Actions
What the agent may do inside your app.
Privacy and security
What leaves the device.
- What lands in Call History — where every screen, tap, signal and action shows up

