@graineai/inapp-react-native 0.40.0 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/INTEGRATION.md CHANGED
@@ -49,7 +49,7 @@ carried differently, and the SDK is what hides that from you.
49
49
  │ hidden WebView (1×1) │ ┌──────────────┐
50
50
  │ the audio engine │══ media ══▶│ the agent │
51
51
  │ capture · playback │ │ │
52
- │ jitter buffer · barge-in│──control ─▶│ runtime │
52
+ │ jitter buffer · barge-in│──control ─▶│ (Graine) │
53
53
  └─────────────────────────┘ └──────┬───────┘
54
54
  │
55
55
  ▲ │
@@ -62,7 +62,7 @@ carried differently, and the SDK is what hides that from you.
62
62
  native audio module, which means a store release for every audio fix — and the
63
63
  audio path is exactly where the fixes are. The page is one pixel, invisible and
64
64
  unreachable; your customer only ever sees your own UI. See *Why there is no
65
- LiveKit step* at the end.
65
+ native setup* at the end.
66
66
 
67
67
  ### What travels, and when
68
68
 
@@ -362,11 +362,11 @@ may do in your app". The name must match exactly. Saving pushes it to the agent
362
362
  immediately.
363
363
 
364
364
  Neither half works alone. A handler with no declaration is dead code; a
365
- declaration with no handler is worse, because the agent will promise it and the
366
- runtime answers "this app build does not implement it".
365
+ declaration with no handler is worse, because the agent will promise it and then
366
+ have to say "this app build does not implement it".
367
367
 
368
368
  Return a message saying what actually happened — the agent repeats it, so a vague
369
- result becomes a vague promise. **The runtime blocks on your handler**, so return
369
+ result becomes a vague promise. **The agent waits for your handler**, so return
370
370
  promptly; anything slow should return immediately and report completion by
371
371
  updating the screen.
372
372
 
@@ -413,7 +413,7 @@ you wrote `[]`.)
413
413
 
414
414
  **A handler has six seconds.** `ACTION_DEADLINE_MS`. Return promptly — do
415
415
  the slow part after you have answered. A handler that has not resolved by
416
- then is answered as a timeout on the runtime side, and the agent tells the
416
+ then is answered as a timeout, and the agent tells the
417
417
  customer it was *not* done rather than guessing. A handler that throws is
418
418
  answered as `error` for the same reason.
419
419
 
@@ -510,7 +510,7 @@ explains a conversation: a declined payment, a rejected document, an OTP retried
510
510
  three times.
511
511
 
512
512
  The last 10 are kept. To make the agent speak first, report an event instead —
513
- and the runtime decides whether it is worth interrupting for.
513
+ and the agent decides whether it is worth interrupting for.
514
514
 
515
515
  ---
516
516
 
@@ -532,7 +532,7 @@ Reserve it for a dead end the customer would want addressed without asking: a
532
532
  save that never reached the server, an upload that failed twice, a payment
533
533
  declined. Not a tap, not a navigation, not a save that worked.
534
534
 
535
- The runtime still decides whether to speak: silent while it is talking, while a
535
+ The agent still decides whether to speak: silent while it is talking, while a
536
536
  reply is generating, for 20s after the last intervention, and after three in a
537
537
  session.
538
538
 
@@ -641,7 +641,7 @@ export default function App() {
641
641
  ```
642
642
 
643
643
  Taps land in the screen frame as `tap:screen` events with the tap's position;
644
- the runtime reads them as "tapped the screen" and Call History shows them on the
644
+ the agent reads them as "tapped the screen" and Call History shows them on the
645
645
  call's timeline. Named taps from `useGraineTap` are unaffected and still carry
646
646
  their names. (0.27.4 — earlier versions did not export `GraineTouchCapture`.)
647
647
 
@@ -791,8 +791,13 @@ call that asked for it.
791
791
  <GraineProvider … endOnBackground={false}> {/* default: true */}
792
792
  ```
793
793
 
794
- Turn it off only if a conversation should genuinely survive a switch to another
795
- app, and you are prepared to explain the open microphone.
794
+ It governs the call as well as the chat — the voice launcher honours it from
795
+ **0.40.1**; before, a call ended two seconds after the app left the foreground
796
+ whatever the provider said. Turn it off only when your app keeps the call alive
797
+ itself: a call screen of its own and, on Android, a microphone foreground
798
+ service. On iOS, test on real devices first — whether a web view keeps its
799
+ microphone in the background varies between iOS versions. See *A call like
800
+ WhatsApp* below.
796
801
 
797
802
  Text sessions reconnect when the app returns. **Voice does not auto-resume** — a
798
803
  call is something a person chose to start, and starting one again because they
@@ -800,9 +805,8 @@ came back to the app is a microphone opening unasked. `connected` goes false;
800
805
  draw from that.
801
806
 
802
807
  **`connect()` is idempotent.** Calling it twice returns the same session rather
803
- than opening a second socket. This matters under React 18 strict mode, which
804
- mounts effects twice: previously the first socket was orphaned with no reference
805
- to close it, and the runtime kept it open and billing until its own timeout.
808
+ than opening a second one — which matters under React 18 strict mode, which
809
+ mounts effects twice.
806
810
 
807
811
  **Nothing is left holding the call.** An outstanding action's waiter is released
808
812
  in a `finally`, so a customer closing the app mid-action releases it too, and
@@ -816,38 +820,74 @@ for you on unmount and on backgrounding.
816
820
 
817
821
  ---
818
822
 
823
+ ## A call like WhatsApp
824
+
825
+ A call that rings the customer's phone — CallKit and VoIP pushes on iOS, a
826
+ ringing call notification and call screen on Android — and goes on with the
827
+ screen off runs on Graine's native call engine, in its own package:
828
+ `@graineai/inapp-react-native-calls`. It carries native code, so it needs a new
829
+ store build; this SDK stays over-the-air.
830
+
831
+ ```tsx
832
+ await GraineCalls.configure({ publishableKey: "pk_live_…", displayName: "Acme Assistant" });
833
+ GraineCalls.enableVoIPPushes((token) => myServer.saveVoipToken(token)); // iOS
834
+ // Android: GraineCalls.handlePush(message.data) first in your Firebase handlers
835
+ await GraineCalls.startCall({ reason: "card_blocked" }); // the customer calls the agent
836
+ ```
837
+
838
+ **Attach this SDK's client** inside `GraineProvider` and the call gets the same
839
+ screen context, identity, variables and actions as the assistant bar:
840
+
841
+ ```tsx
842
+ function CallsBridge() {
843
+ const client = useGraineClient();
844
+ useEffect(() => GraineCalls.attach(client), [client]);
845
+ return null;
846
+ }
847
+ ```
848
+
849
+ **Two prompt variables carry the reason**: `who_called` (`agent` or
850
+ `customer`) and `call_reason`, set by the engine on every call; give both a
851
+ default under Embed & Widgets → Variables. The pushes, the iOS capabilities and
852
+ your server's side are at https://www.graine.ai/docs/in-app/calling#react-native.
853
+
854
+ **Without a native module** (an Expo Go build, a release shipped only over the
855
+ air), a call can still run on this SDK's in-app engine: the launcher on a call
856
+ screen of your own, with `endOnBackground={false}` on the provider (honoured
857
+ for calls from 0.40.1). The ring is then a notification of your own — not
858
+ CallKit, because the engine's microphone runs in WebKit — and Android needs a
859
+ microphone foreground service for the length of the call. On iOS, whether a web
860
+ view keeps its microphone in the background varies between iOS versions: test
861
+ it on the versions you support.
862
+
863
+ ---
864
+
819
865
  ## How long "Connecting…" takes, and why
820
866
 
821
- From the tap to the agent's first word there are four waits. Three of them
822
- are paid before the tap on a current SDK; the fourth is bounded. If the
823
- launcher sits on "Connecting…" for more than about two seconds, one of these
824
- is not happening, and the table says which.
867
+ From the tap to the agent's first word, four things happen. On a current SDK,
868
+ three of them are done before the tap. If the launcher sits on "Connecting…"
869
+ for more than about two seconds, one of them is not happening, and the table
870
+ says which.
825
871
 
826
- | Wait | What it is | When it is paid | Typical |
872
+ | Step | What it is | When it happens | Typical |
827
873
  |---|---|---|---|
828
- | Page load | The voice engine is a page in a WebView | **Before the tap** — `GraineFab` mounts the engine on open; a custom bar mounts `GraineVoiceLauncher` at launch with `active={false}` and flips it on open (0.27.5) | ~4s, hidden |
829
- | Mint + REGISTER | A 120-second SIP credential, then registration on the SBC | **Before the tap**, re-warmed as it nears expiry and whenever the app comes back to the foreground (0.36.0) | ~1s, hidden |
830
- | Microphone | The OS permission prompt, first time only | At the tap | 0 once granted |
831
- | INVITE → answer | The call itself, on a network path prepared before the tap | At the tap | ~0.2s |
832
-
833
- **The INVITE is bounded, not left to the browser.** WebRTC gathers network
834
- candidates before dialling, and Chrome does not report gathering complete
835
- until STUN has been tried on *every* interface a device has — a VPN tunnel,
836
- a link-local IPv6 address, a cellular path the app may not use. Those never
837
- answer, and Chrome retries each for about forty seconds before giving up.
838
- Measured on the embed page with the SIP frames timestamped: registered at
839
- 8.2s, the usable candidate in hand at 8.3s, the INVITE not sent until 48.3s,
840
- then answered in 300ms. On a phone with Wi-Fi and cellular that is the
841
- ten-second "Connecting…" that was reported. The engine now dials the moment a
842
- server-reflexive candidate exists, or 1.5 seconds after gathering starts if
843
- none arrives — a network with no STUN at all still gets a call,
844
- with host candidates, rather than the pause and then the same call.
845
-
846
- **On an app that is still on 0.26.x** the first two rows are paid *after* the
847
- tap: the page loads and registers only once the customer has pressed the
848
- microphone, so "Connecting…" is honest and about six seconds long. Moving to
849
- 0.27.4 is what hides them. The dial bound needs no app change — it lives in
850
- the engine page, so every install gets it when the page is deployed.
874
+ | The engine loads | The voice engine is a page in a WebView | **Before the tap** — `GraineFab` loads it when it opens; a custom bar mounts `GraineVoiceLauncher` at launch with `active={false}` and turns it on when it opens (0.27.5) | ~4 s, out of sight |
875
+ | The line is prepared | A short-lived connection to Graine, made ready | **Before the tap**, renewed before it expires and whenever the app returns to the foreground (0.36.0) | ~1 s, out of sight |
876
+ | The microphone | The OS permission prompt, first time only | At the tap | none once granted |
877
+ | The call connects | The call itself, on a network route found before the tap | At the tap | ~0.2 s |
878
+
879
+ **Connecting never waits on a network route it does not need.** A device can
880
+ have interfaces that never answer — a VPN tunnel, a link-local IPv6 address, a
881
+ cellular path the app may not use — and trying every one of them can take forty
882
+ seconds. The engine connects on the first route that works, or after 1.5
883
+ seconds with the routes it has, so a phone on Wi-Fi and cellular connects in
884
+ about a second rather than ten.
885
+
886
+ **On an app that is still on 0.26.x** the first two steps happen *after* the
887
+ tap: the page loads and prepares the line only once the customer has pressed
888
+ the microphone, so "Connecting…" is honest and about six seconds long. Moving
889
+ to 0.27.4 is what hides them. The route bound needs no app change — it lives in
890
+ the engine page, so every install already has it.
851
891
 
852
892
  What "Connecting…" should look like on a current app, then: a tap, the
853
893
  microphone prompt once, and the greeting inside a second.
@@ -896,12 +936,12 @@ Pass it before you debug anything else.
896
936
  | `reason` | Means | Retry helps? |
897
937
  |---|---|---|
898
938
  | `not_configured` | A setting is wrong — see below. Also raised through `onError`. | Never |
899
- | `unreachable` | No SIP realm on the org, or the SBC is not reachable from this network. | Sometimes |
939
+ | `unreachable` | WebRTC is not set up for your organisation, or Graine's media servers cannot be reached from this network. | Sometimes |
900
940
  | `screen_channel` | Audio connected, the screen channel did not, so the call restarted rather than run blind. | Sometimes |
901
941
 
902
942
  **`not_configured`: which setting?** The call still works — it is carried on the
903
943
  WebSocket — so nothing is on fire, but WebRTC will never be used until this is
904
- fixed. Ask the mint endpoint directly, without rebuilding anything:
944
+ fixed. Ask the WebRTC endpoint directly, without rebuilding anything:
905
945
 
906
946
  ```bash
907
947
  curl -i -X POST https://www.graine.ai/api/embed/rtc-session \
@@ -917,8 +957,8 @@ curl -i -X POST https://www.graine.ai/api/embed/rtc-session \
917
957
  | 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. |
918
958
  | 403 | `no_domains_configured` | The allowlist is empty, which denies everything. |
919
959
  | 403 | `origin_not_allowed` | Add your site to Allowed domains. |
920
- | 503 | `not_configured` | WebRTC is not enabled on that deployment. Not fixable from the app. |
921
- | 200 | — | The mint works; look at `onTransport` for `unreachable` instead. |
960
+ | 503 | `not_configured` | WebRTC is not enabled for your organisation. Ask Graine support — it is not fixable from the app. |
961
+ | 200 | — | Your key and Allowed domains are fine; look at `onTransport` for `unreachable` instead. |
922
962
 
923
963
  That curl sends **no** `Origin` header, exactly as a native app does — so a 403
924
964
  from it is precisely the 403 your app is getting.
@@ -985,9 +1025,9 @@ A customer who taps through four screens and then asks "where am I?" gets the
985
1025
  fourth. The guarantees, so you do not have to think about them:
986
1026
 
987
1027
  - **Immediate.** `useGraineScreen` reports a screen the moment it mounts, and
988
- the voice launcher relays it to the runtime at once — the 400ms coalescing
989
- applies only to the SDK's own text socket, never to a host's channel.
990
- - **Ordered.** Every context frame carries a monotonic `seq`; the runtime keeps
1028
+ the voice launcher passes it on at once — the 400ms coalescing applies only
1029
+ to the SDK's own chat connection, never to a host's channel.
1030
+ - **Ordered.** Every context frame carries a monotonic `seq`; the agent keeps
991
1031
  the newest and drops anything that arrives late. The last screen always wins.
992
1032
  - **Cleanup-safe.** The description is re-sent whenever its state changes and
993
1033
  withdrawn only on unmount — never on a change (0.26.2; earlier versions sent
@@ -1001,9 +1041,9 @@ fourth. The guarantees, so you do not have to think about them:
1001
1041
  a call and when the app returns to the foreground, with its original
1002
1042
  sequence number — so a frame lost in flight is repaired within seconds, not
1003
1043
  at the next screen change; one that arrived is simply ignored.
1004
- - **Applied every turn.** The runtime puts the last-reported screen on the
1005
- prompt at every generation, so nothing that rewrites the prompt mid-call — a
1006
- language switch, a summary — can make the agent forget where the customer is.
1044
+ - **Applied every turn.** The agent is given the last-reported screen on every
1045
+ turn, so nothing that changes the conversation mid-call — a language switch,
1046
+ a summary — can make it forget where the customer is.
1007
1047
 
1008
1048
  Every reporting hook works **outside a provider** too — against the client the
1009
1049
  provider registered, or one you pass to `setDefaultClient(client)` once — so an
@@ -1033,48 +1073,12 @@ screens already report.
1033
1073
  ## Captions on WebRTC
1034
1074
 
1035
1075
  Captions — the agent's line and what the customer is saying — reach the host
1036
- on both transports. On WebRTC the audio carries no text, so the runtime
1037
- mirrors the same caption frames over the control channel; a native bar using
1076
+ on both transports. On WebRTC the captions arrive on a channel of their own; a
1077
+ native bar using
1038
1078
  `onCaption` sees `role: "customer"` and `role: "agent"` lines either way.
1039
1079
 
1040
1080
  ---
1041
1081
 
1042
- ## Coming from RevRag
1043
-
1044
- Same shape, different names. If you have a RevRag integration, this is the
1045
- whole translation:
1046
-
1047
- | RevRag | Graine |
1048
- | --- | --- |
1049
- | `useInitialize({ apiKey })` → `{ isInitialized, error }` | `useGraineReady()` → `{ ready, error }` (the key goes on `GraineProvider`) |
1050
- | `EmbedProvider navigationRef appVersion` | `GraineProvider navigationRef appVersion` |
1051
- | `includeScreens` | `includeScreens` |
1052
- | `embedButtonDelayMs` | `launcherDelayMs` |
1053
- | `embedButtonVisibilityConfig` `{ defaultDelayMs, defaultInset, groups }` | `visibility` `{ defaultDelayMs, defaultInset, groups }` |
1054
- | `EmbedButtonGroupConfig` `{ id, screens, continuity, delayMs, delayPolicy, inset }` | `LauncherGroup` — identical fields |
1055
- | `continuous` / `perScreen` | same |
1056
- | `perScreen` / `oncePerGroupEntry` / `oncePerAppSession` | same |
1057
- | `EmbedButtonInset` | `LauncherInset` — same shape |
1058
- | `EmbedButton` / the provider mounts the FAB | `fab={{ webView }}` on `GraineProvider`, or `GraineFab` yourself |
1059
- | Best-effort click tracking on touchables | `captureTaps` (on by default with `fab`) — `tap:screen` events |
1060
- | `useInitialize` → `{ isInitialized, error }` | `useGraineInit()` |
1061
- | `Embed.Event(USER_DATA, { app_user_id, data })` | `useGraineIdentify()` / `client.identify({ id, name, … })` |
1062
- | `Embed.Event(SCREEN_STATE, { screen, data })` | `useGraineScreen({ screen, fields })` |
1063
- | `Embed.Event(CUSTOM_EVENT, data)` / `ANALYTICS_DATA` | `useGraineTrack()` / `client.track(name, data)` |
1064
- | `embedOnAgent(AGENT_CONVERSATION_STARTED / ENDED)` | `useGraineEvents(e => e.type === "conversation_started" / "conversation_ended")` |
1065
- | `AgentEvent.MICROPHONE_PERMISSION_DENIED` | `{ type: "mic_denied", reason }` on the same stream |
1066
- | `AgentEvent.POPUP_MESSAGE_VISIBLE` | `{ type: "launcher_shown", screen }` |
1067
- | `checkPermissions()` | `requestMicrophonePermission()` |
1068
- | server `widget_config` | `appearance` from the session, via `resolveNativeAppearance` |
1069
- | LiveKit native setup | none — see *Why there is no LiveKit step* |
1070
- | Friction detection (dashboard switch) | Per-screen JSON rules — `friction` on the provider, or Embed & Widgets → Friction; five named signals, `friction_detected` event |
1071
-
1072
- Two things RevRag has that are deliberately absent: a LiveKit install step
1073
- (audio rides a WebView on a hosted page, so audio fixes ship without an app
1074
- release), and click tracking on every touchable by default (`useGraineTap` is
1075
- opt-in per element, because a stream of every tap is noise the agent has to be
1076
- told to ignore).
1077
-
1078
1082
  ## Reference
1079
1083
 
1080
1084
  | Hook | For |
@@ -1104,7 +1108,7 @@ told to ignore).
1104
1108
  | `visibility` | `LauncherVisibility` | Groups, continuity, per-group delays and insets. See *Controlling where the launcher appears*. |
1105
1109
  | `autoConnect` | `boolean` | Default `true`. Set `false` when `GraineVoiceLauncher` owns the connection — see below. |
1106
1110
  | `onProactive` | `(text) => void` | The agent spoke first; draw your own nudge. |
1107
- | `endOnBackground` | `boolean` | Default `true`. You want it on. |
1111
+ | `endOnBackground` | `boolean` | Default `true`: the session — and, from 0.40.1, the call — ends two seconds after the app leaves the foreground. Turn it off only when your app keeps the call alive itself; see *A call like WhatsApp*. |
1108
1112
  | `appVersion` | `string` | Which build the conversation happened in. Lands on the call record. |
1109
1113
  | `friction` | `FrictionConfig` | This app's friction rules, layered over the dashboard's. See *Friction detection*. |
1110
1114
  | `loadFont` | `(fonts) => Promise` | expo-font's `Font.loadAsync`. The SDK loads the design's typeface itself — only the faces it uses, from Graine. **0.38.0** |
@@ -1137,8 +1141,8 @@ told to ignore).
1137
1141
  | `onEnded` | `() => void` | The call is over. Fires once. Give the goodbye ~400ms to play before you unmount. |
1138
1142
  | `onMicDenied` | `(reason) => void` | `NotAllowedError` is a refusal; `NotFoundError` is a device with no usable microphone. Different sentences for a customer. |
1139
1143
  | `onError` | `(message) => void` | Voice could not be set up at all — key rejected, agent not enabled for apps, no network. Without this the launcher renders nothing and you cannot tell why. |
1140
- | `children` | `(api) => ReactNode` | Receives `{ connected, connecting, muted, start, end, setMuted }`. |
1141
- | `active` | `boolean` | Default `true`. Mount early with `false` so the engine page loads before the customer needs it (it registers nothing while idle); flip to `true` when your UI opens — with `autoStart` that places the call, without it it warms the registration so the tap is instant. Back to `false` ends a live call. (0.27.5; 0.27.6 makes that hangup unconditional) |
1144
+ | `children` | `(api) => ReactNode` | Receives the voice API: `start`, `end`, `setMuted`, `connected`, `connecting`, `connectStage`, `slowConnect`, `callFailed`, `captions`, `quality`, `reconnecting`, `micDenied`, `error` and more — draw your own call from it (*Captions and hanging up*). |
1145
+ | `active` | `boolean` | Default `true`. Mount early with `false` so the engine page loads before the customer needs it (it connects nothing while idle); flip to `true` when your UI opens — with `autoStart` that places the call, without it it prepares the line so the tap is instant. Back to `false` ends a live call. (0.27.5; 0.27.6 makes that hangup unconditional) |
1142
1146
 
1143
1147
  **The launcher owns the connection.** It asks for the microphone, resolves the
1144
1148
  session, holds the socket and relays your screen, actions and events onto it. So
@@ -1154,20 +1158,20 @@ changed without shipping a release. Passing the prop overrides it.
1154
1158
  `ws` (the default) sends raw PCM on the same socket that carries screen context
1155
1159
  and in-app actions.
1156
1160
 
1157
- `rtc` hands media to jambonz's SBC — Opus, DTLS-SRTP, an adaptive jitter buffer,
1158
- and the platform's own echo canceller running below the microphone with the
1159
- playout signal as its reference. Better audio on a bad network.
1161
+ `rtc` sends audio over WebRTC to Graine's media servers — Opus, encrypted
1162
+ (DTLS-SRTP), an adaptive jitter buffer, and the platform's own echo canceller.
1163
+ Better audio on a bad network.
1160
1164
 
1161
- **`rtc` carries the screen too.** The browser holds two connections — media to
1162
- the SBC, and a control socket for `app_context`, `app_action` and `app_event` —
1163
- and the server joins them by a session id the browser puts on the INVITE. So an
1164
- in-app agent on `rtc` sees exactly what it sees on `ws`. Frames sent before the
1165
+ **`rtc` carries the screen too.** Screen context, actions and events travel on
1166
+ a second connection that Graine joins to the call, so an in-app agent on `rtc`
1167
+ sees exactly what it sees on `ws`. Frames sent before the
1165
1168
  join completes are queued rather than dropped, because the first screen frame
1166
1169
  almost always arrives while the phone is still ringing, and it is the one the
1167
1170
  agent's opening line is composed from.
1168
1171
 
1169
1172
  `rtc` **falls back.** WebRTC has more ways to be unavailable than a WebSocket —
1170
- no SIP realm on the org, an unreachable SBC, a network that eats UDP — and each
1173
+ WebRTC not set up for your organisation, media servers out of reach, a network
1174
+ that blocks UDP — and each
1171
1175
  is a reason to carry the call differently rather than refuse it. The page
1172
1176
  reconnects on the WebSocket and tells you through `onTransport`, so a permanent
1173
1177
  misconfiguration is visible instead of being merely audible. A refused
@@ -1175,17 +1179,13 @@ microphone is **not** a transport problem and does not fall back.
1175
1179
 
1176
1180
  ---
1177
1181
 
1178
- ## Why there is no LiveKit step
1182
+ ## Why there is no native setup
1179
1183
 
1180
- Other in-app agent SDKs run voice over LiveKit, which means native
1181
- initialisation in `MainApplication.kt` and `AppDelegate.swift`, a pod install, a
1182
- Lottie dependency, ProGuard rules, and a clean rebuild of both platforms. Their
1183
- own documentation leads its troubleshooting with the error you get when one of
1184
- those is missed.
1184
+ The SDK needs no edit to `MainApplication.kt` or `AppDelegate.swift`, no pod
1185
+ install and no native rebuild. The audio runs in the same hosted engine as the
1186
+ web widget, so an improvement to the audio — where nearly every fix lands —
1187
+ reaches your app over the air, and integration is an npm install.
1185
1188
 
1186
- We put the audio in a WebView instead. The trade is real and worth stating: WebRTC
1187
- gets acoustic echo cancellation from the platform's audio stack, and our
1188
- level-based echo guard is an approximation of it that gives up when the speaker
1189
- is very loud. What you get in exchange is that the audio path — where nearly
1190
- every fix lands — ships over the air, and integration is an npm install rather
1191
- than an afternoon in Xcode.
1189
+ The trade, stated plainly: a WebRTC call uses the phone's own echo canceller;
1190
+ on the WebSocket path a level-based guard stands in for it, and gives up when
1191
+ the speaker is very loud. Earphones or a lower volume settle it.
package/README.md CHANGED
@@ -180,9 +180,8 @@ varies with the network and a player with no cushion runs dry in that gap. Hold
180
180
  queue does run dry, refill to that again before resuming — otherwise one hiccup
181
181
  becomes continuous stuttering for the rest of the turn.
182
182
 
183
- Report what you are holding through `bufferedSeconds()` and the SDK will
184
- acknowledge the agent's audio at playout rather than on arrival, which is what
185
- the runtime uses to know when it has finished speaking.
183
+ Report what you are holding through `bufferedSeconds()`, so the agent knows
184
+ when its reply has actually been heard rather than just received.
186
185
 
187
186
  ## Privacy
188
187
 
@@ -205,10 +204,19 @@ requests a permission on your behalf.
205
204
 
206
205
  > **A publishable key inside a mobile app is public.** Anyone can extract it
207
206
  > from an APK or IPA, and the web domain allowlist does not apply to mobile
208
- > apps. Rate limits, single-use connection tickets and your account's credit
209
- > limits are what bound it. See the
207
+ > apps. Rate limits, single-use connection credentials and your account's
208
+ > credit limits are what bound it. See the
210
209
  > [security notes](https://www.graine.ai/docs/in-app/privacy) before launch.
211
210
 
211
+ ## Calls like WhatsApp
212
+
213
+ A call that rings the customer's phone — CallKit and VoIP pushes on iOS, a
214
+ ringing call notification and call screen on Android — and goes on with the
215
+ screen off comes in its own package, `@graineai/inapp-react-native-calls`,
216
+ which runs Graine's native call engine and shares this SDK's screen context and
217
+ actions (`GraineCalls.attach(client)`). It needs a new store build; this SDK
218
+ stays over-the-air. https://www.graine.ai/docs/in-app/calling#react-native
219
+
212
220
  ## Requirements
213
221
 
214
222
  - React Native 0.68+
package/dist/client.d.ts CHANGED
@@ -55,6 +55,10 @@ export declare class GraineInAppClient {
55
55
  private legacyStall;
56
56
  private pingTimer;
57
57
  private agentSpeaking;
58
+ private openedAsVoice;
59
+ private lastAgentAudioAt;
60
+ private speakingTimer;
61
+ private spokenTurn;
58
62
  private bargedIn;
59
63
  private muted;
60
64
  private playoutClock;
@@ -145,6 +149,8 @@ export declare class GraineInAppClient {
145
149
  setPlayoutClock(fn: (() => number) | null): void;
146
150
  get isAgentSpeaking(): boolean;
147
151
  submitWidget(widgetId: string, data: Record<string, unknown>, summary: string): void;
152
+ private agentAudioArrived;
153
+ private scheduleSpeakingEnd;
148
154
  private stopTimers;
149
155
  close(): void;
150
156
  }
package/dist/client.js CHANGED
@@ -1,8 +1,9 @@
1
- import { contextToken, readAgentChunk, variablesForTicket, classifyQuality, ACTION_DEADLINE_MS, CONTEXT_THROTTLE_MS, } from "./protocol.js";
1
+ import { contextToken, readAgentChunk, spokenJoinGap, variablesForTicket, classifyQuality, ACTION_DEADLINE_MS, CONTEXT_THROTTLE_MS, } from "./protocol.js";
2
2
  import { maskDeep } from "./mask.js";
3
3
  import { FrictionDetector, resolveFriction } from "./friction.js";
4
4
  export const SUBPROTOCOL = "graine.embed.v1";
5
5
  const PING_MS = 25000;
6
+ const SPEAKING_TAIL_MS = 300;
6
7
  export class GraineInAppClient {
7
8
  allVariables() {
8
9
  return {
@@ -79,6 +80,10 @@ export class GraineInAppClient {
79
80
  this.legacyStall = false;
80
81
  this.pingTimer = null;
81
82
  this.agentSpeaking = false;
83
+ this.openedAsVoice = false;
84
+ this.lastAgentAudioAt = 0;
85
+ this.speakingTimer = null;
86
+ this.spokenTurn = "";
82
87
  this.bargedIn = false;
83
88
  this.muted = false;
84
89
  this.playoutClock = null;
@@ -248,6 +253,7 @@ export class GraineInAppClient {
248
253
  await this.session();
249
254
  const agentId = this.config.webcallAgentId || this.config.agentId;
250
255
  const t = await this.ticket();
256
+ this.openedAsVoice = this.socketIsVoice;
251
257
  let url;
252
258
  let protocols;
253
259
  if (t?.wsUrl && t.ticket) {
@@ -319,15 +325,24 @@ export class GraineInAppClient {
319
325
  switch (msg.type) {
320
326
  case "text": {
321
327
  const chunk = readAgentChunk(msg.data);
322
- if (chunk.startsTurn && !this.agentSpeaking) {
328
+ if (!this.openedAsVoice && (chunk.startsTurn || chunk.text) && !this.agentSpeaking) {
323
329
  this.agentSpeaking = true;
324
330
  this.emit("agent_speaking", true);
325
331
  }
326
- if (chunk.text)
327
- this.emit("chunk", chunk.text);
328
- if (chunk.endsTurn && this.agentSpeaking) {
329
- this.agentSpeaking = false;
330
- this.emit("agent_speaking", false);
332
+ let words = chunk.text;
333
+ if (this.openedAsVoice && words) {
334
+ words = spokenJoinGap(this.spokenTurn, words) + words;
335
+ this.spokenTurn += words;
336
+ }
337
+ if (words)
338
+ this.emit("chunk", words);
339
+ if (chunk.endsTurn) {
340
+ this.spokenTurn = "";
341
+ this.emit("turn_end");
342
+ if (!this.openedAsVoice && this.agentSpeaking) {
343
+ this.agentSpeaking = false;
344
+ this.emit("agent_speaking", false);
345
+ }
331
346
  }
332
347
  break;
333
348
  }
@@ -341,6 +356,7 @@ export class GraineInAppClient {
341
356
  void this.runAction(msg.action);
342
357
  break;
343
358
  case "audio":
359
+ this.agentAudioArrived();
344
360
  this.emit("audio", {
345
361
  data: msg.data,
346
362
  format: msg.meta_info?.format ?? null,
@@ -357,6 +373,9 @@ export class GraineInAppClient {
357
373
  break;
358
374
  case "clear":
359
375
  case "interruption":
376
+ clearTimeout(this.speakingTimer);
377
+ this.speakingTimer = null;
378
+ this.spokenTurn = "";
360
379
  this.agentSpeaking = false;
361
380
  this.emit("clear");
362
381
  break;
@@ -684,7 +703,33 @@ export class GraineInAppClient {
684
703
  if (summary)
685
704
  this.say(summary);
686
705
  }
706
+ agentAudioArrived() {
707
+ this.lastAgentAudioAt = Date.now();
708
+ if (!this.agentSpeaking) {
709
+ this.agentSpeaking = true;
710
+ this.emit("agent_speaking", true);
711
+ }
712
+ this.scheduleSpeakingEnd(SPEAKING_TAIL_MS);
713
+ }
714
+ scheduleSpeakingEnd(delayMs) {
715
+ clearTimeout(this.speakingTimer);
716
+ this.speakingTimer = setTimeout(() => {
717
+ this.speakingTimer = null;
718
+ if (!this.agentSpeaking)
719
+ return;
720
+ const quietMs = Date.now() - this.lastAgentAudioAt;
721
+ const buffered = this.playoutClock?.() ?? 0;
722
+ if (quietMs >= SPEAKING_TAIL_MS && buffered <= 0.02) {
723
+ this.agentSpeaking = false;
724
+ this.emit("agent_speaking", false);
725
+ return;
726
+ }
727
+ this.scheduleSpeakingEnd(Math.min(1000, Math.max(50, buffered * 1000, SPEAKING_TAIL_MS - quietMs)));
728
+ }, delayMs);
729
+ }
687
730
  stopTimers() {
731
+ clearTimeout(this.speakingTimer);
732
+ this.speakingTimer = null;
688
733
  clearInterval(this.pingTimer);
689
734
  clearTimeout(this.stallTimer);
690
735
  clearTimeout(this.contextTimer);
package/dist/index.d.ts CHANGED
@@ -20,7 +20,7 @@ export { PENDING_WIDGET_MS, AWAITING_REPLY_MS, MIN_LOADER_MS } from "./react-nat
20
20
  export type { GraineWidgetProps, GraineWidgetTheme } from "./react-native/widget.js";
21
21
  export { GraineInAppClient, type GraineInAppOptions, type SessionConfig, type ActionDiagnosis, } from "./client.js";
22
22
  export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, type FrictionConfig, type FrictionScreenRules, type FrictionNudge, type FrictionSignal, type FrictionKind, } from "./friction.js";
23
- export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice.js";
23
+ export { VoiceSession, AGENT_PCM_RATE, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice.js";
24
24
  export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio.js";
25
25
  export { addMaskRule, maskDeep, maskString } from "./mask.js";
26
26
  export { GraineProvider, useGraineAgent, useGraineSurface, useGraineConnection, useGraineVariables, useKeyboardLift, liftFor, useDraggablePlacement, clampOffset, offsetDuringDrag, isTap, DRAG_SLOP_PX, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, useGraineInit, useGraineTheme, type GraineEvent, type GraineProviderProps, type SvgKit, type Turn, type Caption, } from "./react-native/index.js";
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ export { GraineLoader, useGraineLoader } from "./react-native/loader.js";
11
11
  export { PENDING_WIDGET_MS, AWAITING_REPLY_MS, MIN_LOADER_MS } from "./react-native/index.js";
12
12
  export { GraineInAppClient, } from "./client.js";
13
13
  export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, } from "./friction.js";
14
- export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice.js";
14
+ export { VoiceSession, AGENT_PCM_RATE, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice.js";
15
15
  export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio.js";
16
16
  export { addMaskRule, maskDeep, maskString } from "./mask.js";
17
17
  export { GraineProvider, useGraineAgent, useGraineSurface, useGraineConnection, useGraineVariables, useKeyboardLift, liftFor, useDraggablePlacement, clampOffset, offsetDuringDrag, isTap, DRAG_SLOP_PX, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, useGraineInit, useGraineTheme, } from "./react-native/index.js";
@@ -194,6 +194,7 @@ export interface AgentChunk {
194
194
  startsTurn: boolean;
195
195
  endsTurn: boolean;
196
196
  }
197
+ export declare function spokenJoinGap(soFar: string, next: string): string;
197
198
  export declare function readAgentChunk(raw: unknown): AgentChunk;
198
199
  export type ConnectionQuality = "excellent" | "good" | "poor" | "lost" | "unknown";
199
200
  export interface QualitySample {
package/dist/protocol.js CHANGED
@@ -322,6 +322,17 @@ export function resolveSurface(appearance, prefer) {
322
322
  }
323
323
  export const STREAM_START = "<beginning_of_stream>";
324
324
  export const STREAM_END = "<end_of_stream>";
325
+ export function spokenJoinGap(soFar, next) {
326
+ if (!soFar || !next)
327
+ return "";
328
+ if (/\s$/.test(soFar) || /^\s/.test(next))
329
+ return "";
330
+ if (/^[,.;:!?)\]}…%'’”»।॥]/.test(next))
331
+ return "";
332
+ if (/[(\[{“‘«\/-]$/.test(soFar))
333
+ return "";
334
+ return " ";
335
+ }
325
336
  export function readAgentChunk(raw) {
326
337
  const text = raw == null ? "" : String(raw);
327
338
  if (!text)
@@ -43,6 +43,7 @@ interface GraineContextValue {
43
43
  fonts: Record<string, string> | null;
44
44
  faces: LoadedFaces;
45
45
  svg: SvgKit | null;
46
+ endOnBackground: boolean;
46
47
  sessionReady: boolean;
47
48
  onEvent: (fn: (e: GraineEvent) => void) => () => void;
48
49
  track: (name: string, data?: Record<string, unknown>) => void;
@@ -390,9 +390,10 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
390
390
  connectionQuality,
391
391
  network,
392
392
  setVariables: (vars) => client.setVariables(vars),
393
+ endOnBackground,
393
394
  }), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption,
394
395
  launcherVisible, launcherInset, currentScreen, onEvent, appearance, design, fonts, faces, svgKit, sessionReady,
395
- surface, surfacePolicy, setSurface, connectionQuality, network]);
396
+ surface, surfacePolicy, setSurface, connectionQuality, network, endOnBackground]);
396
397
  const wantsCapture = captureTaps ?? !!fab;
397
398
  const body = fab || wantsCapture ? (_jsxs(GraineTouchCapture, { enabled: wantsCapture, children: [children, fab ? _jsx(GraineFab, { ...fab }) : null] })) : children;
398
399
  return _jsx(Ctx.Provider, { value: value, children: body });
@@ -1,12 +1,15 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { useCallback, useContext, useEffect, useRef, useState } from "react";
3
3
  import { AppState, View } from "react-native";
4
- import { useGraineAgent } from "./index.js";
4
+ import { Ctx, useGraineAgent } from "./index.js";
5
5
  import { requestMicrophonePermission } from "./permissions.js";
6
6
  export const CAPTION_HISTORY = 12;
7
7
  export const SLOW_CONNECT_MS = 4000;
8
8
  export function GraineVoiceLauncher({ webView: WebView, autoStart = false, active: activeProp = true, transport, onTransport, onCaption, onCallState, onMicDenied, onError, timing, onEnded, children, }) {
9
9
  const { client, appearance, surface, surfaces } = useGraineAgent();
10
+ const endOnBackground = useContext(Ctx)?.endOnBackground ?? true;
11
+ const endOnBackgroundRef = useRef(endOnBackground);
12
+ endOnBackgroundRef.current = endOnBackground;
10
13
  const active = activeProp && surface !== "chat";
11
14
  const ref = useRef(null);
12
15
  useEffect(() => {
@@ -518,7 +521,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
518
521
  post({ type: "graine:warm" });
519
522
  return;
520
523
  }
521
- if (next === "background" && wasActive && !pending) {
524
+ if (next === "background" && wasActive && !pending && endOnBackgroundRef.current) {
522
525
  pending = setTimeout(() => {
523
526
  pending = null;
524
527
  if (AppState.currentState !== "active")
package/dist/voice.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { GraineInAppClient } from "./client.js";
2
2
  export declare const CAPTURE_SAMPLE_RATE = 16000;
3
+ export declare const AGENT_PCM_RATE = 24000;
3
4
  export declare const MIN_PLAYOUT_BUFFER_SECONDS = 0.15;
4
5
  export interface AudioAdapter {
5
6
  startCapture(onChunk: (base64: string) => void): Promise<void> | void;
@@ -23,6 +24,7 @@ export declare class VoiceSession {
23
24
  private echo;
24
25
  private agentAudible;
25
26
  private lastAudioAt;
27
+ private audibleUntil;
26
28
  constructor(client: GraineInAppClient, adapter: AudioAdapter, options?: VoiceSessionOptions);
27
29
  get active(): boolean;
28
30
  start(): Promise<void>;
package/dist/voice.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { EchoGuard, base64ToBytes, decodeAgentAudio, decodePcm16 } from "./audio.js";
2
2
  export const CAPTURE_SAMPLE_RATE = 16000;
3
+ export const AGENT_PCM_RATE = 24000;
3
4
  export const MIN_PLAYOUT_BUFFER_SECONDS = 0.15;
4
5
  export function base64ToPcm16(b64) {
5
6
  return decodePcm16(base64ToBytes(b64));
@@ -15,6 +16,7 @@ export class VoiceSession {
15
16
  this.echo = new EchoGuard();
16
17
  this.agentAudible = false;
17
18
  this.lastAudioAt = 0;
19
+ this.audibleUntil = 0;
18
20
  }
19
21
  get active() {
20
22
  return this.capturing;
@@ -25,13 +27,14 @@ export class VoiceSession {
25
27
  this.capturing = true;
26
28
  this.offs = [
27
29
  this.client.on("audio", ({ data, format, sampleRate, }) => {
28
- const decoded = decodeAgentAudio(base64ToBytes(data), { format, sampleRate }, this.format);
30
+ const decoded = decodeAgentAudio(base64ToBytes(data), { format, sampleRate }, this.format, AGENT_PCM_RATE);
29
31
  if (!decoded)
30
32
  return;
31
33
  if (decoded.latch)
32
34
  this.format = decoded.latch;
33
35
  this.lastAudioAt = Date.now();
34
36
  this.adapter.play(decoded.pcm16, decoded.sampleRate);
37
+ this.audibleUntil = Date.now() + (this.adapter.bufferedSeconds?.() ?? 0) * 1000;
35
38
  }),
36
39
  this.client.on("clear", () => {
37
40
  this.adapter.clear();
@@ -59,7 +62,7 @@ export class VoiceSession {
59
62
  });
60
63
  }
61
64
  agentIsAudible() {
62
- if (Date.now() - this.lastAudioAt > 1500)
65
+ if (Date.now() - Math.max(this.lastAudioAt, this.audibleUntil) > 1500)
63
66
  return false;
64
67
  const buffered = this.adapter.bufferedSeconds?.();
65
68
  return buffered !== undefined ? buffered > 0.02 || this.agentAudible : this.agentAudible;
@@ -71,6 +74,7 @@ export class VoiceSession {
71
74
  this.format = null;
72
75
  this.agentAudible = false;
73
76
  this.lastAudioAt = 0;
77
+ this.audibleUntil = 0;
74
78
  this.echo.reset();
75
79
  this.offs.forEach((off) => off());
76
80
  this.offs = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.40.0",
3
+ "version": "0.40.1",
4
4
  "type": "module",
5
5
  "description": "Graine in-app agent for React Native — an agent that sees the screen your customer is on and can act on it.",
6
6
  "license": "MIT",