Graine AI

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

https://www.graine.ai/embed/<AGENT_ID>?mode=voice&embed=1&branding=0

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

MessagePayloadWhen
graine:start-callBegin talking
graine:end-callHang 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

MessagePayloadMeaning
graine:readyThe 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

final controller = WebViewController()
  ..setJavaScriptMode(JavaScriptMode.unrestricted)
  ..addJavaScriptChannel('ReactNativeWebView', onMessageReceived: (m) {
    final msg = jsonDecode(m.message);
    switch (msg['type']) {
      case 'graine:ready':
        _post({'type': 'graine:start-call'});
        _pushContext();
        break;
      case 'graine:app-action':
        _runAction(msg['action']);          // then post the result back
        break;
      case 'graine:mic-denied':
        showMicPrompt(msg['reason']);
        break;
    }
  })
  ..loadRequest(Uri.parse('https://www.graine.ai/embed/$agentId?mode=voice&embed=1'));
 
void _post(Map<String, dynamic> m) =>
    controller.runJavaScript('window.postMessage(${jsonEncode(m)}, "*");');

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

webView.settings.javaScriptEnabled = true
webView.settings.mediaPlaybackRequiresUserGesture = false
webView.addJavascriptInterface(object {
    @JavascriptInterface
    fun postMessage(json: String) { /* same switch as above */ }
}, "ReactNativeWebView")
webView.webChromeClient = object : WebChromeClient() {
    // The page asks for the microphone. Your app has already asked the user —
    // granting here avoids a second prompt, which is how people say no.
    override fun onPermissionRequest(request: PermissionRequest) = request.grant(request.resources)
}
webView.loadUrl("https://www.graine.ai/embed/$agentId?mode=voice&embed=1")

Manifest needs INTERNET, RECORD_AUDIO and MODIFY_AUDIO_SETTINGS.

Native iOS

let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
config.mediaTypesRequiringUserActionForPlayback = []
config.userContentController.add(self, name: "ReactNativeWebView")

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.

// in
{ "type": "graine:app-action",
  "action": { "action_id": "a1", "name": "set_tenure", "arguments": { "months": 24 } } }
 
// out — always, and only once
{ "type": "graine:app-action-result",
  "actionId": "a1", "status": "ok", "message": "Tenure set to 24 months." }

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

{ "type": "graine:app-context",
  "seq": 1735820100000,
  "availableActions": ["set_tenure", "open_setting"],
  "context": {
    "screen": "loan_offer",
    "title": "Your offer",
    "fields": [
      { "name": "tenure", "label": "Tenure", "value": "12 months", "status": "filled" },
      { "name": "pan", "label": "PAN", "value": "", "status": "empty" }
    ]
  }
}

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-call before 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 browser Origin, and an app sends none.

Next steps

On this page