Graine AI

Your own UI

Draw the agent yourself on any platform. The SDK keeps the call — WebRTC, the microphone, reconnects and recovery — and tells you what to draw.

Every SDK draws a voice bar in the design chosen in the dashboard, and most apps should use it. When your design system needs its own call UI, keep the SDK for the call and draw everything yourself. The two are separate on every platform: the SDK places the call and reports its state, your code decides what the customer sees.

WebRTC is a dashboard setting, not code. Choose High quality (WebRTC) under Agent → Embed & Widgets → Appearance → Setup → Call audio, and every SDK carries the call over WebRTC — your own UI included. If WebRTC cannot connect, the call moves to the standard path by itself; nothing in your UI changes.

What the SDK still does

Drawing your own UI replaces the bar, not the engine. The SDK still:

  • carries the audio over WebRTC — Opus, a jitter buffer, the device's own echo canceller — with a relay (TURN) for mobile networks, and falls back to the standard path when WebRTC cannot connect
  • asks for the microphone when a call starts without it — in Flutter through your requestMicrophone — and reports a refusal
  • loads the engine ahead of the tap and keeps it registered, so a tap is a call away, not a page load away
  • recovers a connection the phone's network dropped while the app sat idle: it checks the line as the customer taps, replaces a dead one at once, tries once more on a fresh connection, and only then switches transport
  • holds a call through a short network drop (reconnecting) and keeps the transcript
  • carries the screen, your actions and the agent's components, exactly as with the bar
  • writes the connection's own rows to Execution Logs — transport, how long the connect took, every recovery

What becomes yours: drawing every state below, the copy, accessibility, and matching the brand's design if you want to (its tokens are available on every platform — see Your design).

The call's state, on every platform

The same call, in each SDK's words. Draw from these; they change as the call does.

React Native — the launcher's apiAndroid — graine.voiceiOS — GraineInApp.shared.voiceFlutter — GraineInApp.instance.voiceWebsite — page messages
Place, hang up, mutestart() end() setMuted(m)start() end() setMuted(m)start() end() setMuted(_:)start() end() setMuted(m)send graine:start-call, graine:end-call, graine:set-muted
On a call, or placing oneconnected, connectingconnected, connectingconnected, connectingconnected, connectinggraine:call { connected, connecting }
One word to draw fromphasephase (CallPhase)phase (GrainePhase)from connected, agentSpeakingfrom graine:call and graine:caption
Where a connect isconnectStage, slowConnect, callFailedconnectStage, slowConnect, callFailedconnectStage, slowConnect, callFailedconnectStage, slowConnect, callFailedgraine:progress { stage }
The linequality, network, reconnectingquality, online, reconnectingquality, online, reconnectingquality, online, reconnectinggraine:quality, graine:network, graine:reconnecting
What was saidcaptions, captiononCaption(role, text, live)captionscaptions, captiongraine:caption { role, text, live }
Microphone refusedmicDeniedonMicDenied(reason)onMicDenied(reason:)micDeniedgraine:mic-denied { reason }
A setting must be fixederrorerroronError(_:)errorgraine:transport with reason: "not_configured"
The engine loadedready, connectivity, retry()ready, connectivity, retry()ready, connectivity, retry()ready, connectivity, retry()graine:ready
The call is overonEnded proponEnded()onEnded()onEnded()graine:ended

Names in onX(…) form arrive on a listener — addListener on Android, iOS and Flutter; the rest are properties you read. connectStage, slowConnect and callFailed need React Native 0.40.0 or Android, iOS and Flutter 0.5.0.

What to draw, moment by moment

MomentWhat it isSay
Not readyThe engine is still loading (ready false). A tap is held and placed when it is"Connecting…"
Placingconnecting"Connecting…", with a loader — and keep the end button working
Taking longslowConnect: the customer has waited 4 s"Still connecting…"
A fresh lineconnectStage is retrying or switching: the first try went unanswered — on mobile data, usually a line the phone's network dropped — and a new one is being made"Reconnecting…"
Could not connectcallFailed: nothing could carry the call"Couldn’t connect · tap to retry" — your call button starts a new one
Microphone refusedmicDeniedWhy you need it, and the way to the phone's settings. Do not start again on your own
Listening, thinking, speakingphase while connectedA level meter while listening; a loader between the customer's last word and the agent's first
Weak linequality is poor"Weak connection" — otherwise the customer blames the agent for the network
Offlinenetwork / online is offline"No connection"
Dropped, coming backreconnecting"Reconnecting…", with the transcript kept
OveronEnded / graine:endedClose or reset about a second later, so the goodbye finishes playing

Two things make a call UI feel reliable: something on screen within a tenth of a second of the tap, and words that change when the wait does. A spinner that says "Connecting…" for ten seconds reads as broken even when the call is recovering correctly.

For accessibility, announce state changes — connecting, connected, ended, a refusal — through a live region or the platform's announcement API, not every caption; label the call, mute and end controls by what they do now ("End the call", not "Button"); and stop any looping animation when the device asks for reduced motion.

React Native

Mount the launcher as with the bar, and draw from its render prop instead of GraineVoiceBar. The provider and the launcher are unchanged.

import { WebView } from "react-native-webview";
import { GraineProvider, GraineVoiceLauncher, type GraineVoiceApi } from "@graineai/inapp-react-native";
 
export function Assistant({ open, onClose }: { open: boolean; onClose: () => void }) {
  return (
    <GraineProvider client={client} autoConnect={false}>
      {/* Mounted at launch with active={false}: the engine loads idle, so opening is a tap away */}
      <GraineVoiceLauncher webView={WebView} active={open} autoStart onEnded={() => setTimeout(onClose, 900)}>
        {(api) => (open ? <MyCallBar api={api} onClose={onClose} /> : null)}
      </GraineVoiceLauncher>
    </GraineProvider>
  );
}
 
function statusOf(api: GraineVoiceApi): string {
  if (api.network === "offline") return "No connection";
  if (api.micDenied) return "Microphone is off for this app";
  if (api.callFailed) return "Couldn’t connect · tap to retry";
  if (api.reconnecting || api.connectStage === "retrying" || api.connectStage === "switching") return "Reconnecting…";
  if (!api.ready || api.connecting) return api.slowConnect ? "Still connecting…" : "Connecting…";
  if (api.phase === "thinking") return "Thinking…";
  if (api.phase === "speaking") return "Speaking";
  if (api.connected) return api.quality === "poor" ? "Weak connection" : "Listening";
  return "Tap to talk";
}
 
function MyCallBar({ api, onClose }: { api: GraineVoiceApi; onClose: () => void }) {
  return (
    <MyBar
      status={statusOf(api)}
      caption={api.captionsOn ? api.caption?.text : undefined}
      live={api.connected}
      muted={api.muted}
      onTalk={() => (api.connected ? api.setMuted(!api.muted) : api.start())}
      onEnd={() => (api.connected || api.connecting ? api.end() : onClose())}
    />
  );
}

The full API, including timing and onTransport, is in React Native.

Android

Mount the engine once and leave out GraineAgentBarView. Draw from graine.voice, and listen for the moments that are events rather than state.

class CallActivity : Activity() {
    private val graine get() = GraineInApp.get()
 
    private val listener = object : GraineListener {
        override fun onCallState(connected: Boolean, connecting: Boolean) = render()
        override fun onConnectProgress(stage: ConnectStage?, slow: Boolean, failed: Boolean) = render()
        override fun onCaption(role: Role, text: String, live: Boolean) { captionView.text = text }
        override fun onMicDenied(reason: String) { showMicExplanation() }
        override fun onReconnecting(active: Boolean) = render()
        override fun onQuality(quality: ConnectionQuality) = render()
        override fun onEnded() { captionView.postDelayed({ finish() }, 900) }
    }
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.call)
        graine.voice.attach(this)                 // the engine, idle — no bar
        graine.addListener(listener)
        talkButton.setOnClickListener {
            val v = graine.voice
            if (v.connected) v.setMuted(!v.muted) else v.start()
        }
        endButton.setOnClickListener { graine.voice.end() }
        render()
    }
 
    override fun onDestroy() { graine.removeListener(listener); super.onDestroy() }
 
    override fun onRequestPermissionsResult(code: Int, permissions: Array<out String>, results: IntArray) {
        if (!graine.voice.onRequestPermissionsResult(code, results)) super.onRequestPermissionsResult(code, permissions, results)
    }
 
    private fun render() {
        val v = graine.voice
        statusView.text = when {
            v.callFailed -> "Couldn’t connect · tap to retry"
            v.reconnecting || v.connectStage == ConnectStage.RETRYING || v.connectStage == ConnectStage.SWITCHING -> "Reconnecting…"
            !v.ready || v.connecting -> if (v.slowConnect) "Still connecting…" else "Connecting…"
            v.phase == CallPhase.THINKING -> "Thinking…"
            v.phase == CallPhase.SPEAKING -> "Speaking"
            v.connected -> if (v.quality == ConnectionQuality.POOR) "Weak connection" else "Listening"
            else -> "Tap to talk"
        }
    }
}

Listeners are called on the main thread. The rest of the API is in Android → Reference.

iOS

GraineVoice is an ObservableObject: SwiftUI redraws from it. Attach the engine once a window exists, and add a listener for the moments that are events.

import SwiftUI
import GraineInApp
 
struct CallBar: View {
    @ObservedObject private var voice = GraineInApp.shared.voice
 
    var body: some View {
        HStack {
            Text(status).accessibilityAddTraits(.updatesFrequently)
            Spacer()
            Button(voice.connected ? (voice.muted ? "Unmute" : "Mute") : "Talk") {
                voice.connected ? voice.setMuted(!voice.muted) : voice.start()
            }
            Button("End the call") { voice.end() }.disabled(!voice.connected && !voice.connecting)
        }
        .onAppear { voice.attach() }              // the engine, idle — in the app's key window
    }
 
    private var status: String {
        if voice.callFailed { return "Couldn’t connect · tap to retry" }
        if voice.reconnecting || voice.connectStage == .retrying || voice.connectStage == .switching { return "Reconnecting…" }
        if !voice.ready || voice.connecting { return voice.slowConnect ? "Still connecting…" : "Connecting…" }
        switch voice.phase {
        case .thinking: return "Thinking…"
        case .speaking: return "Speaking"
        default: return voice.connected ? (voice.quality == .poor ? "Weak connection" : "Listening") : "Tap to talk"
        }
    }
}
 
final class MicWatcher: GraineListener {          // every method has a default; implement what you need
    func onMicDenied(reason: String) { /* explain, and offer Settings */ }
    func onEnded() { /* close the call UI a second later */ }
}
// GraineInApp.shared.addListener(micWatcher) — listeners are held weakly, so keep a reference

Add NSMicrophoneUsageDescription to Info.plist. The rest of the API is in iOS → Reference.

Flutter

GraineEngine still goes once around the navigator — it is the call — but the bar becomes optional. Rebuild your own from GraineInApp.instance.voice, a ChangeNotifier.

MaterialApp(
  builder: (context, child) => GraineEngine(child: child!),   // the engine; no GraineAgentBar
  home: const HomeScreen(),
);
 
class CallBar extends StatelessWidget {
  const CallBar({super.key});
 
  @override
  Widget build(BuildContext context) {
    final voice = GraineInApp.instance.voice;
    return ListenableBuilder(
      listenable: voice,
      builder: (context, _) => Row(children: [
        Expanded(child: Text(_status(voice), semanticsLabel: _status(voice))),
        TextButton(
          onPressed: () => voice.connected ? voice.setMuted(!voice.muted) : voice.start(),
          child: Text(voice.connected ? (voice.muted ? 'Unmute' : 'Mute') : 'Talk'),
        ),
        TextButton(onPressed: voice.end, child: const Text('End the call')),
      ]),
    );
  }
 
  String _status(GraineVoice v) {
    if (v.micDenied) return 'Microphone is off for this app';
    if (v.callFailed) return 'Couldn’t connect · tap to retry';
    if (v.reconnecting || v.connectStage == ConnectStage.retrying || v.connectStage == ConnectStage.switching) return 'Reconnecting…';
    if (!v.ready || v.connecting) return v.slowConnect ? 'Still connecting…' : 'Connecting…';
    if (v.connected) return v.agentSpeaking ? 'Speaking' : (v.quality == ConnectionQuality.poor ? 'Weak connection' : 'Listening');
    return 'Tap to talk';
  }
}

The microphone is asked for through requestMicrophone in GraineConfig; see Flutter → Quickstart.

Website

On a website the engine is a page Graine serves in an iframe — the same engine every SDK hosts. Keep it out of sight, drive it with messages, and draw your own button and status. The Graine loader does all of this for you when you want the standard launcher; do it yourself only for your own UI.

Allow your site. Under Agent → Embed & Widgets, add your domain to Allowed domains (example.com, or .example.com for every subdomain). Never add Graine's own domain: anyone could then use your key.

Ask which agent answers, then load the engine idle. The session names the call's agent and the audio setting from the dashboard.

Answer graine:need-ticket. The engine is Graine's page, so its requests carry Graine's address, not yours. When WebRTC needs to know which site it is on, the engine asks your page; your page mints a ticket — from your own origin, which is the one Allowed domains checks — and hands it over. Without an answer, WebRTC refuses and the call runs on the standard path.

Warm it on graine:ready, start it from your button, and draw from the messages. Check event.origin on every one.

<button id="talk" aria-live="polite">Talk to us</button>
<p id="caption" aria-live="off"></p>
 
<script>
  const GRAINE = "https://www.graine.ai";
  const KEY = "pk_live_…";                       // Agent → Embed & Widgets
  let frame, state = { connected: false, connecting: false };
 
  const send = (type, extra) => frame?.contentWindow?.postMessage({ type, ...extra }, GRAINE);
  const say = (text) => { document.getElementById("talk").textContent = text; };
 
  async function boot() {
    const s = await fetch(GRAINE + "/api/embed/session", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ publishableKey: KEY }),
    }).then((r) => r.json());
    const transport = s.appearance?.audioTransport === "rtc" ? "rtc" : "ws";
 
    // Invisible but laid out: a frame with no box may have its audio suspended.
    frame = document.createElement("iframe");
    frame.src = `${GRAINE}/embed/${s.webcallAgentId || s.agentId}` +
      `?mode=voice&embed=1&idle=1&transport=${transport}&pk=${encodeURIComponent(KEY)}`;
    frame.allow = "microphone; autoplay";
    frame.title = "Voice assistant";
    Object.assign(frame.style, { position: "fixed", bottom: "0", left: "0", width: "1px", height: "1px",
      opacity: "0", border: "0", pointerEvents: "none" });
    document.body.appendChild(frame);
  }
 
  window.addEventListener("message", async (e) => {
    if (e.origin !== GRAINE || e.source !== frame?.contentWindow) return;   // only the engine
    const m = e.data || {};
    switch (m.type) {
      case "graine:need-ticket": {                 // vouch for your site, from your origin
        const t = await fetch(GRAINE + "/api/embed/ticket", {
          method: "POST", headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ publishableKey: KEY }),
        }).then((r) => (r.ok ? r.json() : null)).catch(() => null);
        send("graine:ticket", t ? { ticket: t.ticket, wsUrl: t.wsUrl } : { ticket: null });
        break;
      }
      case "graine:ready":    send("graine:warm"); break;               // register ahead of the tap
      case "graine:call":     state = m; say(m.connected ? "End the call" : m.connecting ? "Connecting…" : "Talk to us"); break;
      case "graine:progress":
        if (m.stage === "retrying" || m.stage === "switching") say("Reconnecting…");
        if (m.stage === "failed") say("Couldn’t connect · tap to retry");
        break;
      case "graine:caption":  document.getElementById("caption").textContent = m.text; break;
      case "graine:mic-denied": say("Allow the microphone to talk"); break;
      case "graine:ended":    setTimeout(() => say("Talk to us"), 900); break;
    }
  });
 
  document.getElementById("talk").onclick = () =>
    send(state.connected || state.connecting ? "graine:end-call" : "graine:start-call");
 
  boot();
</script>

Every message, in both directions, is in WebView hosts → The messages; a website's page is the same host on a different window. Your page's screen and actions travel the same way: graine:app-context in, graine:app-action out.

Calls like WhatsApp

A full-screen call with your agent — the customer calling it, or your agent ringing the customer — runs on the SDK's native call engine, which brings its own ring, notifications and call screen. To draw the call screen yourself, set callScreen: "app" (React Native, Flutter), name your activity in callActivity (Android) or present your own view (iOS), and draw it from the call's state. Unlike the bar, a call does not reconnect when the network drops: it ends. The whole build is In-app calling, and every field is in the calling reference.

Components and actions

Your own call UI changes nothing about what the agent can do in the app.

  • Actions run in the handlers you registered, exactly as with the bar.
  • Components need somewhere to appear. The bar draws them for you in React Native, Android and Flutter; on iOS, and without a bar anywhere, mount the SDK's component view where they belong — GraineWidget in React Native, GraineWidgetView on Android, iOS and Flutter — or draw them yourself from the widget events. On a website, the engine posts graine:widget, and your page answers with graine:widget-result. See Components → Drawing them yourself.

Check it before you ship

  • Each state once, on purpose: deny the microphone; turn the network off mid-call (reconnecting, then the call returns); place a call on a slow network (Still connecting…); end from both sides.
  • The record: a test call's Execution Logs show which transport carried it (transport: WebRTC), how long the connect took (connect: warm · 0.6s (setup 0.1s, answer 0.5s)), and any recovery on the way (recovery: …).
  • A website: with your domain in Allowed domains and graine:need-ticket answered, the record says transport: WebRTC. If it says transport: fell back to WebSocket (not_configured), read the message of the graine:transport your page received: when it says the page around the frame did not vouch for its site, your page did not answer the ticket request.

The whole path, from the dashboard to a monitored launch, is in Going live.

On this page