Any app (WebView)
Flutter, native Android, native iOS, or any host that can show a WebView — the same agent, through one page and six messages.
If your app is not React Native, you do not need an SDK for your platform. You need a WebView and six messages.
The React Native SDK is itself a thin wrapper around this: it renders a 1×1 invisible WebView pointed at your hosted agent page, and relays messages. Doing that yourself is roughly forty lines in any language.
The page does capture, playback, jitter buffering, mu-law decoding and echo suppression. Your host draws the UI and answers actions. Nothing about audio is your problem, and audio fixes reach you without an app release.
The page
Load it in a WebView with JavaScript enabled, inline media playback allowed, and media playback not requiring a user gesture — nothing in a 1×1 view can be tapped, so a gesture requirement can never be satisfied and the agent will never make a sound.
Give the view a real box — 1×1 with opacity 0, not display:none or zero height.
A view with no box can have its media suspended by the platform, which kills the
call the moment your UI collapses.
The six messages
Everything is window.postMessage in, and your platform's WebView message
channel out.
Host → page
| Message | Payload | When |
|---|---|---|
graine:start-call | — | Begin talking |
graine:end-call | — | Hang up. Send before you destroy the view |
graine:set-muted | { muted } | Mute or unmute the microphone |
graine:app-context | { context, availableActions, seq } | The screen changed |
graine:app-action-result | { actionId, status, message, data } | You finished an action |
graine:app-event | { name, data } | Something happened worth speaking about |
Page → host
| Message | Payload | Meaning |
|---|---|---|
graine:ready | — | The page has mounted. Start the call from here |
graine:call | { connected, connecting } | Call state for your UI |
graine:muted | { muted } | Mute state, including changes the page made itself |
graine:caption | { role, text, live } | The line being said right now |
graine:app-action | { action } | The agent wants you to do something |
graine:mic-denied | { reason } | Microphone refused or missing — show a prompt |
Wait for graine:ready before sending anything. onLoad fires before the page
has mounted, and messages sent then are dropped — which presents as an agent
that greets and then waits forever.
Flutter
The channel is named ReactNativeWebView because that is the name the page
posts to. It is not React Native specific — it is just the handle, and naming
yours the same is one line cheaper than changing the page.
Native Android
Manifest needs INTERNET, RECORD_AUDIO and MODIFY_AUDIO_SETTINGS.
Native iOS
Info.plist needs NSMicrophoneUsageDescription. On iOS also set your audio
session to allow recording and to play in silent mode — without the second, the
agent is silent whenever the ringer switch is off, which is most phones and
presents as a broken agent rather than a muted one.
Answering an action
This is the part that matters. The runtime blocks on your answer.
status is ok, refused or error. Say what actually happened: the agent
repeats your message to the customer, so ok on a change that did not apply
tells them their form is complete when it is not.
Answer within about five seconds. For anything slower, answer immediately and
report the outcome in your next graine:app-context.
Describing the screen
seq must increase. Frames can overtake each other on a reconnect, and an older
screen applied over a newer one leaves the agent describing somewhere the
customer has left.
status (filled / empty / invalid, plus error) is what turns proactive
help from "need a hand?" into "the PAN isn't being accepted".
availableActions may only narrow what the dashboard declared — an action
invented here is not callable.
Before you ship
- Send
graine:end-callbefore destroying the view. Tearing down the WebView drops the socket unclosed, which files as a lost connection rather than a hangup. - Declare every action in the dashboard under Agent → Embed & Widgets. A handler with no declaration is never offered to the model.
- Add
app://to the agent's allowed domains. The allowlist matches a browserOrigin, and an app sends none.

