React Native
The React Native SDK in full — install, every prop, hook and option, calls, your design, and what to check when something behaves oddly.
Integrating for the first time? Quickstart is four steps. This page is the surface area: every prop, every hook, and the troubleshooting table.
Install
react-native-webview autolinks. There is no native code to write — no
MainApplication.kt edit, no AppDelegate.swift edit, no pod install beyond
the usual, no ProGuard rule. The app does declare the microphone, because this
package carries no manifest of its own: RECORD_AUDIO and
MODIFY_AUDIO_SETTINGS in AndroidManifest.xml, and
NSMicrophoneUsageDescription in Info.plist — with Expo, android.permissions
and ios.infoPlist in app.json
(Voice → Microphone permission).
Expo SDK 50+ works in a development build; Expo Go cannot load
react-native-webview, so voice will not run there.
React Native 0.71+, React 18+.
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 |
surface | "voice" | "chat" | No | Open on the call or the chat, where the dashboard allows both. It cannot add a surface that was turned off. |
onSurfaceChange | (s) => void | No | The customer moved between the call and the chat |
onProactive | (text) => void | No | The agent spoke first — draw your own nudge |
endOnBackground | boolean | No | End the session — and, from 0.40.1, the call — two seconds after the app leaves the foreground. Default true. Turn it off only when your app keeps the call alive itself; see In-app calling |
appVersion | string | Recommended | Which build the conversation happened in; lands on the call record |
variables | Record<string, string> | No | Prompt variables your app knows at launch, the same for every user. The signed-in user's details go in setVariables — Send your user's details to the agent |
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. |
loadFont | (fonts) => Promise | Recommended | expo-font's Font.loadAsync, as it is. The SDK then loads the design's typeface itself — only the faces the design uses, from Graine — and redraws when they arrive. Without it, the system font. 0.38.0 |
svg | { Svg, Path } | Recommended | react-native-svg's two components. The SDK then draws its icons in the design's icon style; without it, plain glyphs. 0.38.0 |
fonts | Record<string, string> | No | Families your app already bundles, by the dashboard's name → your registered fontFamily. Yours win over a download |
chatSize | "compact" | "large" | "full" | No | How big the chat window opens, over the dashboard's choice; client.openChat({ size }) wins for one open — Window size. 0.41.0 |
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 |
timing | { stallMs, bootMs, hardCapMs, retryDelaysMs, endWatchdogMs, slowConnectMs } | No | Silence before a load is abandoned, a downloaded page's grace to say ready, the ceiling on one attempt, the reload ladder; slowConnectMs (default 4000) is when a connect becomes slowConnect. A load still arriving is never abandoned |
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, unconditionally. |
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 |
captionsOn / setCaptionsOn(on) | The CC switch, defaulting on |
clearCaptions() | Forget the transcript without ending the call |
phase | idle · connecting · reconnecting · thinking · speaking · listening — one thing to draw from |
connectivity / retry() | Whether the page itself loaded, and a way to try again |
quality | The connection in one word: excellent · good · poor · lost · unknown |
network | online · offline · unknown, from the engine's own view of the device |
reconnecting | The call was interrupted by the network and is being re-established |
error | Set when the call cannot work until something is fixed — a key or agent that is not set up for in-app calls. 0.38.0 |
micDenied | The customer refused the microphone on the last start. 0.38.0 |
connectStage | Where a call being placed is: registering · dialling · retrying · switching, or null. retrying is a fresh connection after an unanswered first try — on mobile data, usually a connection the phone dropped while idle. 0.40.0 |
slowConnect | The customer has been waiting on a connect for timing.slowConnectMs. Say so ("Still connecting…") rather than spin. 0.40.0 |
callFailed | The last start could not be connected on either transport. start() tries again. 0.40.0 |
GraineVoiceBar props
The call's bar, drawn by the SDK in the brand's design — the dashboard Garage's
in-app preview, for real. Mount it in GraineVoiceLauncher's render prop. There
is nothing about its look to pass: the design, typeface, colours, heading, logo
and loader all come from the dashboard. 0.38.0
| Prop | Type | Description |
|---|---|---|
api | voice API | What the launcher's render prop hands you. Required |
onClose | () => void | After a hang-up or a close, once the goodbye has left the speaker |
bottomInset | number | How far above the bottom edge it sits, clear of your tab bar. Passed, it wins; otherwise the per-flow inset, then 96 |
draggable | boolean | Let the customer drag it up and down. Default true |
placement / onPlacementChange | { bottomOffset } | A position your app persisted, to restore / the customer let go somewhere new — persist it |
name | string | What spoken labels call the agent ("Start talking to Asha"). The name on the bar is always the dashboard's heading |
accessory | ReactNode | Anything that rides on the bar, directly above it — a button that moves the conversation to a call, say. It moves with the bar and is not clipped by it; GraineBarAction (below) draws one in the design. 0.40.2 |
GraineBarAction
A button for the bar's accessory, drawn in the brand's design — the accent,
the corners, the typeface and the icon style the dashboard chose — so your app
decides nothing about how it looks, as with the bar itself. 0.40.2
| Prop | Type | Description |
|---|---|---|
label | string | What it says, and what a screen reader reads. Required |
icon | "call" or "chat" | The SDK icon in front of the label. Default "call" |
onPress | () => void | What a tap does. Required |
It rises above the keyboard on its own, draws any component the agent sends above the caption, and switches between the call and the chat when the dashboard allows both.
GraineLauncherButton props
The design's launcher on its own — its shape, glyph or logo, shadow or hard
offset — for an app that opens GraineVoiceBar itself rather than handing the
flow to fab. 0.38.0
| Prop | Type | Description |
|---|---|---|
onPress | () => void | Open your bar. Required |
style | ViewStyle | Where it sits, e.g. { position: "absolute", right: 16, bottom: 24 } |
label | string | What a screen reader says. Default "Talk to" and the heading |
size | number | Default 56 |
GraineAgentBar props
The bar for a chat-first integration on the provider's own socket (for a call,
use GraineVoiceBar in GraineVoiceLauncher). Everything here is optional;
the dashboard's design is the source and a prop is an override.
| Prop | Type | Description |
|---|---|---|
name / accent / avatarUrl | string | Override what Appearance set |
bottomInset | number | Where it sits with no per-flow inset (default 96) |
hidden | boolean | Hide it without unmounting the provider |
captionsOn | boolean | Start with captions on |
scheme / theme | "auto" | "light" | "dark" / Partial<GraineBarTheme> | Palette |
draggable | boolean | Let the customer drag the bar up and down — as one unit with its panel and any component. Default true |
placement | { bottomOffset } | A position your app persisted, to restore |
onPlacementChange | ({ bottomOffset }) => void | The customer let go somewhere new — persist it |
The bar rises above the keyboard on its own, from wherever the customer left it.
Hooks
| Hook | For |
|---|---|
useGraineAgent() | Connection state, transcript, mute, send a turn. failed — the chat gave up (its first connect failed, or Graine ended or refused the conversation; never a network drop) — with retry() to connect again, and clearChat() to empty the chat on screen (only the screen: the conversation goes on). 0.43.0 |
useGraineSurface() | Which surface is open — the call or the chat — and how to move |
useGraineVariables() | Set prompt variables from the app's own data while it runs — the user's name, age, plan, under the names the agent's prompt reads: Send your user's details to the agent |
useGraineConnection() | The connection's quality and whether the device is online |
useGraineTheme() | The brand's design as tokens — colours, type, corners, spacing, shadows — for the device's light or dark, for anything you draw beside the agent |
useKeyboardLift() | Pixels to add to a bottom-anchored bar's bottom while the keyboard is up — measured, right on iOS and both Android modes |
useDraggablePlacement({ baseBottom, initialOffset, onChange }) | Let the customer drag your own bar up and down: spread panHandlers and onLayout on its root, add offset to its bottom. The SDK owns the gesture, the bounds and the tap-versus-drag rule |
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 |
A component the agent draws is useGraineWidget()'s; client.submitWidget(widgetId, data, summary) answers one from your own UI, and client.presentWidget(frame) /
client.presentPendingWidget(hint) draw one (or its loader) yourself — a preview,
a test. 0.43.0 — Components
Calls like WhatsApp
A call that rings the customer's phone — CallKit and VoIP pushes on iOS, a ringing call notification and call screen on Android — and goes on with the screen off runs on the native call engine, in its own package:
It carries native code, so it needs a new store build; this SDK stays
over-the-air. The pushes, the iOS capabilities, attach(client) and your
server's side are in In-app calling → React Native.
Calls over WebRTC (optional)
Calls — the customer calling the agent, and a call Graine rang — carry their audio over a WebSocket unless you build with WebRTC. Turn it on per platform, then rebuild the app (it is native code):
On a build server (EAS, CI), GRAINE_WEBRTC=1 in the build's environment turns
it on for both. Nothing else changes — your code, the ring, the call screen
and the outcomes stay the same. Calls package 0.4.0.
- What the customer notices: a call that holds up better on mobile data — a jitter buffer, loss concealment, the phone's own echo canceller. Over WebRTC the agent's voice arrives at telephone bandwidth today.
- When WebRTC cannot carry a call, it continues on the WebSocket inside the
same ring and call screen: no second ring, no error.
useGraineCall()says which one carried it —transport("webrtc"or"websocket") andtransportFallbackReason. - Choose with
GraineCalls.configure({ callTransport }):"auto"(the default — WebRTC in a build that has it),"websocket"or"webrtc". The agent's In-app call audio setting (Calls & scheduling) — WebRTC (recommended) or Standard — can move every install's calls to the WebSocket from their next start, without a release. - Size: about 12 MB installed on an iPhone, about 7.4 MB on a 64-bit Android phone — a prebuilt WebRTC library whose symbols are renamed, so it never clashes with another copy of WebRTC in your app (a video-calling SDK, say). CocoaPods installs one version of it: a library that pins it at another version cannot be installed beside it.
- Android adds
ACCESS_NETWORK_STATEto your manifest (a normal permission: nothing is asked); the R8 rules ship with the package. Licences: WebRTC is BSD 3-Clause with Google's patent grant — list it in your app's open-source licences.
More: Calls over WebRTC.
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.
Your design, in your app
The design chosen in the dashboard (Agent → Embed & Widgets → Appearance) is drawn by the SDK itself — the voice bar, the caption, the chat, the launcher and every component — exactly as the dashboard's preview shows it. Change the design or the typeface there and every phone follows the next time your app starts; nothing in your app names a colour or a font.
Mount the SDK's bar inside the voice launcher, and hand the provider the two platform abilities it cannot import itself — registering a font, and drawing a vector — once:
- Typefaces. With
loadFont, the SDK fetches the faces the design draws with — only those, from Graine — and redraws when they arrive. Until then, and if one never arrives, it draws the system font. A family you already bundle can be named infonts({ Manrope: "Manrope-Regular" }); yours wins. - Icons. With
svg, the microphone, hang-up, send and chat icons are drawn in the design's icon style. Without it, plain glyphs. - Your own UI.
useGraineTheme()returns the resolved tokens — colours, type, corners, spacing, shadows — for anything you draw beside the agent.
How long connecting takes
Four things happen between the tap and the agent's first word. On a current app, three of them are done before the tap.
| Step | When | Typical |
|---|---|---|
| The engine loads | Before the tap — GraineFab loads it when it opens; a custom bar mounts GraineVoiceLauncher at launch with active={false} and turns it on when it opens | 2–4 s, out of sight |
| The line is prepared | Before the tap — at launch, again before it expires, and whenever the app returns to the foreground (0.36.0); at the tap on a bar that starts by itself | about 1 s, out of sight |
| The microphone prompt | At the first tap only, alongside everything else the call is waiting for | none once granted |
| The call connects | At the tap | about 0.2 s |
The engine connects on the first network route that works, within 1.5 s — it never waits for a VPN or an idle mobile interface to be tried, which on its own can take forty seconds.
If "Connecting…" lasts more than about two seconds on a current app, the engine is being loaded 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 — with the field the agent's Who is the user? setting names (user_idby default), so the agent remembers them across chats, Talk and calls: Memory. - Send the user's details under the prompt's own names with
setVariables, and clear them withsetVariables(null)on sign-out — Send your user's details to the agent. - 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 native setup
The SDK needs no edit to MainApplication.kt or AppDelegate.swift and has no
native module of its own: beyond react-native-webview and the microphone's two
declarations, nothing native changes. The audio runs in the same hosted engine
as the web widget, which already does capture, playback, buffering, decoding and
echo suppression — so an improvement to the audio reaches your app without an
app-store release, and updating the SDK is an npm install.
The trade, stated plainly: a WebRTC call uses the phone's own echo canceller; on the fallback path a level-based guard stands in for it, and gives up when the speaker is very loud. Earphones or a lower volume settle it.

