Graine AI

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

dependencies:
  graine_inapp: ^0.10.3
  permission_handler: ^11.0.0   # or whatever your app already uses to ask for permissions

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:

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

iOS — ios/Runner/Info.plist:

<key>NSMicrophoneUsageDescription</key>
<string>The assistant listens when you talk to it.</string>
<!-- With calls on WebRTC (GRAINE_WEBRTC): App Store processing requires it, because WebRTC contains camera code -->
<key>NSCameraUsageDescription</key>
<string>Calls are audio only. The camera is never turned on.</string>

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:

# ios/Podfile, at the top
ENV['GRAINE_WEBRTC'] = '1'
# android/gradle.properties
graineWebRtc=true

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_STATE to 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

import 'package:graine_inapp/graine_inapp.dart';
import 'package:permission_handler/permission_handler.dart';
 
void main() {
  WidgetsFlutterBinding.ensureInitialized();
  GraineInApp.init(GraineConfig(
    baseUrl: 'https://www.graine.ai',
    publishableKey: 'pk_live_…',          // Agent → Embed & Widgets
    appVersion: '4.2.0',
    debug: kDebugMode,
    // Your app asks for the microphone; the SDK hands it to the engine.
    requestMicrophone: () async => (await Permission.microphone.request()).isGranted,
  ));
  runApp(const MyApp());
}

2. Place the engine and the bar, once, around the navigator

MaterialApp(
  builder: (context, child) => GraineEngine(
    child: Stack(children: [
      child!,
      GraineAgentBar(
        initialOffset: prefs.getInt('graine.bar'),
        onPlacementChange: (o) => prefs.setInt('graine.bar', o),   // the SDK stores nothing
      ),
    ]),
  ),
  home: const HomeScreen(),
);

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

final graine = GraineInApp.instance;
graine.identify({'name': user.name, 'plan': user.plan});   // null on sign-out
graine.setScreen(GraineScreen(
  'kyc_pan',
  title: 'PAN verification',
  journey: const GraineJourney('KYC', step: 2, of: 4, next: 'Selfie check'),
  fields: [
    GraineField('pan', label: 'PAN', value: pan,
        status: error != null ? FieldStatus.invalid : FieldStatus.filled, error: error),
  ],
));

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

final unregister = graine.registerAction('set_tenure', (args) async {
  final months = args['months'];
  if (months is! int) return const ActionResult.refused('No tenure was given.');
  await loan.setTenure(months);
  return ActionResult.ok('Tenure set to $months months.');
});

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.

await GraineCalls.instance.configure(const GraineCallsOptions(
  publishableKey: 'pk_live_…',
  displayName: 'Acme Assistant',
));
GraineCalls.instance.attach();                                   // your screen context and actions
await GraineCalls.instance.startCall(reason: 'card_blocked');    // the customer calls the agent

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:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" tools:node="remove" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" tools:node="remove" />
    <application>
        <service android:name="ai.graine.inapp.GraineCallService" tools:node="remove" />
    </application>
</manifest>

Reference

GraineConfig

OptionDefault
baseUrl—https://www.graine.ai
publishableKey—Selects the agent. Public by design
requestMicrophone—Ask the OS for the microphone; return whether it was granted
variablesemptyPrompt variables known at build time
transportthe dashboard'sForce Transport.webrtc or Transport.websocket, for testing
appVersion—Lands on the call record
endOnBackgroundtrueEnd a call two seconds after the app leaves the foreground
timingLoad watchdog and retry ladder
friction—Your layer of the stuck-customer rules, over the dashboard's
debugfalseBridge traffic and the engine's console in the log, values redacted
chatSizethe dashboard'sHow 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 / onPlacementChangeRestore where the customer left it, and hear when they move it
showComponentsDraw components on the bar. Default true
accent, name, avatarUrl, scheme, radiusOverride one thing each; the dashboard's design is the source
onCloseThe end button was pressed with no call — hide the bar if your app shows it on demand. 0.3.0
showCaptionsDraw the live caption above the bar. Default true. 0.3.0
builtInChatThe 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
chatA chat connection of your own (a GraineChatState) to draw instead of the agent's. 0.3.0
appThe 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.

class Analytics with GraineListener {
  @override void onError(String message) => log(message);   // loud for you, invisible to the customer
}
graine.addListener(Analytics());

Troubleshooting

SymptomCheck
Nothing happens, and the log says 403app:// is missing from the agent's Allowed domains
The call ends when the customer changes screenGraineEngine is inside a route. Move it to MaterialApp.builder
The microphone is never grantedrequestMicrophone is not set, or the manifest or Info.plist entry is missing
The agent does not know the screensetScreen 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 WebRTCThe 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

Next steps

On this page