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
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:
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.
On api | |
|---|---|
captions | Every turn so far, oldest first, capped at CAPTION_HISTORY (12) |
caption | The turn being said now, or the last one. null before any |
captionsOn / setCaptionsOn | The 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.
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.
This path needs voice on the provider, since the SDK's own socket carries the
audio:
The boundary is four methods:
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) | |
|---|---|---|
| Audio | raw PCM on the conversation socket | Opus over WebRTC, terminated on our SBC |
| Loss handling | none — a lossy network sounds lossy | adaptive jitter buffer, packet-loss concealment |
| Echo | level-based guard in the page | the device's own canceller, with the playout signal as reference |
| Screen context | same socket | a second socket, joined server-side by session id |
| Needs | nothing | WebRTC 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:
reason | Means | Retry helps? |
|---|---|---|
not_configured | A setting is wrong. Also raised through onError. | Never |
unreachable | No SIP realm on the org, or the SBC is not reachable from this network. | Sometimes |
screen_channel | Audio 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:
| Response | code | Fix |
|---|---|---|
| 401 | key_missing | No key reached the endpoint. Update the SDK — before 0.17.0 it did not put pk in the page URL. |
| 401 | key_malformed | Not a publishable key. It starts with pk_live_. |
| 401 | key_unknown | No agent registered to that key. |
| 403 | no_origin | The common one for apps. A native app sends no Origin, so add the literal entry app:// to that agent's Allowed domains. |
| 403 | no_domains_configured | The allowlist is empty, which denies everything. |
| 403 | origin_not_allowed | Add your site to Allowed domains. |
| 503 | not_configured | WebRTC is not enabled on that deployment. |
| 200 | — | The 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.

