Graine AI

Any other platform

The protocol every Graine SDK speaks — one session call, one hosted page, and the messages in and out — for a host there is no SDK for yet.

There is an SDK for your platform if you build with React Native, Android, iOS or Flutter. Use it: it does everything on this page, plus components, the bar, friction detection and masking, and it is tested against the same contract as the others. This page is for everything else — Kotlin Multiplatform, .NET MAUI, Capacitor, Unity — and for anyone who wants to know what the SDKs actually do.

Every Graine SDK is a thin host around one page Graine serves. The page does capture, playback, jitter buffering, echo suppression, the WebRTC relay and the WebSocket fallback. The host shows it in a WebView, tells it who the customer is and what is on screen, and answers when the agent asks to do something. Nothing about audio is the host's problem, and an audio fix reaches every app without a release.

Your appEngine page (in the WebView)load /embed/…?idle=1 (nothing registered, no microphone)graine:readygraine:variablesgraine:app-contextgraine:warm (or graine:start-call if the customer already tapped)graine:start-callgraine:call · graine:caption · graine:app-action · graine:widgetnothing sent before this is heardthe customer tapsthe rest of the call, until graine:ended

The order is the protocol. The page drops anything sent before graine:ready, and the agent's first sentence is built from the variables and the screen — so both go before the call starts, not after.

1. Ask for the session

POST https://www.graine.ai/api/embed/session
Content-Type: application/json
 
{ "publishableKey": "pk_live_…" }
FieldUse it for
webcallAgentIdThe page to load. Fall back to agentId if it is missing
pageVersionThe page's cache key, so a new release is fetched and an unchanged one is not
appearance.audioTransport"rtc" means WebRTC; anything else means WebSocket
appearance.accentThe brand colour, for the page's loader and your own UI
designThe brand's design, resolved: config, and light and dark token sets — colours, type, corners, spacing, shadows, motion — to draw your own UI in the same design. See Your design
variablesThe dashboard's default prompt variables — yours override them
appActionsThe actions declared on the agent, to check against the handlers you registered

A 403 means the agent's Allowed domains does not list app://. A native HTTP client sends no Origin header, and that entry is what matches its absence. A 401 means the key was rejected.

2. Load the page, idle

https://www.graine.ai/embed/<webcallAgentId>?mode=voice&branding=0&embed=1&transport=<rtc|ws>&b=<pageVersion>&idle=1&pk=<publishableKey>&accent=<hex without #>
Parameter
transportFrom the session's appearance.audioTransport, unless you are forcing one to test
bThe session's pageVersion. Without one, any value that is fixed for the life of the app process
idle=1Load without registering or opening the microphone. Load at launch, not at the tap: the page is ready long before anyone wants to talk
pkYour publishable key. The page mints its own short-lived call credentials with it
accentOptional. Six hex digits, no #

Percent-encode every value. The SDKs build this URL from a shared test fixture, so theirs is byte-for-byte the one above.

The WebView needs JavaScript and DOM storage on, inline media playback allowed, and media playback not gated on a user gesture — nothing in a 1×1 view can be tapped, so a gesture requirement is never met and the agent never makes a sound.

Give it a real box: 1×1, fully transparent, attached to the window. Not display:none, not zero height, not removed from the hierarchy. A view with no box can have its media suspended, which kills the call the moment your UI collapses.

3. Build the bridge

Into the page, evaluate:

window.postMessage(<json>, "*"); true;

The trailing true; matters on Android: evaluateJavascript otherwise logs an error for every message, because a postMessage call returns nothing it can serialise.

Out of the page, the page calls window.ReactNativeWebView.postMessage(<json string>). That object must exist before the page's own scripts run. The name is a handle, not a React Native dependency.

webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.settings.mediaPlaybackRequiresUserGesture = false
webView.addJavascriptInterface(object {
    @JavascriptInterface
    fun postMessage(json: String) {
        // Called on a WebView thread. Hop to the main thread before touching UI.
        mainHandler.post { onPageMessage(json) }
    }
}, "ReactNativeWebView")
 
webView.webChromeClient = object : WebChromeClient() {
    // Grant the microphone only, and only when the app itself holds it.
    override fun onPermissionRequest(request: PermissionRequest) {
        val wantsAudio = PermissionRequest.RESOURCE_AUDIO_CAPTURE in request.resources
        if (wantsAudio && hasRecordAudioPermission()) {
            request.grant(arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE))
        } else {
            request.deny()
        }
    }
}

The manifest needs INTERNET, RECORD_AUDIO and MODIFY_AUDIO_SETTINGS, and a release build needs a ProGuard rule that keeps @JavascriptInterface methods.

4. The messages

Into the page

MessagePayloadWhen
graine:variables{ variables }After ready, and again whenever they change. Flat string values
graine:app-context{ context, availableActions, seq }After ready, when the screen changes, and when a call connects
graine:warm—The customer has shown intent, or the app is back in the foreground: register now so the tap is instant. Safe to repeat — a registration that is still good is kept, one in progress is joined
graine:start-call—Place the call
graine:set-muted{ muted }Mute or unmute the microphone
graine:end-call—Hang up. Send before you destroy the view
graine:app-action-result{ actionId, status, message, data }Your answer to an action. Exactly once per action
graine:app-event{ name, data }Something happened worth speaking about, during a call
graine:widget-result{ widgetId, data, summary }The customer answered a component

Out of the page

MessagePayloadMeaning
graine:ready—The page is mounted. Nothing sent earlier was heard
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: { action_id, name, arguments } }The agent wants your app to do something, and waits
graine:widget-pending{ widget }A component is on its way. Draw a placeholder for its kind
graine:widget{ widget }Draw a component: kind, data, style, on_select, fallback_text
graine:mic-denied{ reason }The microphone was refused or is missing
graine:transport{ transport, requested, reason, message }Which transport carries the audio, and why not the one asked for
graine:quality{ sample: { rtt, jitter, loss, quality } }The line, measured every two seconds during a WebRTC call, with the page's verdict in quality
graine:network{ online }The device went offline or came back
graine:reconnecting{ active }The network dropped the call and it is being re-established
graine:progress{ stage }Where a connect is: registering, dialling, retrying (a fresh connection after an unanswered first try), switching (moving to the other transport), or failed — nothing could carry the call; stop showing "connecting" and offer a retry
graine:ended—The call is over, however it ended. Fires once

Ignore any type you do not recognise. New ones are added without a version bump, and a host that throws on them breaks on a page update it never shipped.

The minimum for a working call is graine:ready, graine:start-call, graine:end-call, graine:call, graine:ended and graine:mic-denied. A host that draws no components can ignore both widget messages: the agent speaks the fallback_text instead.

graine:quality comes from the peer connection's own statistics, so a WebSocket call has none. Every SDK reads a sample the same way: 400 ms of round trip, 80 ms of jitter or 8% loss is a poor line; 150 ms, 30 ms or 2% is good rather than excellent. Say "weak connection" when it is poor. A customer whose agent goes quiet on a train otherwise reads "listening" and concludes the agent is broken.

On a website

A web page can host the engine too: the same page, in an iframe instead of a WebView. Three things differ.

  • Messages travel between windows. Send with frame.contentWindow.postMessage(message, "https://www.graine.ai"), and the page sends to its parent. Read only messages whose event.origin is https://www.graine.ai and whose event.source is the frame.
  • The frame needs the microphone delegated: allow="microphone; autoplay". Keep it laid out — 1 × 1 pixels, transparent — rather than display: none.
  • Your page vouches for its site. The engine's own requests carry Graine's address, not yours, so when WebRTC needs to know which site it is on the page posts graine:need-ticket to its parent. Request a ticket from your page — POST https://www.graine.ai/api/embed/ticket with { "publishableKey": "pk_live_…" }, which checks YOUR origin against Allowed domains — and answer { type: "graine:ticket", ticket, wsUrl }, or { type: "graine:ticket", ticket: null } if it failed. The Graine loader does this for you.

A complete page, with a status line and a talk button, is in Your own UI → Website.

Answering an action

The agent waits for 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. An action your build does not implement is refused, with a message that says so.

The SDKs give a handler six seconds, then answer error on its behalf. 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",
    "journey": { "name": "Loan", "step": 3, "of": 4 },
    "fields": [
      { "name": "tenure", "label": "Tenure", "value": "12 months", "status": "filled" },
      { "name": "pan", "label": "PAN", "value": "", "status": "empty" }
    ],
    "idle_ms": 45000
  }
}

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 (empty, filled, invalid, pending, locked, 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.

Mask before you send. The SDKs replace PAN, Aadhaar, card, phone and email patterns in every value that leaves the device — screen, variables and events — with [PAN], [AADHAAR], [CARD], [PHONE] and [EMAIL]. A host of your own has to do the same.

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 hang-up.
  • End the call when the app goes to the background. A backgrounded WebView can keep the microphone open and the call billing with nobody listening.
  • 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.

Next steps

On this page