Architecture and guarantees
The in-app platform from an engineer's seat — its surfaces, the two engines and how their audio travels, how a conversation is admitted, what data leaves the device and what is kept, the limits, what fails and how, and what each SDK does today.
This page is the platform as a whole, for the engineer who signs off on it: what runs where, what crosses the network, what is promised and where the edges are. To build first and read later, start with Integrate in an afternoon.
The pieces
Two engines in one SDK, one agent behind both. Only one conversation runs at a time.
| Piece | What it is | How it updates |
|---|---|---|
| The SDK | A library in your app: React Native, Android (no runtime dependencies), iOS (a Swift package with no dependencies) or Flutter. It masks, orders and sends what your app reports, answers the agent's action requests through your handlers, and draws the bar, the chat and components in the design chosen in the dashboard | With your app |
| The in-app engine | A page Graine hosts, loaded idle into an invisible web view at launch. It holds the microphone and the connection for the assistant bar's voice, and plays the agent | Live — a fix reaches every app with no release |
| The native call engine | Audio in your app's own process, with the platform's echo canceller, so a call rings through CallKit on iOS and a full-screen call notification on Android, and keeps going with the screen off | With the SDK — a new store build |
| Your agent | The agent's web-call variant: it shares the prompt and knowledge base with the agent that takes phone calls, and is kept in step with it, but runs separately — so nothing in your app can affect your phone calling | Live — every save |
| Your design | Resolved on Graine's side into light and dark tokens and served with the session; every SDK draws from them | Live — the next session |
Surfaces
| Surface | Runs on | Carries | Platforms |
|---|---|---|---|
| The bar, voice | The in-app engine | Audio, screen context, actions, components | All four SDKs and websites |
| The bar, chat | A text connection the SDK opens | Text, screen context, actions, components | React Native and websites today |
| Components | Drawn natively by the SDK, in your design | A list, plans, slots or choices; the tap returns as the customer's turn | Wherever the bar runs |
| Calls | The native call engine | Audio, screen context, actions — voice only | Android, iOS, Flutter; React Native with its calls package |
| Website embed | The same engine page, in an iframe | Everything the bar carries | Any site, through the loader |
Only one conversation runs at a time in an app: moving from the call to the chat ends the call, and a native call ends a conversation in the bar (which ends which).
How the audio travels
The bar loads the engine page idle at launch — no microphone, nothing dialled — and keeps a line registered, so a tap connects in about a fifth of a second when the engine is ready and under two seconds when it is not. The agent's dashboard setting (Call audio) picks the transport:
| High quality (WebRTC) | Standard (WebSocket) | |
|---|---|---|
| Audio | Opus to Graine's media servers, with a relay (TURN) for mobile networks | Raw 16 kHz PCM on the conversation's connection |
| Loss | Adaptive jitter buffer and loss concealment | None — a lossy network sounds lossy |
| Echo | The device's own canceller | A level-based guard in the page |
| Screen context | A channel of its own, joined to the call | The same connection |
The engine connects on the first network route that works within 1.5 seconds. If
WebRTC cannot be used, the call moves to the WebSocket and says why
(onTransport): not_configured, unreachable, screen_channel or media — a
call that answered with no audio path is caught within 8 seconds. A network drop
mid-call holds the call as reconnecting for up to 30 seconds and picks it up on
the same session. Voice
A native call opens its own connection from your app's process: over WebRTC
when the app includes the SDK's WebRTC module, else over the WebSocket. WebRTC
that cannot set the call up within 12 seconds hands it to the WebSocket inside the
same call — no second ring — and the call says why (transportFallbackReason).
The line must open within 15 seconds. Once the audio flows a call never changes
road: on WebRTC a dropped connection gets one chance to recover, and a network
that stays down ends the call — your app hears ended with failed.
WebRTC or the WebSocket
A chat is a text connection the SDK opens for the conversation, with a single-use ticket.
How a conversation is admitted
- The publishable key selects the agent. It is public by design — anyone can read it out of an app or a page — and it cannot read your data or place a phone call.
- Allowed domains decides who may use it. A website's origin must be on the
list. A native app sends no origin, so the literal entry
app://admits apps. This is why the allowlist does not protect a key shipped in an app — Privacy and security. - A connection ticket is minted per conversation — single-use, valid for about two minutes, not replayable — and session and ticket requests are rate-limited per key and per address.
- The agent must be switched on and your workspace must have credit. A conversation that cannot start never starts; one already running is never cut off by these checks.
Who the customer is
identify() takes the traits your app knows — a name, a plan — and the SDK
masks them on the device like everything else. They become prompt variables
(Hi {name}) and the line "who you are talking to" in the agent's context.
The one exception is the identifier, the trait the agent's identity setting names: it is sent as it is, only to identify the customer, and never becomes a prompt variable. Graine stores the identifier your app sends so your team can see who each conversation was with — Call History shows it, such as Email · asha@acme.example — and the memory service only ever receives a one-way key derived from it (Identity and users).
Traits are what the device says. With a publishable key and no server of yours in the path, nothing verifies them. Use them to personalise, never to authorise: before the agent discusses an account, have it confirm the customer through one of your actions, which runs in your signed-in app.
Data handled
| Data | Leaves the device | Masked | Kept |
|---|---|---|---|
| Screen context you report | As it changes, while a conversation is open | On the device, then again at Graine | Shapes the conversation; not kept as a record of its own afterwards |
| Traits and variables | When a conversation starts, and as they change | On the device | On the conversation, masked |
| The identifier | When a conversation starts | No — sent as it is, to identify the customer | On the conversation, so Call History shows who it was with; memory receives only a one-way key derived from it |
| The customer's voice | Only while a call is connected | — | As the transcript, in Call History |
| Typed messages | In a chat | — | As the transcript |
| Your action results | When your handler answers | Not by the SDK — it sends what your handler returns, so keep identifiers out of message | On the record: action, arguments, outcome, your message |
| Component answers | On the tap | Not by the SDK | In the conversation and at your webhook |
| Connection telemetry | During a call: transport, quality, network | Nothing personal in it | Quiet rows in Execution Logs, never spoken |
Masked means PAN, Aadhaar, card and account numbers, phone numbers and email
addresses become [PAN], [AADHAAR], [CARD], [PHONE] and [EMAIL] before
anything leaves the phone, with your own patterns added through the masking
rules. A masked value stays masked everywhere after it: there is no unmasked copy
to recover. Component content and tools are fetched from Graine's servers,
never the phone, so a key in a request header never ships in your app; endpoints
must be public https and get 8 seconds.
What the SDK never does: read the screen on its own (no screen recording, no accessibility scraping), open the microphone before a call starts, or perform an action your app did not register.
Limits
| Limit | |
|---|---|
| Variables on a conversation | Up to 12, names up to 40 characters, values up to 200, about 1 KB in all — past that, the biggest are left off first, who_called and call_reason last |
| An action's answer | 6 seconds, then the agent is told it did not finish |
| Actions an agent offers the model | 24 |
| A component's endpoint | 8 seconds, then the authored content is shown |
| Speaking up unprompted | Never while it is talking or a reply is on the way; by default not within 20 seconds of the last time, and at most three times a conversation |
| Product events kept for context | The last 10 |
| The bar after the app leaves the screen | Ends after 2 seconds, unless you turn endOnBackground off |
| The bar through a network drop | Held for 30 seconds |
| Calls at once | One per device |
| A call's ring | 30 seconds unless you set another |
| Opening a call's line | 15 seconds |
When something fails
| What fails | What the SDK does | What the customer sees | What you see |
|---|---|---|---|
The key is rejected, or app:// is missing | Nothing is drawn | No launcher | onError, and a 401 or 403 with a code — Voice → When rtc cannot be used |
| The engine page will not load | Reloads it on a bounded, jittered ladder | "Connecting…", then a retry | connectivity.state, and retry() |
| WebRTC cannot connect | Moves the call to the WebSocket | Nothing different | onTransport with the reason |
| The network drops mid-call (bar) | Holds the call, then resumes it on the same session | "Reconnecting…" | reconnecting; a call record row |
| WebRTC cannot set a native call up | Continues it on the WebSocket, inside the same call | Connecting… a moment longer | transport websocket, transportFallbackReason |
| The network drops mid-call (native call) | Ends the call — on WebRTC after one try to recover | The call ends | ended with failed |
| The microphone is refused | Never falls back, never prompts again | Your explanation | onMicDenied / micDenied |
| A handler is slow, throws, or is missing | Answers for it: timeout, error or refused | The agent says it was not done | The action on the record, with its outcome |
| A component's endpoint fails | Shows the authored content | The component, as designed | The failure on the record |
| The app leaves the screen | Ends the bar's call after 2 seconds; a native call carries on | — | onEnded |
Guarantees
- Masking runs before the network, the same way everywhere. Every SDK applies the same rules, checked against one shared set of test cases, in every script — Devanagari next to an identifier included.
- Every action request gets exactly one answer — yours, or a timeout,
errororrefusedgiven on its behalf. The agent never waits forever and never claims something your app did not report. - Screen frames are ordered. Each carries a sequence number, so a late frame never replaces a newer one, and the latest screen is sent again when the connection rejoins and when the app returns to the foreground.
- Ending ends it. Hanging up is idempotent from every path, and a page that never confirms is torn down.
- One conversation at a time, so the agent never answers twice — once aloud, once in writing.
- Your design is legible. Every text colour a design resolves to is checked for contrast, in light and dark.
- Older SDKs keep working. New fields are optional: an SDK that does not know a component look draws Cards, and one older than a design feature draws without it — a plain surface instead of windows, say.
What each SDK does today
| React Native | Android | iOS | Flutter | Website | |
|---|---|---|---|---|---|
| Voice in the bar, WebRTC or WebSocket | ✓ | ✓ | ✓ | ✓ | ✓ |
| Text chat | ✓ | — | — | Your own chat connection, drawn by the bar | ✓ |
| Components while the bar is on a call | ✓ | ✓ | ✓ | ✓ | ✓ |
| Components in a chat | ✓ | — | — | — | ✓ |
| Components during a native call | — | — | — | — | — |
| Screen context and masking | ✓ | ✓ | ✓ | ✓ | Through page messages |
| The screen reported from your navigator by itself | ✓ (React Navigation) | — | — | — | — |
| Taps tracked, rage taps | ✓ | — | — | — | — |
| Friction signals | All six | Idle on a step, during a call | Idle on a step, during a call | Idle on a step, during a call | — |
| Pointing at an element | ✓ | — | — | — | — |
| Native calls | With the calls package | ✓ | ✓ | ✓ | — |
| Native calls over WebRTC (opt-in) | With the calls package, built with WebRTC | With inapp-android-rtc | With GraineInAppRTC | Built with WebRTC | — |

