Graine AI

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

RequirementNotes
Node.js18+
React Native0.68 or higher
React17 or higher
iOS13+
AndroidAPI 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 installreact-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

npm install @graineai/inapp-react-native react-native-webview
cd ios && pod install && cd ..     # for react-native-webview only

Then declare microphone permission. It is your app asking, so it is your manifest:

android/app/src/main/AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
ios/YourApp/Info.plist
<key>NSMicrophoneUsageDescription</key>
<string>Used to talk to the in-app assistant.</string>

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

App.tsx
import { useRef } from "react";
import { NavigationContainer } from "@react-navigation/native";
import { WebView } from "react-native-webview";
import {
  GraineProvider,
  GraineVoiceLauncher,
  GraineAgentBar,
} from "@graineai/inapp-react-native";
 
export default function App() {
  const navigationRef = useRef(null);
 
  return (
    <GraineProvider
      baseUrl="https://www.graine.ai"
      publishableKey="pk_live_…"
      navigationRef={navigationRef}
      includeScreens={["Home", "Settings"]}   // omit for every screen
    >
      <NavigationContainer ref={navigationRef}>
        <RootNavigator />
      </NavigationContainer>
 
      <GraineVoiceLauncher webView={WebView} autoStart>
        {() => <GraineAgentBar />}
      </GraineVoiceLauncher>
    </GraineProvider>
  );
}

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:

<GraineProvider baseUrl="https://www.graine.ai" publishableKey="pk_live_…"
  navigationRef={navigationRef} appVersion={version}
  fab={{ webView: WebView }}>
  <NavigationContainer ref={navigationRef}>…</NavigationContainer>
</GraineProvider>

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:

import { FullWindowOverlay } from "react-native-screens";

fab={{ webView: WebView, overlay: Platform.OS === "ios" ? FullWindowOverlay : undefined }}

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

const identify = useGraineIdentify();
 
useEffect(() => {
  if (user) identify({ name: user.name, plan: user.plan });
  else identify(null);          // on sign-out
}, [user, identify]);

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.

useGraineScreen(useMemo(() => ({
  screen: "kyc_documents",
  title: "Upload your documents",
  journey: { name: "KYC", step: 2, of: 4 },
  fields: [
    { name: "pan", label: "PAN", value: pan,
      status: panError ? "invalid" : pan ? "filled" : "empty", error: panError },
  ],
}), [pan, panError]));

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:

useGraineAction("set_tenure", ({ months }) => {
  const n = Number(months);
  if (!TENURES.includes(n))
    return { status: "refused", message: `I can set ${TENURES.join(", ")} months.` };
  setTenure(n);
  return { status: "ok", message: `Tenure set to ${n} months.` };
});

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

const track = useGraineTrack();
track("payment_declined", { reason: "insufficient_funds", amount: 4999 });

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

useGraineEvents((e) => {
  switch (e.type) {
    case "conversation_started": analytics.track("agent_started"); break;
    case "conversation_ended":   analytics.track("agent_ended", { reason: e.reason }); break;
    case "agent_volunteered":    analytics.track("agent_spoke_first"); break;
    case "action_requested":     analytics.track("agent_action", { name: e.name }); break;
    case "action_completed":     analytics.track("agent_action_result", { name: e.name, status: e.status }); break;
    case "friction_detected":    analytics.track("agent_friction", { rule: e.name, screen: e.screen }); break;
    case "mic_denied":           analytics.track("agent_mic_denied", { reason: e.reason }); break;
  }
});

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

PropTypeRequiredDescription
childrenReactNodeYesYour app, usually NavigationContainer and below
baseUrlstringYeshttps://www.graine.ai unless self-hosted
publishableKeystringYesFrom Agent → Embed & Widgets. This selects the agent — one key per agent; there is no agentId prop.
navigationRefrefRecommendedSame ref as NavigationContainer, for route tracking
includeScreensstring[]NoRoute names where the launcher may appear; omit for all
launcherDelayMsnumberNoDelay before showing the launcher on an eligible screen
visibilityobjectNoGroups, continuity, per-group delays, insets — see Launcher visibility
autoConnectbooleanNoConnect at launch. Required for proactive help; default true
voicebooleanNoOnly for the bring-your-own-audio path, not for GraineVoiceLauncher
onProactive(text) => voidNoThe agent spoke first — draw your own nudge
appVersionstringRecommendedWhich build the conversation happened in; lands on the call record
frictionFrictionConfigNoThis app's friction rules, layered over the dashboard's — see Friction detection
fab{ webView, position?, size?, accent?, label?, overlay?, prewarm? }NoMount 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.
captureTapsbooleanNoTrack every tap automatically. Default on when fab is set.

GraineVoiceLauncher props

PropTypeRequiredDescription
webViewcomponentYesWebView from react-native-webview
autoStartbooleanNoStart the call as soon as the page is ready
onCaption(c) => voidNoEach line as it is said
onCallState(s) => voidNo{ connected, connecting }
onEnded() => voidNoThe call is over. Fires once
onMicDenied(reason) => voidNoThe microphone was refused or absent
onError(message) => voidNoVoice could not be set up — key rejected, agent not enabled for apps
transport"ws" | "rtc"NoWhich transport carries audio; omit and the dashboard decides
childrenfunctionNoReceives the voice API — see below
activebooleanNoDefault 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 / mutedCall state
readyA call can be started right now
start() / end() / setMuted(m)Place, hang up, mute
captions / captionThe 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

Captions and hanging up →

Hooks

HookFor
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.

WaitPaidTypical
Engine page load in the WebViewBefore 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 REGISTERBefore the tap with the FAB's prewarm; at the tap on a bar that auto-starts~1s
Microphone promptAt the tap, first time only0 once granted
INVITE → answeredAt 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

SymptomWhat to check
Agent talks but does not know the screennavigationRef 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 iPhoneThe ringer switch. Set playsInSilentModeIOS: true and allowsRecordingIOS: true while a call is live
Launcher never appearsCurrent route is not in includeScreens or any group, or a delay is still running. Omit includeScreens to test
Agent interrupts itself after a wordEcho. Lower the speaker volume, or use earphones to confirm
Agent mishears everythingBring-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 includeScreens or 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