Flutter
The in-app agent in a Flutter app — screen context, actions, components and voice, for Android and iOS from one package.
graine_inapp puts the agent into a Flutter app on Android and iOS. It hosts
the same voice engine as every other Graine SDK, in a WebView it owns — one
invisible pixel — so every audio fix reaches your app without a release. Dart
handles the session, prompt variables, screen context, actions and UI, and its
rules are tested against the answers the React Native SDK gives.
Install
Flutter 3.19+ (Dart 3.3+). The package depends only on webview_flutter and
http. The source, an example app and the tests are at
github.com/GraineAI/inapp-flutter.
Android — android/app/src/main/AndroidManifest.xml, and minSdk 24 or
higher:
iOS — ios/Runner/Info.plist:
In the dashboard, give the app its own agent
and add app:// to its Allowed domains under Embed & Widgets.
Calls over WebRTC (optional)
The native calls — the customer calling the agent, and a call Graine rang — carry their audio over a WebSocket unless you build with WebRTC. Turn it on per platform, then rebuild:
On a build server, GRAINE_WEBRTC=1 in the build's environment turns it on for
both. flutter build runs pod install when the Podfile changes; with only the
environment variable, run pod install in ios/ too. Nothing else changes —
your code, the ring, the call screen and the outcomes stay the same.
- What the customer notices: a call that holds up better on mobile data — a jitter buffer, loss concealment, the phone's own echo canceller. Over WebRTC the agent's voice arrives at telephone bandwidth today.
- When WebRTC cannot carry a call, it continues on the WebSocket inside the same ring and call screen: no second ring, no error.
- The dashboard decides last: the agent's In-app call audio setting (Calls & scheduling) — WebRTC (recommended) or Standard — can move every install's calls to the WebSocket from their next start, without a release.
- Size: about 12 MB installed on an iPhone, about 7.4 MB on a 64-bit Android phone — a prebuilt WebRTC library whose symbols are renamed, so it never clashes with another copy of WebRTC in your app. CocoaPods installs one version of it: a library that pins it at another version cannot be installed beside it.
- Android adds
ACCESS_NETWORK_STATEto your manifest (a normal permission: nothing is asked); the R8 rules ship with the package. Licences: WebRTC is BSD 3-Clause with Google's patent grant — list it in your app's open-source licences.
The choice in code, and which one ran: Calls over WebRTC.
Quickstart
1. Initialise before runApp
2. Place the engine and the bar, once, around the navigator
GraineEngine goes once, above the navigator: a call must outlive every
route, because the agent's job is to move the customer between them. It loads
the engine idle at startup, so a tap is a call away rather than a page load away.
3. Tell it what is on screen
Report real values: PAN, Aadhaar, card, phone and email are masked on the
device before anything leaves it. Masking.addRule adds your own shapes.
4. Let it act
A handler answers within six seconds or the agent is told the app did not
answer. A throw is an error, and a name you did not register is refused.
5. Components
The bar draws the components the agent shows and answers with what the customer
picked. To draw them elsewhere, pass showComponents: false to the bar and
place GraineWidgetView where they belong. See Components.
Your own UI
GraineInApp.instance.voice is a ChangeNotifier with the call's state —
connected, connecting, ready, muted, quality, online,
reconnecting, connectivity, captions, agentSpeaking, micDenied, and
since 0.5.0 connectStage, slowConnect and callFailed (where a call being
placed is, whether it has run past timing.slowConnectMs, and whether nothing
could carry it) — and start(), end(), setMuted() and retry(). Since 0.9.0 it has
the captions switch too (captionsOn, setCaptionsOn(on), clearCaptions() —
GraineCaptionsSwitch): off, the bar hides its caption while captions keeps what
was said. A chat of your own reads chat.reconnecting — true while a dropped chat
is being made again, so say Reconnecting…, not Offline. Rebuild from it with a
ListenableBuilder, and the default bar becomes optional — a full example,
with every state to draw, is in Your own UI.
Calls like WhatsApp
From 0.6.1, GraineCalls.instance puts your agent in a native call: the
customer calls it from your app, or your agent rings the customer through a
push — CallKit and VoIP pushes on iOS, a ringing call notification and call
screen on Android — and the call goes on with the screen off.
The pushes, the iOS capabilities and your server's side are in
In-app calling → Flutter. Adding calls needs a
new store build: the package now carries native code. Built
with WebRTC, both directions carry their audio over
WebRTC; GraineCallsOptions(callTransport:) chooses and the snapshot's
transport says which one ran. 0.9.0
Not using calls? From 0.6.1 the package's Android manifest adds what a call needs to every app
that includes it. On Android 14 and later, Google Play asks apps that declare
USE_FULL_SCREEN_INTENT or FOREGROUND_SERVICE_MICROPHONE to explain why in
the Play Console. An app that never places a call removes them, and the call's
microphone service, in its own AndroidManifest.xml:
Reference
GraineConfig
| Option | Default | |
|---|---|---|
baseUrl | — | https://www.graine.ai |
publishableKey | — | Selects the agent. Public by design |
requestMicrophone | — | Ask the OS for the microphone; return whether it was granted |
variables | empty | Prompt variables known at build time |
transport | the dashboard's | Force Transport.webrtc or Transport.websocket, for testing |
appVersion | — | Lands on the call record |
endOnBackground | true | End a call two seconds after the app leaves the foreground |
timing | Load watchdog and retry ladder | |
friction | — | Your layer of the stuck-customer rules, over the dashboard's |
debug | false | Bridge traffic and the engine's console in the log, values redacted |
chatSize | the dashboard's | How big the chat window opens: GraineChatSize.compact, large or full; GraineChat.open(context, size:) wins for one open — Window size |
GraineAgentBar
The bar is drawn in the brand's design, exactly as the dashboard's Garage previews it: the design's surface, corners and shadow (a hard offset for Neo-Brutalist, frosted for Liquid Glass), the accent avatar with the launcher glyph, the name and status in the design's typeface with a meter that moves at the design's pace, the microphone, the hang-up and the caption card — and the dashboard's loader while the call connects, reconnects or the agent thinks. The SDK downloads the design's typeface from Graine and registers it itself; there is nothing to bundle and nothing to configure. 0.3.0 — Your design
| Parameter | |
|---|---|
initialOffset / onPlacementChange | Restore where the customer left it, and hear when they move it |
showComponents | Draw components on the bar. Default true |
accent, name, avatarUrl, scheme, radius | Override one thing each; the dashboard's design is the source |
onClose | The end button was pressed with no call — hide the bar if your app shows it on demand. 0.3.0 |
showCaptions | Draw the live caption above the bar. Default true. 0.3.0 |
builtInChat | The bar runs the agent's own chat (GraineInApp.chat): it offers the switch where the dashboard allows both, and draws the chat window in the design. Default true. 0.7.0 |
chat | A chat connection of your own (a GraineChatState) to draw instead of the agent's. 0.3.0 |
app | The agent this bar talks to, in an app with several (GraineInApp.named('sales')); the default agent without one. 0.7.0 |
The bar is draggable (a movement under 8 px is still a tap), clamped to the screen, and rises above the keyboard by the keyboard's height — nothing if the route already resized for it.
Events
GraineListener is a mixin; override only what you use. Session ready, call
state, captions, components, ended, microphone denied, quality, network,
reconnecting, transport, connectivity, actions, friction and errors.
Troubleshooting
| Symptom | Check |
|---|---|
| Nothing happens, and the log says 403 | app:// is missing from the agent's Allowed domains |
| The call ends when the customer changes screen | GraineEngine is inside a route. Move it to MaterialApp.builder |
| The microphone is never granted | requestMicrophone is not set, or the manifest or Info.plist entry is missing |
| The agent does not know the screen | setScreen is not called on that screen, or not on every change |
| What went over the bridge? | debug: true logs every message both ways, values redacted |
| Calls never use WebRTC | The build was made without the switch (ENV['GRAINE_WEBRTC'] in the Podfile, graineWebRtc=true in gradle.properties), callTransport is GraineCallTransport.websocket, or the agent's In-app call audio is Standard. The snapshot's transportFallbackReason says why a call that tried WebRTC did not use it |

