Graine AI

Voice

Let customers talk to the agent instead of typing — with no native audio module, no rebuild, and fixes that ship over the air.

The agent is a conversation. Text is the fallback.

The short way

import { WebView } from "react-native-webview";
import { GraineProvider, GraineVoiceLauncher, GraineAgentBar } from "@graineai/inapp-react-native";
 
<GraineProvider
  publishableKey="pk_live_…"
  navigationRef={navigationRef}
  autoConnect={false}
>
  <GraineVoiceLauncher
    webView={WebView}
    autoStart
    onError={(m) => console.warn("[graine]", m)}
  >
    {() => <GraineAgentBar />}
  </GraineVoiceLauncher>
</GraineProvider>

That is the whole voice integration. react-native-webview autolinks; there is no native initialisation, no MainApplication.kt edit, no AppDelegate.swift edit, no ProGuard rule, no babel plugin ordering.

autoConnect={false} is not optional here. The provider opens a connection of its own by default, and the launcher opens the one that carries the call. Leave both on and the agent answers twice — the customer hears two greetings over each other, and you are billed for two conversations. Drop the launcher and use text only, and autoConnect goes back to its default.

Pass onError. A rejected key, or an agent that has not been enabled for apps, leaves the launcher rendering nothing at all — forever, with nothing in the console. This is the only way to find out.

Why a WebView

React Native has no getUserMedia. Every route to a microphone is a native module — and a native module means an app-store release for every fix.

The audio path is where nearly every fix lands. Decoding, jitter buffering, echo suppression, the mute latch: all of it changed in the last month. Through a WebView those ship over the air, because the microphone and speaker run inside the same hosted page the web widget uses, which already does capture, playback, buffering, mu-law decoding and echo suppression.

The customer never sees it. It is one pixel, invisible, unreachable — laid out at 1×1 rather than hidden, because a view with no box can have its media suspended by the platform, and that would kill the call the moment the bar collapses.

Controlling the call

The child function receives the controls, so your own UI can drive it:

<GraineVoiceLauncher webView={WebView}>
  {({ connected, connecting, muted, start, end, setMuted }) => (
    <MyBar
      status={connecting ? "connecting" : connected ? "live" : "idle"}
      onCall={() => (connected ? end() : start())}
      onMute={() => setMuted(!muted)}
      muted={muted}
    />
  )}
</GraineVoiceLauncher>

onCaption gives you each line as it is said, if you draw your own captions.

Captions, and the CC button

The launcher keeps the transcript. A caption box is a map, and the CC switch is api.captionsOn — you do not accumulate anything yourself. 0.28.0.

<GraineVoiceLauncher webView={WebView} active={open} autoStart>
  {(api) => (
    <>
      {api.captionsOn && api.captions.slice(-3).map((c, i) => (
        <Text key={i} style={{ opacity: c.live ? 0.7 : 1 }}>
          {c.role === "agent" ? "Agent" : "You"}: {c.text}
        </Text>
      ))}
      <Button title="CC" onPress={() => api.setCaptionsOn(!api.captionsOn)} />
    </>
  )}
</GraineVoiceLauncher>
On api
captionsEvery turn so far, oldest first, capped at CAPTION_HISTORY (12)
captionThe turn being said now, or the last one. null before any
captionsOn / setCaptionsOnThe CC switch. Defaults on
clearCaptions()Forget the transcript without ending the call

A turn arrives many times as its words come in — each with fuller text and live: true — then once settled with live: false. The launcher folds those into one entry, so captions holds turns rather than keystrokes. Render live ones faded if you want; they are the same line about to be finished.

The transcript goes with the call

It is cleared when a call starts and when you set active={false}. If you need the words afterwards, copy them in onEnded.

onCaption still fires for every line and is unchanged — it is a convenience on top of captions now, rather than the only way to see them.

Hanging up

Two paths, both idempotent, so a host that uses one, the other, or both ends exactly one call:

  • api.end() — your own close or hang-up button.
  • active={false} — your UI closing. Since 0.28.0 this posts the stop frame unconditionally. It used to be guarded on the launcher's own belief about whether a call was running, and those flags are cleared by the page's own frame — so a close that raced it dismissed the UI and left the call up.

Deactivating also clears connected / connecting and the transcript, so a reopened launcher never shows a call that has ended.

Microphone permission

The SDK never asks. When to ask is a product decision, and a permission dialog appearing from inside an SDK at a moment the customer does not expect is the fastest way to be denied permanently.

Declare it, ask at a sensible moment in your flow, then start the call.

<!-- Android -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- iOS Info.plist -->
<key>NSMicrophoneUsageDescription</key>
<string>Used to talk to the in-app assistant.</string>

On iPhone, set your audio session to playsInSilentModeIOS: true and allowsRecordingIOS: true while a call is live. Without the first, the agent is silent whenever the ringer switch is off — which is most phones, most of the time, and it presents as a broken agent rather than a muted one.

Interruption

Handled for you. When the customer talks over the agent, the agent stops and whatever it had queued is discarded rather than played out behind them.

A customer who interrupts and then hears the agent finish the sentence anyway concludes it is not listening — and they are right.

The SDK also keeps the agent from hearing itself. On a phone the speaker is two inches from the microphone, so a large part of what it hears while the agent talks is the agent; sent upstream, the transcriber writes those words down as the customer's and the agent interrupts itself after a word or two. The guard measures the echo on the device rather than assuming a level, and holds ~240ms of audio back so a real interruption is not clipped at the start.

A very loud speaker defeats it. If the agent's echo is within about 1.5× of the customer's own voice, no level-based rule can separate them — barge-in stops working rather than misfiring, which is the safer direction. Lower the volume or use earphones to confirm that is what you are seeing.

Voice and screen context together

This is the combination worth building for. The customer says "why was this rejected?" without naming a field, and the agent already knows which screen they are on and which field is red — so it answers about that one.

Nothing extra is required. Add screen context and it applies to voice conversations automatically.

Bringing your own audio

If you already ship a native audio module and would rather use it than a WebView, the adapter path is still there. It is more work and it cannot be updated over the air, so reach for it only when you have a reason.

import { useGraineVoice, liveAudioStreamAdapter } from "@graineai/inapp-react-native";
import LiveAudioStream from "react-native-live-audio-stream";
 
const adapter = liveAudioStreamAdapter(LiveAudioStream, myPcmPlayer);
const { listening, start, stop } = useGraineVoice(adapter);

This path needs voice on the provider, since the SDK's own socket carries the audio:

<GraineProvider baseUrl="…" publishableKey="pk_live_…" voice>

The boundary is four methods:

interface AudioAdapter {
  startCapture(onChunk: (base64: string) => void): void | Promise<void>;
  stopCapture(): void | Promise<void>;
  play(pcm16: Int16Array, sampleRate: number): void;
  clear(): void;             // the customer interrupted — drop what is queued
  bufferedSeconds?(): number; // lets marks be acked at playout, not on arrival
}

Capture at 16 kHz, mono, PCM16. Ask your native module for exactly that.

A different sample rate does not produce an error. It produces a fluent, confident, completely wrong transcript — far harder to diagnose than a failure. If the agent seems to mishear everything, check the rate first.

Do not resample in JavaScript: resampling every chunk on the audio path is how a mid-range Android starts dropping frames and the agent starts hearing every third word. And read MIN_PLAYOUT_BUFFER_SECONDS before writing the player — a cushion enforced as a hard gate strands the end of every reply and eventually stops the conversation.

How the audio travels

Two transports, one integration. GraineVoiceLauncher takes a transport prop, and if you omit it the agent's dashboard setting decides — Agent → Embed & Widgets → Call audio — so this can change for every app running your build without shipping a release.

Standard (ws)High quality (rtc)
Audioraw PCM on the conversation socketOpus over WebRTC, terminated on our SBC
Loss handlingnone — a lossy network sounds lossyadaptive jitter buffer, packet-loss concealment
Echolevel-based guard in the pagethe device's own canceller, with the playout signal as reference
Screen contextsame socketa second socket, joined server-side by session id
NeedsnothingWebRTC enabled for your organisation

Screen context, actions and events work identically on both. On rtc they travel on a separate control channel that the runtime joins to the media call, so the agent sees exactly what it sees on ws.

When rtc cannot be used

It falls back to ws rather than failing, and tells you through onTransport:

onTransport={({ transport, requested, reason, message }) => {
  if (transport !== requested) console.warn(`[graine] ${reason}: ${message}`);
}}
reasonMeansRetry helps?
not_configuredA setting is wrong. Also raised through onError.Never
unreachableNo SIP realm on the org, or the SBC is not reachable from this network.Sometimes
screen_channelAudio connected, the screen channel did not, so the call restarted rather than run blind.Sometimes

A refused microphone is not a transport problem. It fails identically on both, so it is reported through onMicDenied and never falls back — prompting a second time for something already declined reads as the app not listening.

Diagnosing not_configured

The call still works, so nothing is on fire — but WebRTC will never be used until this is fixed. Ask the endpoint directly, without rebuilding anything:

curl -i -X POST https://www.graine.ai/api/embed/rtc-session \
  -H 'Content-Type: application/json' \
  -d '{"publishableKey":"pk_live_YOUR_KEY"}'
ResponsecodeFix
401key_missingNo key reached the endpoint. Update the SDK — before 0.17.0 it did not put pk in the page URL.
401key_malformedNot a publishable key. It starts with pk_live_.
401key_unknownNo agent registered to that key.
403no_originThe common one for apps. A native app sends no Origin, so add the literal entry app:// to that agent's Allowed domains.
403no_domains_configuredThe allowlist is empty, which denies everything.
403origin_not_allowedAdd your site to Allowed domains.
503not_configuredWebRTC is not enabled on that deployment.
200The mint works; look at onTransport for unreachable instead.

That curl sends no Origin header, exactly as a native app does — so a 403 from it is precisely the 403 your app is getting.

Next steps

On this page