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.
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
| Field | Use it for |
|---|---|
webcallAgentId | The page to load. Fall back to agentId if it is missing |
pageVersion | The 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.accent | The brand colour, for the page's loader and your own UI |
design | The 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 |
variables | The dashboard's default prompt variables — yours override them |
appActions | The 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
| Parameter | |
|---|---|
transport | From the session's appearance.audioTransport, unless you are forcing one to test |
b | The session's pageVersion. Without one, any value that is fixed for the life of the app process |
idle=1 | Load without registering or opening the microphone. Load at launch, not at the tap: the page is ready long before anyone wants to talk |
pk | Your publishable key. The page mints its own short-lived call credentials with it |
accent | Optional. 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:
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.
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
| Message | Payload | When |
|---|---|---|
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
| Message | Payload | Meaning |
|---|---|---|
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 whoseevent.originishttps://www.graine.aiand whoseevent.sourceis the frame. - The frame needs the microphone delegated:
allow="microphone; autoplay". Keep it laid out — 1 × 1 pixels, transparent — rather thandisplay: 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-ticketto its parent. Request a ticket from your page —POST https://www.graine.ai/api/embed/ticketwith{ "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.
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
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-callbefore 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.

