Install the SDK
Add the Graine in-app agent to a React Native app — provider, launcher, and the one decision you must make before connecting.
Before you install: two dashboard steps
Both are easy to miss and each produces a confusing failure. Do them first.
1. Give the app its own agent
Clone your website agent rather than pointing the app at it. Actions are declared
per agent, and every surface that agent serves is offered all of them — so a
shared agent hands your marketing site a fill_pan tool it can never run.
Why this matters.
Cloning keeps the prompt, knowledge base and voice; you then change only what differs. Two agents also means two sets of analytics, which is what you want the first time you ask whether the app conversations go better than the web ones.
2. Add app:// to the agent's allowlist
Open Embed & Widgets → Install & domains on the app's agent and add the entry
app://.
A native app is not a web page. It has no Origin header to send, and there is
no header a client could send that we would be willing to trust instead — so the
allowlist cannot identify it the way it identifies a website. app:// is how you
say "this agent may also be reached from a mobile app", and it is an explicit
opt-in rather than a default because an agent reachable without an origin is
reachable from anything that can hold the key.
Skip this and the SDK connects to nothing. The connection is refused with
no_origin, the launcher never renders, and the app looks like the key is
wrong. An empty allowlist refuses everything for the same reason — a new
embed that accepted every origin would let any site on the internet mount your
agent and spend your call credit.
What actually bounds a mobile app is the publishable key, per-key and per-IP rate limits, and a single-use connection ticket. A key inside an APK or IPA is extractable, so treat it as public — see Privacy and security.
Install
One package covers Android and iOS. No native module, no pod install, no
rebuild — the audio path lives in a WebView pointed at a hosted page, so an
upgrade is this plus an over-the-air update.
Do not pin 0.27.0
It imports a native module it does not declare and breaks expo export. Any
later version is fine; what each one changed is in the
changelog.
Wrap your app
navigationRef is the line that makes this an in-app agent rather than a voice
widget: the SDK reads the current route itself, so the agent knows which screen
the customer is on without a call on every screen — including the screen you
add next year when nobody remembers this page.
It must be the same ref you pass to NavigationContainer, and GraineProvider
must wrap it. Without it the agent still talks; it just has no idea where
anyone is.
Get publishableKey from Embed & Widgets on the app's agent — the one you
cloned above, not the website's. Each agent has its own key, allowlist,
appearance and action list.
Colour, title and logo come from the dashboard's Appearance section, not from props, so a brand change reaches every app without a release.
Install react-native-webview alongside the package. It autolinks and needs no
setup code; it is what lets voice work without a native audio module. See
Voice for why.
To tell the agent who the customer is, call useGraineIdentify() after sign-in
rather than passing variables at construction — the provider mounts at launch
and the customer signs in afterwards.
Connect at launch, not on tap
GraineProvider connects when your app starts. Leave it that way.
An agent that only connects when the customer taps the launcher 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 to the customer, no microphone is opened, and no conversation is billed until there is one.
Voice or text
Decide before connecting:
This is not a per-message choice. Voice and text are different sockets, and a text socket has no speech synthesis at all — so an agent connected for text will answer a voice session in writing and never make a sound. Switching later means reconnecting.
See Voice for microphones and audio.
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, not the better one: 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.
The session 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 it would mean asking for the microphone hangs up
the call that asked for it.
Text 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 to the app 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, and close() sends a stop frame before dropping
the socket so the conversation is filed as ended rather than dropped.
Bring your own UI
GraineLauncher is a working default, not a requirement. Everything it draws is
built on the same public hooks you have:
Most teams with a brand to honour replace it. That is the expected path.
Check it works
- Run the app and open the launcher.
- Send "hello". The agent should reply within a second or two.
- Open Call history in the dashboard — the conversation appears there.
If nothing happens, the console carries the reason. Errors are logged loudly for you 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 Install & domains |
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 Install & domains |
| The agent offers to do things the app cannot | 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 in the dashboard to create one |
| Replies in text during a voice session | Connected without voice | Add the voice prop to GraineProvider |

