@graineai/inapp-react-native 0.12.0 → 0.16.0

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
@@ -8,6 +8,87 @@ Package: `@graineai/inapp-react-native`
8
8
 
9
9
  ---
10
10
 
11
+ ## How it works, end to end
12
+
13
+ Two things travel between your app and the agent, and keeping them straight
14
+ explains most of this document.
15
+
16
+ **Media** is the customer's voice and the agent's. **Control** is everything the
17
+ agent knows about your app: the screen, who is on it, what just happened, and
18
+ what it is allowed to do about it. They are different problems — media wants
19
+ pacing and loss tolerance, control wants ordered, reliable delivery — so they are
20
+ carried differently, and the SDK is what hides that from you.
21
+
22
+ ```
23
+ YOUR APP GRAINE
24
+ ───────────────────────────────────── ──────────────────────────────
25
+
26
+ ┌───────────────────────────────────┐
27
+ │ your screens │
28
+ │ useGraineScreen(...) ──────┐ │
29
+ │ useGraineAction(...) ──────┤ │
30
+ │ identify() / trackEvent() │ │
31
+ └───────────────────────────────┼───┘
32
+ │ one client, one source of truth
33
+
34
+ ┌─────────────────────────┐
35
+ │ GraineInAppClient │
36
+ │ screen · identity │
37
+ │ events · actions │
38
+ │ buildContextFrame() │
39
+ │ maskDeep() ← PII never │
40
+ │ leaves the device │
41
+ └───────────┬─────────────┘
42
+
43
+ ┌───────────▼─────────────┐
44
+ │ GraineVoiceLauncher │
45
+ │ mic · lifecycle · relay│
46
+ └───────────┬─────────────┘
47
+ │ postMessage
48
+ ┌───────────▼─────────────┐
49
+ │ hidden WebView (1×1) │ ┌──────────────┐
50
+ │ the audio engine │══ media ══▶│ the agent │
51
+ │ capture · playback │ │ │
52
+ │ jitter buffer · barge-in│──control ─▶│ runtime │
53
+ └─────────────────────────┘ └──────┬───────┘
54
+
55
+ ▲ │
56
+ └──────── app_action ────────────┘
57
+ "open the offers screen"
58
+ (the agent BLOCKS on your answer)
59
+ ```
60
+
61
+ **Why a WebView.** React Native has no `getUserMedia`. The alternative is a
62
+ native audio module, which means a store release for every audio fix — and the
63
+ audio path is exactly where the fixes are. The page is one pixel, invisible and
64
+ unreachable; your customer only ever sees your own UI. See *Why there is no
65
+ LiveKit step* at the end.
66
+
67
+ ### What travels, and when
68
+
69
+ | Frame | Direction | When |
70
+ |---|---|---|
71
+ | `app_context` | app → agent | Every screen change and field edit, throttled. Masked first. |
72
+ | `app_action` | agent → app | The agent wants to do something. **It blocks on your reply** (~6s). |
73
+ | `app_action_result` | app → agent | Your handler's answer: `ok`, `refused`, or `error`. |
74
+ | `app_event` | app → agent | Something happened that may be worth speaking about unprompted. |
75
+
76
+ ### The one rule that matters
77
+
78
+ **One client.** `GraineProvider` builds a client unless you hand it one. If your
79
+ app already has a module-level instance — and any app of size does, because
80
+ `identify()` and `trackEvent()` are not all called from React — pass it in:
81
+
82
+ ```tsx
83
+ <GraineProvider client={myClient} autoConnect={false}>
84
+ ```
85
+
86
+ Two clients is the failure that looks like a broken product: your screens write
87
+ the context into one, the launcher reads the other and finds nothing, and the
88
+ agent connects, greets, and says it cannot see the screen.
89
+
90
+ ---
91
+
11
92
  ## What you need
12
93
 
13
94
  | | |
@@ -89,12 +170,18 @@ export default function App() {
89
170
  baseUrl="https://www.graine.ai"
90
171
  publishableKey="pk_live_..."
91
172
  navigationRef={navigationRef}
173
+ // The launcher owns the connection — see below.
174
+ autoConnect={false}
92
175
  >
93
176
  <NavigationContainer ref={navigationRef}>
94
177
  <RootNavigator />
95
178
  </NavigationContainer>
96
179
 
97
- <GraineVoiceLauncher webView={WebView} autoStart>
180
+ <GraineVoiceLauncher
181
+ webView={WebView}
182
+ autoStart
183
+ onError={(m) => console.warn("[graine]", m)}
184
+ >
98
185
  {() => <GraineAgentBar />}
99
186
  </GraineVoiceLauncher>
100
187
  </GraineProvider>
@@ -102,6 +189,16 @@ export default function App() {
102
189
  }
103
190
  ```
104
191
 
192
+ **`autoConnect={false}` is not optional here.** The provider opens a connection
193
+ of its own by default, and `GraineVoiceLauncher` opens the one that carries the
194
+ call. Leave both on and the agent answers twice — the customer hears two
195
+ greetings over each other, and you are billed for two conversations. Drop the
196
+ launcher and use text only, and `autoConnect` goes back to its default.
197
+
198
+ **Pass `onError`.** A rejected key or an agent that has not been enabled for apps
199
+ leaves the launcher rendering nothing at all, forever, with nothing in the
200
+ console. This is the only way to find out.
201
+
105
202
  `navigationRef` is the line that matters. With it the SDK reads the current route
106
203
  itself, so the agent knows which screen the customer is on without you calling
107
204
  anything per screen — including the screen you add next year when nobody
@@ -135,6 +232,63 @@ agent just does not know their name.
135
232
 
136
233
  ---
137
234
 
235
+ ### Metadata before the first word
236
+
237
+ Two places, and the difference is when you know the value.
238
+
239
+ ```tsx
240
+ // Known at build time — carried in the init frame, before the agent speaks.
241
+ <GraineProvider variables={{ tier: 'gold', region: 'IN' }} />
242
+
243
+ // Known after sign-in — merged into that same init frame if it lands before the
244
+ // socket opens, sent on the next context frame if it lands after.
245
+ identify({ name: user.name, plan: user.plan });
246
+ ```
247
+
248
+ `identify()` wins over `variables` on the same key: a signed-in customer is more
249
+ specific than a default. Both are masked on the device before they leave it.
250
+
251
+ ### It reaches the call record, not only the prompt
252
+
253
+ Traits are stored on the conversation as `app_user`, and they are what labels
254
+ the row in Call History. **A web call has no phone number**, so without this
255
+ every in-app conversation shows a bare session id — a page of uuids.
256
+
257
+ Read from the LIVE identity rather than the init frame, so a customer who signs
258
+ in halfway through is identified from that moment rather than from a frame that
259
+ predates them.
260
+
261
+ #### Tag the session with whatever you file people under
262
+
263
+ You do not have to have a `name`. Call History looks for a label in this order:
264
+
265
+ `name` → `full_name` / `fullName` → `display_name` / `displayName` → `label` →
266
+ `username` / `user_name` → `customer_name` → `company` → `email` → **any other
267
+ string trait**
268
+
269
+ So an app that files people by policy number, customer id or internal handle is
270
+ still legible:
271
+
272
+ ```tsx
273
+ identify({ label: `Policy ${policy.number}` }); // → "Policy HDFC-4471"
274
+ identify({ customer_name: acct.holder }); // → "R. Bhanot"
275
+ identify({ crm_id: lead.id }); // → falls through to the id
276
+ ```
277
+
278
+ Two rules worth knowing:
279
+
280
+ - **Redacted values are skipped.** `phone` and `email` are masked on the device,
281
+ so a row never renders `[PHONE]` as somebody's name. Send the phone anyway if
282
+ it is useful — the agent is told a number is on file without being told the
283
+ digits — just do not rely on it as the label.
284
+ - **Identity alone is enough.** You do not need `useGraineScreen` for this. An
285
+ app that only calls `identify()` still tags every conversation.
286
+
287
+ `identify(null)` on sign-out. An agent still greeting the previous account by
288
+ name after a switch is worse than one that never knew it.
289
+
290
+ ---
291
+
138
292
  ## Step 3 — Report what is ON the screen
139
293
 
140
294
  The route gives the agent a screen *name*. This gives it the contents.
@@ -218,6 +372,23 @@ updating the screen.
218
372
 
219
373
  ---
220
374
 
375
+ ### Declaring them: paste, do not retype
376
+
377
+ Keep the list in your repo — `docs/agent-actions.json` is the convention — and
378
+ load it with **Embed & Widgets → App actions → Import from JSON**. A bare array
379
+ works, so does `{ "appActions": [...] }`.
380
+
381
+ Names must match your `useGraineAction` handlers exactly, and that is the whole
382
+ argument for importing: a name one underscore out declares a tool your app will
383
+ refuse for the life of the release, and the agent keeps trying it. `enum`
384
+ constraints are preserved, so a value the model cannot get wrong stays that way.
385
+
386
+ Import replaces the panel's contents rather than merging — the file is the
387
+ source of truth, and a merge leaves actions declared here and implemented
388
+ nowhere. Save is what pushes the catalogue to the agent.
389
+
390
+ ---
391
+
221
392
  ## Step 5 — Product events, without interrupting
222
393
 
223
394
  ```tsx
@@ -235,6 +406,30 @@ and the runtime decides whether it is worth interrupting for.
235
406
 
236
407
  ---
237
408
 
409
+ ### When something should be spoken about
410
+
411
+ ```tsx
412
+ const { client } = useGraineAgent();
413
+ client.reportEvent({
414
+ name: 'mandate_failed',
415
+ detail: 'Their mandate was declined twice. Offer to switch to a card.',
416
+ });
417
+ ```
418
+
419
+ `track()` is "know this when they next ask". `reportEvent()` is "this may be
420
+ worth interrupting for". `detail` tells the agent what to OFFER — without it the
421
+ agent reads an event name aloud, which is worse than saying nothing.
422
+
423
+ Reserve it for a dead end the customer would want addressed without asking: a
424
+ save that never reached the server, an upload that failed twice, a payment
425
+ declined. Not a tap, not a navigation, not a save that worked.
426
+
427
+ The runtime still decides whether to speak: silent while it is talking, while a
428
+ reply is generating, for 20s after the last intervention, and after three in a
429
+ session.
430
+
431
+ ---
432
+
238
433
  ## Step 6 — React to the conversation
239
434
 
240
435
  ```tsx
@@ -406,6 +601,12 @@ than opening a second socket. This matters under React 18 strict mode, which
406
601
  mounts effects twice: previously the first socket was orphaned with no reference
407
602
  to close it, and the runtime kept it open and billing until its own timeout.
408
603
 
604
+ **Nothing is left holding the call.** An outstanding action's waiter is released
605
+ in a `finally`, so a customer closing the app mid-action releases it too, and
606
+ every wait has a deadline — an app that never answers cannot hold the turn open
607
+ against someone who has already gone. A timeout is reported rather than
608
+ swallowed, because silence would have the agent claim the change landed.
609
+
409
610
  **`close()` sends a stop frame** before dropping the socket, so the conversation
410
611
  is filed as ended rather than as a customer who vanished mid-turn. It is called
411
612
  for you on unmount and on backgrounding.
@@ -445,6 +646,29 @@ Echo. The SDK suppresses the agent's own voice from the microphone, but a very
445
646
  loud speaker defeats a level-based guard. Lower the volume, or use earphones to
446
647
  confirm that is what it is.
447
648
 
649
+ **Nothing at all happens, and `onError` never fires either.**
650
+ You did not pass `onError`. Without it a failed session — a rejected key, an
651
+ agent not enabled for apps — leaves the launcher rendering nothing, silently.
652
+ Pass it before you debug anything else.
653
+
654
+ **On `rtc`: every call reports a fallback to `ws`.**
655
+ `onTransport` gives a `reason`. `unreachable` means WebRTC could not be reached
656
+ at all — usually no SIP realm on the org, or the SBC is not reachable from the
657
+ customer's network. `screen_channel` means the audio connected but the control
658
+ socket did not join, so the call was restarted on the WebSocket rather than run
659
+ blind. Both are org configuration, not integration code.
660
+
661
+ **On `rtc`: the greeting plays twice, occasionally.**
662
+ That is the `screen_channel` fallback restarting the call. It only happens when
663
+ the control channel fails to join within five seconds, and it is deliberate: an
664
+ in-app agent that cannot see the screen is not a lesser version of the product.
665
+ If it happens on every call, fix the control channel rather than living with it.
666
+
667
+ **Audio is muffled or crackly on Android but fine on iOS.**
668
+ Update the SDK. Versions before 0.15.0 asked the platform for the stream's own
669
+ sample rate; iOS refuses and falls back to the device rate, Android grants it
670
+ and resamples in its output stage, which is where the artefacts came from.
671
+
448
672
  ---
449
673
 
450
674
  ## Reference
@@ -459,12 +683,68 @@ confirm that is what it is.
459
683
  | `useGraineEvents()` | conversation lifecycle |
460
684
  | `useGraineVoice()` | only if you bring your own native audio instead of the WebView |
461
685
 
462
- `GraineProvider`: `baseUrl`, `publishableKey`, `navigationRef`, `includeScreens`,
463
- `launcherDelayMs`, `visibility`, `autoConnect`, `onProactive`.
686
+ ### `GraineProvider`
464
687
 
465
- `GraineVoiceLauncher`: `webView` (required), `autoStart`, `onCaption`,
466
- `onCallState`, and a child function receiving `{ connected, connecting, muted,
467
- start, end, setMuted }`.
688
+ | Prop | Type | Notes |
689
+ |---|---|---|
690
+ | `publishableKey` | `string` | Required unless you pass `client`. |
691
+ | `baseUrl` | `string` | Defaults to Graine's host. |
692
+ | `client` | `GraineInAppClient` | Use **your** instance instead of building one. Pass this if your app has a module-level client. |
693
+ | `navigationRef` | `ref` | The same ref you give `NavigationContainer`. Makes the agent screen-aware with no per-screen code. |
694
+ | `includeScreens` | `string[]` | Route names where the launcher may appear. Omit for everywhere. |
695
+ | `launcherDelayMs` | `number` | Delay before the launcher appears on an eligible screen. |
696
+ | `visibility` | `LauncherVisibility` | Groups, continuity, per-group delays and insets. See *Controlling where the launcher appears*. |
697
+ | `autoConnect` | `boolean` | Default `true`. Set `false` when `GraineVoiceLauncher` owns the connection — see below. |
698
+ | `onProactive` | `(text) => void` | The agent spoke first; draw your own nudge. |
699
+ | `endOnBackground` | `boolean` | Default `true`. You want it on. |
700
+
701
+ ### `GraineVoiceLauncher`
702
+
703
+ | Prop | Type | Notes |
704
+ |---|---|---|
705
+ | `webView` | `ComponentType` | **Required.** Pass `react-native-webview`'s `WebView`. Passed in rather than imported so the package installs without it. |
706
+ | `autoStart` | `boolean` | Start talking as soon as the page is ready. |
707
+ | `transport` | `"ws" \| "rtc"` | Which transport carries **audio**. See below. |
708
+ | `onTransport` | `({transport, requested}) => void` | Which transport actually carried the call, including after a fallback. |
709
+ | `onCaption` | `({role, text, live}) => void` | Each caption line, for your own UI. |
710
+ | `onCallState` | `({connected, connecting}) => void` | Connection state. |
711
+ | `onEnded` | `() => void` | The call is over. Fires once. Give the goodbye ~400ms to play before you unmount. |
712
+ | `onMicDenied` | `(reason) => void` | `NotAllowedError` is a refusal; `NotFoundError` is a device with no usable microphone. Different sentences for a customer. |
713
+ | `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. |
714
+ | `children` | `(api) => ReactNode` | Receives `{ connected, connecting, muted, start, end, setMuted }`. |
715
+
716
+ **The launcher owns the connection.** It asks for the microphone, resolves the
717
+ session, holds the socket and relays your screen, actions and events onto it. So
718
+ set `autoConnect={false}` on the provider when you use it: two live connections
719
+ on one agent is two conversations, and the customer hears both greetings.
720
+
721
+ ### `transport` — how audio travels
722
+
723
+ **Omit it and the dashboard decides.** Agent → Embed & Widgets → *Call audio*
724
+ sets this for every app and site running the agent, so the transport can be
725
+ changed without shipping a release. Passing the prop overrides it.
726
+
727
+ `ws` (the default) sends raw PCM on the same socket that carries screen context
728
+ and in-app actions.
729
+
730
+ `rtc` hands media to jambonz's SBC — Opus, DTLS-SRTP, an adaptive jitter buffer,
731
+ and the platform's own echo canceller running below the microphone with the
732
+ playout signal as its reference. Better audio on a bad network.
733
+
734
+ **`rtc` carries the screen too.** The browser holds two connections — media to
735
+ the SBC, and a control socket for `app_context`, `app_action` and `app_event` —
736
+ and the server joins them by a session id the browser puts on the INVITE. So an
737
+ in-app agent on `rtc` sees exactly what it sees on `ws`. Frames sent before the
738
+ join completes are queued rather than dropped, because the first screen frame
739
+ almost always arrives while the phone is still ringing, and it is the one the
740
+ agent's opening line is composed from.
741
+
742
+ `rtc` **falls back.** WebRTC has more ways to be unavailable than a WebSocket —
743
+ no SIP realm on the org, an unreachable SBC, a network that eats UDP — and each
744
+ is a reason to carry the call differently rather than refuse it. The page
745
+ reconnects on the WebSocket and tells you through `onTransport`, so a permanent
746
+ misconfiguration is visible instead of being merely audible. A refused
747
+ microphone is **not** a transport problem and does not fall back.
468
748
 
469
749
  ---
470
750
 
package/README.md CHANGED
@@ -114,6 +114,45 @@ the outcome through the next `useGraineScreen` update.
114
114
 
115
115
  ## Voice
116
116
 
117
+ The short way: no native audio module, no rebuild, and audio fixes reach you
118
+ over the air.
119
+
120
+ ```tsx
121
+ import { WebView } from "react-native-webview";
122
+ import { GraineProvider, GraineVoiceLauncher, GraineAgentBar } from "@graineai/inapp-react-native";
123
+
124
+ <GraineProvider
125
+ baseUrl="…"
126
+ publishableKey="pk_live_…"
127
+ navigationRef={navigationRef}
128
+ autoConnect={false} // the launcher owns the connection
129
+ >
130
+ <RootNavigator />
131
+ <GraineVoiceLauncher
132
+ webView={WebView}
133
+ autoStart
134
+ onError={(m) => console.warn("[graine]", m)}
135
+ >
136
+ {() => <GraineAgentBar />}
137
+ </GraineVoiceLauncher>
138
+ </GraineProvider>
139
+ ```
140
+
141
+ `react-native-webview` is the only peer dependency and it autolinks. The
142
+ microphone and speaker live in a hidden one-pixel WebView — your customer never
143
+ sees a web page. Set `autoConnect={false}`: leave the provider connecting too
144
+ and the agent answers twice, so the customer hears two greetings and you are
145
+ billed for two conversations.
146
+
147
+ Audio travels on a WebSocket by default. Set **Embed & Widgets → Call audio** to
148
+ *High quality* for WebRTC — Opus, a jitter buffer and the device's own echo
149
+ canceller — which carries screen context just the same and falls back on its own
150
+ if it cannot connect. See `INTEGRATION.md`.
151
+
152
+ ### Bringing your own audio
153
+
154
+ Only if you already have a native audio module and want it used instead.
155
+
117
156
  ```tsx
118
157
  <GraineProvider baseUrl="…" publishableKey="pk_live_…" voice>
119
158
  ```
package/dist/audio.js CHANGED
@@ -60,7 +60,8 @@ export function decodeAgentAudio(bytes, declared, latched, fallbackPcmRate = 160
60
60
  const pcm16 = muLawWins ? asMuLaw : asPcm;
61
61
  const kind = muLawWins ? "mulaw" : "pcm";
62
62
  const sampleRate = muLawWins ? 8000 : declared?.sampleRate || fallbackPcmRate;
63
- const confident = rms(pcm16) >= CONFIDENT_RMS;
63
+ const confident = rms(asPcm) >= CONFIDENT_RMS &&
64
+ zeroCrossingRate(asMuLaw) !== zeroCrossingRate(asPcm);
64
65
  return { pcm16, sampleRate, kind, latch: confident ? { kind, sampleRate } : null };
65
66
  }
66
67
  const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ActionHandler, type AppActionResult, type AppEvent, type ScreenContext } from "./protocol";
1
+ import { type ActionHandler, type AppActionResult, type AppEvent, type ScreenContext } from "./protocol.js";
2
2
  export declare const SUBPROTOCOL = "graine.embed.v1";
3
3
  export interface GraineInAppOptions {
4
4
  baseUrl: string;
@@ -53,7 +53,14 @@ export declare class GraineInAppClient {
53
53
  executeAction(name: string, args?: Record<string, unknown>): Promise<AppActionResult>;
54
54
  private runAction;
55
55
  private invokeAction;
56
+ get isConnected(): boolean;
56
57
  getScreen(): ScreenContext | null;
58
+ getIdentity(): Record<string, string>;
59
+ getRecentEvents(): Array<{
60
+ name: string;
61
+ at: number;
62
+ data?: Record<string, unknown>;
63
+ }>;
57
64
  getAvailableActions(): string[];
58
65
  registerAction(name: string, handler: ActionHandler): () => void;
59
66
  get availableActions(): string[];
@@ -61,6 +68,12 @@ export declare class GraineInAppClient {
61
68
  reportEvent(event: AppEvent): void;
62
69
  track(name: string, data?: Record<string, unknown>): void;
63
70
  private scheduleScreen;
71
+ buildContextFrame(): {
72
+ type: "app_context";
73
+ context: Record<string, unknown>;
74
+ available_actions: string[];
75
+ seq: number;
76
+ } | null;
64
77
  private flushScreen;
65
78
  private armStall;
66
79
  identify(user: Record<string, unknown> | null): void;
package/dist/client.js CHANGED
@@ -1,5 +1,5 @@
1
- import { ACTION_DEADLINE_MS, CONTEXT_THROTTLE_MS, } from "./protocol";
2
- import { maskDeep } from "./mask";
1
+ import { ACTION_DEADLINE_MS, CONTEXT_THROTTLE_MS, } from "./protocol.js";
2
+ import { maskDeep } from "./mask.js";
3
3
  export const SUBPROTOCOL = "graine.embed.v1";
4
4
  const PING_MS = 25000;
5
5
  export class GraineInAppClient {
@@ -259,9 +259,18 @@ export class GraineInAppClient {
259
259
  };
260
260
  }
261
261
  }
262
+ get isConnected() {
263
+ return !!this.ws && this.ws.readyState === 1;
264
+ }
262
265
  getScreen() {
263
266
  return this.screen;
264
267
  }
268
+ getIdentity() {
269
+ return { ...this.identity };
270
+ }
271
+ getRecentEvents() {
272
+ return [...this.recentEvents];
273
+ }
265
274
  getAvailableActions() {
266
275
  return [...this.actions.keys()];
267
276
  }
@@ -287,6 +296,7 @@ export class GraineInAppClient {
287
296
  this.scheduleScreen();
288
297
  }
289
298
  reportEvent(event) {
299
+ this.emit("app_event", event);
290
300
  this.send({ type: "app_event", event });
291
301
  }
292
302
  track(name, data) {
@@ -299,6 +309,7 @@ export class GraineInAppClient {
299
309
  this.scheduleScreen();
300
310
  }
301
311
  scheduleScreen() {
312
+ this.emit("screen", { screen: this.screen, actions: this.getAvailableActions() });
302
313
  if (this.contextTimer) {
303
314
  this.pendingScreen = true;
304
315
  return;
@@ -313,21 +324,28 @@ export class GraineInAppClient {
313
324
  }
314
325
  }, CONTEXT_THROTTLE_MS);
315
326
  }
316
- flushScreen() {
317
- if (!this.screen)
318
- return false;
319
- const { availableActions, ...rest } = this.screen;
320
- return this.send({
327
+ buildContextFrame() {
328
+ const hasIdentity = Object.keys(this.identity).length > 0;
329
+ if (!this.screen && !hasIdentity && this.recentEvents.length === 0)
330
+ return null;
331
+ const { availableActions, ...rest } = (this.screen ?? {});
332
+ return {
321
333
  type: "app_context",
322
334
  context: maskDeep({
323
335
  ...rest,
324
- idle_ms: Date.now() - this.screenSince,
336
+ ...(this.screen ? { idle_ms: Date.now() - this.screenSince } : {}),
325
337
  ...(Object.keys(this.identity).length ? { user: this.identity } : {}),
326
338
  ...(this.recentEvents.length ? { recent_events: this.recentEvents } : {}),
327
339
  }),
328
340
  available_actions: (availableActions ?? this.availableActions).filter((a) => this.actions.has(a)),
329
341
  seq: ++this.screenSeq,
330
- });
342
+ };
343
+ }
344
+ flushScreen() {
345
+ const frame = this.buildContextFrame();
346
+ if (!frame)
347
+ return false;
348
+ return this.send(frame);
331
349
  }
332
350
  armStall() {
333
351
  clearTimeout(this.stallTimer);
@@ -362,8 +380,7 @@ export class GraineInAppClient {
362
380
  flat[k] = typeof v === "string" ? v : JSON.stringify(v);
363
381
  }
364
382
  this.identity = maskDeep(flat);
365
- if (this.screen)
366
- this.scheduleScreen();
383
+ this.scheduleScreen();
367
384
  }
368
385
  noteInteraction() {
369
386
  this.screenSince = Date.now();
package/dist/index.d.ts CHANGED
@@ -1,9 +1,10 @@
1
- export type { ActionHandler, AppActionRequest, AppActionResult, AppEvent, ScreenContext, ScreenField, } from "./protocol";
2
- export { GraineInAppClient, type GraineInAppOptions, type SessionConfig } from "./client";
3
- export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice";
4
- export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio";
5
- export { addMaskRule, maskDeep, maskString } from "./mask";
6
- export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index";
7
- export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps, type GraineBarTheme, } from "./react-native/ui";
8
- export { GraineVoiceLauncher, type GraineVoiceLauncherProps, type GraineVoiceApi, } from "./react-native/voice-launcher";
9
- export { LauncherVisibilityTracker, activeRouteName, type LauncherInset, type LauncherContinuity, type LauncherDelayPolicy, type LauncherGroup, type LauncherVisibility, type VisibilityDecision, } from "./react-native/navigation";
1
+ export type { ActionHandler, AppActionRequest, AppActionResult, AppEvent, ScreenContext, ScreenField, } from "./protocol.js";
2
+ export { GraineInAppClient, type GraineInAppOptions, type SessionConfig } from "./client.js";
3
+ export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice.js";
4
+ export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio.js";
5
+ export { addMaskRule, maskDeep, maskString } from "./mask.js";
6
+ export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index.js";
7
+ export { RtcVoiceSessionController, RtcVoiceError, type RtcVoiceState, type RtcVoiceErrorCode, type RtcVoiceSession, type RtcVoiceOptions, } from "./react-native/voice-rtc.js";
8
+ export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps, type GraineBarTheme, } from "./react-native/ui.js";
9
+ export { GraineVoiceLauncher, type GraineVoiceLauncherProps, type GraineVoiceApi, } from "./react-native/voice-launcher.js";
10
+ export { LauncherVisibilityTracker, activeRouteName, type LauncherInset, type LauncherContinuity, type LauncherDelayPolicy, type LauncherGroup, type LauncherVisibility, type VisibilityDecision, } from "./react-native/navigation.js";
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
- export { GraineInAppClient } from "./client";
2
- export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice";
3
- export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio";
4
- export { addMaskRule, maskDeep, maskString } from "./mask";
5
- export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, } from "./react-native/index";
6
- export { GraineAgentBar, GraineLauncher, } from "./react-native/ui";
7
- export { GraineVoiceLauncher, } from "./react-native/voice-launcher";
8
- export { LauncherVisibilityTracker, activeRouteName, } from "./react-native/navigation";
1
+ export { GraineInAppClient } from "./client.js";
2
+ export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice.js";
3
+ export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio.js";
4
+ export { addMaskRule, maskDeep, maskString } from "./mask.js";
5
+ export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, } from "./react-native/index.js";
6
+ export { RtcVoiceSessionController, RtcVoiceError, } from "./react-native/voice-rtc.js";
7
+ export { GraineAgentBar, GraineLauncher, } from "./react-native/ui.js";
8
+ export { GraineVoiceLauncher, } from "./react-native/voice-launcher.js";
9
+ export { LauncherVisibilityTracker, activeRouteName, } from "./react-native/navigation.js";
@@ -1,8 +1,8 @@
1
1
  import React from "react";
2
- import { GraineInAppClient, type GraineInAppOptions } from "../client";
3
- import { type AudioAdapter, type VoiceSessionOptions } from "../voice";
4
- import type { ActionHandler, ScreenContext } from "../protocol";
5
- import { type LauncherInset, type LauncherVisibility } from "./navigation";
2
+ import { GraineInAppClient, type GraineInAppOptions } from "../client.js";
3
+ import { type AudioAdapter, type VoiceSessionOptions } from "../voice.js";
4
+ import type { ActionHandler, ScreenContext } from "../protocol.js";
5
+ import { type LauncherInset, type LauncherVisibility } from "./navigation.js";
6
6
  interface GraineContextValue {
7
7
  client: GraineInAppClient;
8
8
  connected: boolean;
@@ -60,8 +60,9 @@ export interface Turn {
60
60
  text: string;
61
61
  live?: boolean;
62
62
  }
63
- export interface GraineProviderProps extends GraineInAppOptions {
63
+ export interface GraineProviderProps extends Partial<GraineInAppOptions> {
64
64
  children: React.ReactNode;
65
+ client?: GraineInAppClient;
65
66
  autoConnect?: boolean;
66
67
  onProactive?: (text: string) => void;
67
68
  endOnBackground?: boolean;
@@ -72,7 +73,7 @@ export interface GraineProviderProps extends GraineInAppOptions {
72
73
  launcherDelayMs?: number;
73
74
  visibility?: LauncherVisibility;
74
75
  }
75
- export declare function GraineProvider({ children, autoConnect, onProactive, endOnBackground, navigationRef, includeScreens, launcherDelayMs, visibility, ...options }: GraineProviderProps): React.JSX.Element;
76
+ export declare function GraineProvider({ children, autoConnect, onProactive, endOnBackground, navigationRef, includeScreens, launcherDelayMs, visibility, client: providedClient, ...options }: GraineProviderProps): React.JSX.Element;
76
77
  export declare function useGraineAgent(): GraineContextValue;
77
78
  export declare function useGraineEvents(handler: (e: GraineEvent) => void): void;
78
79
  export declare function useGraineTrack(): (name: string, data?: Record<string, unknown>) => void;
@@ -1,14 +1,16 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
3
3
  import { AppState } from "react-native";
4
- import { GraineInAppClient } from "../client";
5
- import { VoiceSession } from "../voice";
6
- import { LauncherVisibilityTracker, activeRouteName, } from "./navigation";
4
+ import { GraineInAppClient } from "../client.js";
5
+ import { VoiceSession } from "../voice.js";
6
+ import { LauncherVisibilityTracker, activeRouteName, } from "./navigation.js";
7
7
  const Ctx = createContext(null);
8
- export function GraineProvider({ children, autoConnect = true, onProactive, endOnBackground = true, navigationRef, includeScreens, launcherDelayMs = 0, visibility, ...options }) {
8
+ export function GraineProvider({ children, autoConnect = true, onProactive, endOnBackground = true, navigationRef, includeScreens, launcherDelayMs = 0, visibility, client: providedClient, ...options }) {
9
9
  const clientRef = useRef(null);
10
- if (!clientRef.current)
11
- clientRef.current = new GraineInAppClient(options);
10
+ if (!clientRef.current) {
11
+ clientRef.current =
12
+ providedClient ?? new GraineInAppClient(options);
13
+ }
12
14
  const client = clientRef.current;
13
15
  const [connected, setConnected] = useState(false);
14
16
  const [connecting, setConnecting] = useState(false);
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { ActivityIndicator, Image, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, useColorScheme, View, } from "react-native";
4
- import { useGraineAgent } from "./index";
4
+ import { useGraineAgent } from "./index.js";
5
5
  const DARK = {
6
6
  surface: "#17161A",
7
7
  border: "rgba(255,255,255,0.09)",
@@ -1,5 +1,10 @@
1
1
  import React from "react";
2
2
  export interface GraineVoiceLauncherProps {
3
+ transport?: "ws" | "rtc";
4
+ onTransport?: (info: {
5
+ transport: "ws" | "rtc";
6
+ requested: "ws" | "rtc";
7
+ }) => void;
3
8
  webView: React.ComponentType<any>;
4
9
  autoStart?: boolean;
5
10
  onCaption?: (c: {
@@ -13,6 +18,7 @@ export interface GraineVoiceLauncherProps {
13
18
  }) => void;
14
19
  onEnded?: () => void;
15
20
  onMicDenied?: (reason: string) => void;
21
+ onError?: (message: string) => void;
16
22
  children?: (api: GraineVoiceApi) => React.ReactNode;
17
23
  }
18
24
  export interface GraineVoiceApi {
@@ -23,4 +29,4 @@ export interface GraineVoiceApi {
23
29
  end: () => void;
24
30
  setMuted: (m: boolean) => void;
25
31
  }
26
- export declare function GraineVoiceLauncher({ webView: WebView, autoStart, onCaption, onCallState, onMicDenied, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
32
+ export declare function GraineVoiceLauncher({ webView: WebView, autoStart, transport, onTransport, onCaption, onCallState, onMicDenied, onError, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
@@ -1,10 +1,11 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
- import { AppState, View } from "react-native";
4
- import { useGraineAgent } from "./index";
5
- export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCaption, onCallState, onMicDenied, onEnded, children, }) {
3
+ import { AppState, PermissionsAndroid, Platform, View } from "react-native";
4
+ import { useGraineAgent } from "./index.js";
5
+ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, transport, onTransport, onCaption, onCallState, onMicDenied, onError, onEnded, children, }) {
6
6
  const { client, appearance } = useGraineAgent();
7
7
  const ref = useRef(null);
8
+ const startedRef = useRef(false);
8
9
  const [connected, setConnected] = useState(false);
9
10
  const [connecting, setConnecting] = useState(false);
10
11
  const [muted, setMutedState] = useState(false);
@@ -12,22 +13,86 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCap
12
13
  const post = useCallback((msg) => {
13
14
  ref.current?.injectJavaScript(`window.postMessage(${JSON.stringify(msg)}, "*"); true;`);
14
15
  }, []);
16
+ const [voiceAgentId, setVoiceAgentId] = useState(client.config ? client.config.webcallAgentId || client.config.agentId : null);
17
+ const onErrorRef = useRef(onError);
18
+ onErrorRef.current = onError;
19
+ useEffect(() => {
20
+ if (voiceAgentId)
21
+ return;
22
+ let cancelled = false;
23
+ (async () => {
24
+ try {
25
+ const cfg = client.config ?? (await client.session());
26
+ if (!cancelled)
27
+ setVoiceAgentId(cfg.webcallAgentId || cfg.agentId);
28
+ }
29
+ catch (err) {
30
+ if (!cancelled) {
31
+ onErrorRef.current?.(String(err?.message || err || "Voice is unavailable."));
32
+ }
33
+ }
34
+ })();
35
+ return () => { cancelled = true; };
36
+ }, [client, voiceAgentId]);
37
+ const ensureMic = useCallback(async () => {
38
+ if (Platform.OS !== "android")
39
+ return true;
40
+ try {
41
+ const perm = PermissionsAndroid?.PERMISSIONS?.RECORD_AUDIO;
42
+ if (!perm)
43
+ return true;
44
+ if (await PermissionsAndroid.check(perm))
45
+ return true;
46
+ const res = await PermissionsAndroid.request(perm, {
47
+ title: "Microphone",
48
+ message: "Allow the microphone so you can talk to the assistant.",
49
+ buttonPositive: "Allow",
50
+ });
51
+ return res === PermissionsAndroid.RESULTS.GRANTED;
52
+ }
53
+ catch {
54
+ return true;
55
+ }
56
+ }, []);
15
57
  const pushContext = useCallback(() => {
16
- const screen = client.getScreen();
17
- if (!screen)
58
+ const frame = client.buildContextFrame();
59
+ if (!frame)
18
60
  return;
19
- const { availableActions, ...rest } = screen;
20
61
  post({
21
62
  type: "graine:app-context",
22
- context: rest,
23
- availableActions: (availableActions ?? client.getAvailableActions()).filter((a) => client.getAvailableActions().includes(a)),
24
- seq: Date.now(),
63
+ context: frame.context,
64
+ availableActions: frame.available_actions,
65
+ seq: frame.seq,
25
66
  });
26
67
  }, [client, post]);
27
68
  useEffect(() => {
28
69
  if (ready)
29
70
  pushContext();
30
71
  }, [ready, pushContext]);
72
+ const beginCall = useCallback(async () => {
73
+ if (client.isConnected) {
74
+ console.warn("[graine] The provider has its own connection open while GraineVoiceLauncher " +
75
+ "is starting a call. Pass autoConnect={false} to <GraineProvider> — otherwise " +
76
+ "the agent answers twice and the customer hears two greetings.");
77
+ }
78
+ if (!(await ensureMic())) {
79
+ setConnecting(false);
80
+ onMicDenied?.("NotAllowedError");
81
+ return;
82
+ }
83
+ post({ type: "graine:start-call" });
84
+ }, [client, ensureMic, onMicDenied, post]);
85
+ useEffect(() => {
86
+ if (!connected)
87
+ return;
88
+ return client.on("app_event", (event) => {
89
+ pushContext();
90
+ const { name, ...rest } = (event ?? {});
91
+ if (!name)
92
+ return;
93
+ post({ type: "graine:app-event", name, data: rest });
94
+ });
95
+ }, [client, connected, post, pushContext]);
31
96
  const onMessage = useCallback(async (e) => {
32
97
  let msg;
33
98
  try {
@@ -37,15 +102,25 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCap
37
102
  return;
38
103
  }
39
104
  switch (msg.type) {
105
+ case "graine:transport":
106
+ onTransport?.({
107
+ transport: msg.transport === "rtc" ? "rtc" : "ws",
108
+ requested: msg.requested === "rtc" ? "rtc" : "ws",
109
+ });
110
+ return;
40
111
  case "graine:ready":
41
112
  setReady(true);
42
113
  pushContext();
43
- if (autoStart)
44
- post({ type: "graine:start-call" });
114
+ if (autoStart && !startedRef.current) {
115
+ startedRef.current = true;
116
+ void beginCall();
117
+ }
45
118
  return;
46
119
  case "graine:call":
47
120
  setConnected(!!msg.connected);
48
121
  setConnecting(!!msg.connecting);
122
+ if (!msg.connected && !msg.connecting)
123
+ startedRef.current = false;
49
124
  onCallState?.({ connected: !!msg.connected, connecting: !!msg.connecting });
50
125
  return;
51
126
  case "graine:muted":
@@ -75,15 +150,15 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCap
75
150
  return;
76
151
  }
77
152
  }
78
- }, [autoStart, client, onCaption, onCallState, onMicDenied, onEnded, post, pushContext]);
153
+ }, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onEnded, post, pushContext]);
79
154
  const api = {
80
155
  connected,
81
156
  connecting,
82
157
  muted,
83
158
  start: useCallback(() => {
84
159
  setConnecting(true);
85
- post({ type: "graine:start-call" });
86
- }, [post]),
160
+ void beginCall();
161
+ }, [beginCall]),
87
162
  end: useCallback(() => post({ type: "graine:end-call" }), [post]),
88
163
  setMuted: useCallback((m) => {
89
164
  setMutedState(m);
@@ -93,19 +168,34 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCap
93
168
  useEffect(() => () => { post({ type: "graine:end-call" }); }, [post]);
94
169
  useEffect(() => {
95
170
  let last = AppState.currentState;
171
+ let pending = null;
172
+ const CONFIRM_MS = 2000;
96
173
  const sub = AppState.addEventListener("change", (next) => {
97
174
  const wasActive = last === "active";
98
175
  last = next;
99
- if (next === "background" && wasActive)
100
- post({ type: "graine:end-call" });
176
+ if (next === "active") {
177
+ if (pending) {
178
+ clearTimeout(pending);
179
+ pending = null;
180
+ }
181
+ return;
182
+ }
183
+ if (next === "background" && wasActive && !pending) {
184
+ pending = setTimeout(() => {
185
+ pending = null;
186
+ if (AppState.currentState !== "active")
187
+ post({ type: "graine:end-call" });
188
+ }, CONFIRM_MS);
189
+ }
101
190
  });
102
- return () => sub.remove();
191
+ return () => { if (pending)
192
+ clearTimeout(pending); sub.remove(); };
103
193
  }, [post]);
104
- const agentId = client.config?.agentId;
105
- if (!agentId)
194
+ if (!voiceAgentId)
106
195
  return _jsx(_Fragment, { children: children?.(api) });
107
- const src = `${client.opts.baseUrl.replace(/\/$/, "")}/embed/${agentId}` +
108
- `?mode=voice&branding=0&embed=1` +
196
+ const effectiveTransport = transport ?? (appearance?.audioTransport === "rtc" ? "rtc" : "ws");
197
+ const src = `${client.opts.baseUrl.replace(/\/$/, "")}/embed/${voiceAgentId}` +
198
+ `?mode=voice&branding=0&embed=1&transport=${effectiveTransport}` +
109
199
  (appearance?.accent ? `&accent=${encodeURIComponent(String(appearance.accent).replace("#", ""))}` : "");
110
200
  return (_jsxs(_Fragment, { children: [_jsx(View, { style: { position: "absolute", width: 1, height: 1, opacity: 0, bottom: 0, left: 0 }, pointerEvents: "none", children: _jsx(WebView, { ref: ref, source: { uri: src }, originWhitelist: ["*"], javaScriptEnabled: true, domStorageEnabled: true, allowsInlineMediaPlayback: true, mediaPlaybackRequiresUserAction: false, mediaCapturePermissionGrantType: "grant", onMessage: onMessage, style: { width: 1, height: 1, backgroundColor: "transparent" } }) }), children?.(api)] }));
111
201
  }
@@ -0,0 +1,41 @@
1
+ export type RtcVoiceState = "idle" | "connecting" | "ringing" | "active" | "ended";
2
+ export type RtcVoiceErrorCode = "mint_failed" | "microphone_denied" | "connect_failed" | "already_active";
3
+ export declare class RtcVoiceError extends Error {
4
+ readonly code: RtcVoiceErrorCode;
5
+ readonly cause?: unknown;
6
+ constructor(code: RtcVoiceErrorCode, message: string, cause?: unknown);
7
+ }
8
+ export interface RtcVoiceSession {
9
+ server: string;
10
+ username: string;
11
+ password: string;
12
+ realm: string;
13
+ application_sid: string;
14
+ agent_id?: string;
15
+ expires_at?: string;
16
+ }
17
+ export interface RtcVoiceOptions {
18
+ publishableKey?: string;
19
+ baseUrl?: string;
20
+ getSession?: () => Promise<RtcVoiceSession>;
21
+ onState?: (s: RtcVoiceState) => void;
22
+ onEnded?: (reason: string) => void;
23
+ onLog?: (line: string) => void;
24
+ sessionId?: string;
25
+ }
26
+ export declare class RtcVoiceSessionController {
27
+ private client;
28
+ private call;
29
+ private state;
30
+ private startToken;
31
+ readonly sessionId: string;
32
+ private opts;
33
+ constructor(opts: RtcVoiceOptions);
34
+ getState(): RtcVoiceState;
35
+ private set;
36
+ private log;
37
+ private mint;
38
+ start(): Promise<void>;
39
+ setMuted(muted: boolean): void;
40
+ stop(): Promise<void>;
41
+ }
@@ -0,0 +1,143 @@
1
+ export class RtcVoiceError extends Error {
2
+ constructor(code, message, cause) {
3
+ super(message);
4
+ this.name = "RtcVoiceError";
5
+ this.code = code;
6
+ this.cause = cause;
7
+ }
8
+ }
9
+ function newSessionId() {
10
+ return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
11
+ }
12
+ export class RtcVoiceSessionController {
13
+ constructor(opts) {
14
+ this.client = null;
15
+ this.call = null;
16
+ this.state = "idle";
17
+ this.startToken = null;
18
+ this.opts = opts;
19
+ this.sessionId = opts.sessionId || newSessionId();
20
+ }
21
+ getState() {
22
+ return this.state;
23
+ }
24
+ set(s) {
25
+ this.state = s;
26
+ this.opts.onState?.(s);
27
+ }
28
+ log(l) {
29
+ this.opts.onLog?.(l);
30
+ }
31
+ async mint() {
32
+ if (this.opts.getSession)
33
+ return this.opts.getSession();
34
+ if (!this.opts.publishableKey) {
35
+ throw new RtcVoiceError("mint_failed", "Pass publishableKey (or getSession for a custom backend).");
36
+ }
37
+ const base = (this.opts.baseUrl || "https://www.graine.ai").replace(/\/$/, "");
38
+ let res;
39
+ try {
40
+ res = await fetch(`${base}/api/embed/rtc-session`, {
41
+ method: "POST",
42
+ headers: { "Content-Type": "application/json" },
43
+ body: JSON.stringify({ publishableKey: this.opts.publishableKey }),
44
+ });
45
+ }
46
+ catch (e) {
47
+ throw new RtcVoiceError("mint_failed", "Could not reach the call service.", e);
48
+ }
49
+ if (!res.ok) {
50
+ let detail = "";
51
+ try {
52
+ detail = (await res.json())?.error || "";
53
+ }
54
+ catch {
55
+ }
56
+ throw new RtcVoiceError("mint_failed", detail || `Could not start a call (${res.status}).`);
57
+ }
58
+ return res.json();
59
+ }
60
+ async start() {
61
+ if (this.state !== "idle" && this.state !== "ended") {
62
+ throw new RtcVoiceError("already_active", "A call is already in progress.");
63
+ }
64
+ const token = (this.startToken = Symbol("start"));
65
+ this.set("connecting");
66
+ try {
67
+ const session = await this.mint();
68
+ if (token !== this.startToken)
69
+ return;
70
+ this.log(`session for ${session.realm}`);
71
+ const { createJambonzClient } = await import("@jambonz/client-sdk-react-native");
72
+ this.client = createJambonzClient({
73
+ server: session.server,
74
+ username: session.username,
75
+ password: session.password,
76
+ realm: session.realm,
77
+ });
78
+ this.client.on("error", (e) => this.log(`client error: ${e?.message ?? e}`));
79
+ await this.client.connect();
80
+ if (token !== this.startToken) {
81
+ await this.stop();
82
+ return;
83
+ }
84
+ this.log("registered");
85
+ this.call = this.client.callApplication(session.application_sid, {
86
+ headers: {
87
+ "X-Graine-Session-Id": this.sessionId,
88
+ ...(session.agent_id ? { "X-Graine-Agent-Id": session.agent_id } : {}),
89
+ },
90
+ });
91
+ this.call.on("stateChanged", (s) => this.log(`call: ${s}`));
92
+ this.call.on("accepted", () => {
93
+ this.set("active");
94
+ this.log("audio flowing");
95
+ });
96
+ this.call.on("ended", (c) => {
97
+ this.set("ended");
98
+ this.opts.onEnded?.(c?.reason || "ended");
99
+ });
100
+ this.call.on("failed", (c) => {
101
+ this.set("ended");
102
+ this.opts.onEnded?.(c?.reason || "failed");
103
+ });
104
+ this.set("ringing");
105
+ }
106
+ catch (err) {
107
+ if (token !== this.startToken)
108
+ return;
109
+ this.set("ended");
110
+ throw err instanceof RtcVoiceError
111
+ ? err
112
+ : new RtcVoiceError("connect_failed", err?.message || "Could not connect.", err);
113
+ }
114
+ }
115
+ setMuted(muted) {
116
+ try {
117
+ if (muted)
118
+ this.call?.mute?.();
119
+ else
120
+ this.call?.unmute?.();
121
+ }
122
+ catch {
123
+ }
124
+ }
125
+ async stop() {
126
+ if (this.state === "idle" || this.state === "ended")
127
+ return;
128
+ this.startToken = null;
129
+ try {
130
+ this.call?.hangup?.();
131
+ }
132
+ catch {
133
+ }
134
+ try {
135
+ await this.client?.disconnect?.();
136
+ }
137
+ catch {
138
+ }
139
+ this.call = null;
140
+ this.client = null;
141
+ this.set("ended");
142
+ }
143
+ }
package/dist/voice.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { GraineInAppClient } from "./client";
1
+ import type { GraineInAppClient } from "./client.js";
2
2
  export declare const CAPTURE_SAMPLE_RATE = 16000;
3
3
  export declare const MIN_PLAYOUT_BUFFER_SECONDS = 0.15;
4
4
  export interface AudioAdapter {
package/dist/voice.js CHANGED
@@ -1,4 +1,4 @@
1
- import { EchoGuard, base64ToBytes, decodeAgentAudio, decodePcm16 } from "./audio";
1
+ import { EchoGuard, base64ToBytes, decodeAgentAudio, decodePcm16 } from "./audio.js";
2
2
  export const CAPTURE_SAMPLE_RATE = 16000;
3
3
  export const MIN_PLAYOUT_BUFFER_SECONDS = 0.15;
4
4
  export function base64ToPcm16(b64) {
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.12.0",
3
+ "version": "0.16.0",
4
+ "type": "module",
4
5
  "description": "Graine in-app agent for React Native — an agent that sees the screen your customer is on and can act on it.",
5
6
  "license": "MIT",
6
7
  "private": false,
@@ -35,7 +36,8 @@
35
36
  },
36
37
  "peerDependencies": {
37
38
  "react": ">=17",
38
- "react-native": ">=0.68"
39
+ "react-native": ">=0.68",
40
+ "@jambonz/client-sdk-react-native": "^0.1.4"
39
41
  },
40
42
  "devDependencies": {
41
43
  "@types/react": "^18.2.0",
@@ -60,5 +62,10 @@
60
62
  "homepage": "https://www.graine.ai/docs/in-app",
61
63
  "bugs": {
62
64
  "url": "https://www.graine.ai/docs/in-app"
65
+ },
66
+ "peerDependenciesMeta": {
67
+ "@jambonz/client-sdk-react-native": {
68
+ "optional": true
69
+ }
63
70
  }
64
71
  }