crowdplaysdk 0.4.2 → 0.5.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.
@@ -52,6 +52,17 @@ final class CrowdPlayRNCore {
52
52
  ])
53
53
  }.store(in: &cancellables)
54
54
 
55
+ // Agent tool calls (D-103): the JS layer answers via
56
+ // respondToAgentToolCall, so the native auto-handler stays unset.
57
+ engine.$latestAgentToolCall
58
+ .compactMap { $0 }
59
+ .sink { [weak self] call in
60
+ self?.send("crowdplay:agentToolCall", [
61
+ "callId": call.callId, "name": call.name,
62
+ "argumentsJSON": call.argumentsJSON,
63
+ ])
64
+ }.store(in: &cancellables)
65
+
55
66
  engine.$uploads
56
67
  .combineLatest(engine.$uploadsOnWiFi)
57
68
  .sink { [weak self] uploads, onWifi in
@@ -220,7 +231,7 @@ public final class CrowdPlayRNModule: RCTEventEmitter {
220
231
  public override static func requiresMainQueueSetup() -> Bool { true }
221
232
 
222
233
  public override func supportedEvents() -> [String]! {
223
- ["crowdplay:phase", "crowdplay:recording", "crowdplay:uploads", "crowdplay:participants", "crowdplay:warning", "crowdplay:audioRoute", "crowdplay:voiceActivity", "crowdplay:caption"]
234
+ ["crowdplay:phase", "crowdplay:recording", "crowdplay:uploads", "crowdplay:participants", "crowdplay:warning", "crowdplay:audioRoute", "crowdplay:voiceActivity", "crowdplay:caption", "crowdplay:agentToolCall"]
224
235
  }
225
236
 
226
237
  @objc public func startVoiceActivityUpdates() {
@@ -294,8 +305,30 @@ public final class CrowdPlayRNModule: RCTEventEmitter {
294
305
  let voice = agent["voice"] as? String
295
306
  let prompt = agent["systemPrompt"] as? String
296
307
  let context = agent["context"] as? String
297
- if voice != nil || prompt != nil || context != nil {
298
- agentOptions = CrowdPlayAgentOptions(voice: voice, systemPrompt: prompt, context: context)
308
+ // Tools cross the bridge as one JSON string (D-103); the server
309
+ // re-validates every entry.
310
+ var tools: [CrowdPlayAgentTool]?
311
+ if let toolsJSON = agent["toolsJSON"] as? String,
312
+ let data = toolsJSON.data(using: .utf8),
313
+ let entries = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
314
+ let parsed: [CrowdPlayAgentTool] = entries.compactMap { entry in
315
+ guard let name = entry["name"] as? String,
316
+ let description = entry["description"] as? String else { return nil }
317
+ var parametersJSON = "{}"
318
+ if let parameters = entry["parameters"],
319
+ JSONSerialization.isValidJSONObject(parameters),
320
+ let pData = try? JSONSerialization.data(withJSONObject: parameters),
321
+ let pString = String(data: pData, encoding: .utf8) {
322
+ parametersJSON = pString
323
+ }
324
+ return CrowdPlayAgentTool(name: name, description: description,
325
+ parametersJSON: parametersJSON)
326
+ }
327
+ if !parsed.isEmpty { tools = parsed }
328
+ }
329
+ if voice != nil || prompt != nil || context != nil || tools != nil {
330
+ agentOptions = CrowdPlayAgentOptions(voice: voice, systemPrompt: prompt,
331
+ context: context, tools: tools)
299
332
  }
300
333
  }
301
334
  Task { @MainActor in
@@ -360,6 +393,19 @@ public final class CrowdPlayRNModule: RCTEventEmitter {
360
393
  }
361
394
  }
362
395
 
396
+ @objc public func respondToAgentToolCall(_ callId: NSString, output: NSString) {
397
+ Task { @MainActor in
398
+ await CrowdPlayRNCore.shared.engine.respondToAgentToolCall(
399
+ callId: callId as String, output: output as String)
400
+ }
401
+ }
402
+
403
+ @objc public func updateAgentContext(_ text: NSString) {
404
+ Task { @MainActor in
405
+ await CrowdPlayRNCore.shared.engine.updateAgentContext(text as String)
406
+ }
407
+ }
408
+
363
409
  @objc public func retryRecording() {
364
410
  Task { @MainActor in
365
411
  let core = CrowdPlayRNCore.shared
@@ -25,6 +25,9 @@ RCT_EXTERN_METHOD(setCameraEnabled : (BOOL)enabled
25
25
  resolver : (RCTPromiseResolveBlock)resolve
26
26
  rejecter : (RCTPromiseRejectBlock)reject)
27
27
  RCT_EXTERN_METHOD(setAudioOutput : (NSString *)output)
28
+ RCT_EXTERN_METHOD(respondToAgentToolCall : (NSString *)callId
29
+ output : (NSString *)output)
30
+ RCT_EXTERN_METHOD(updateAgentContext : (NSString *)text)
28
31
  RCT_EXTERN_METHOD(retryRecording)
29
32
  RCT_EXTERN_METHOD(retryUploads)
30
33
  RCT_EXTERN_METHOD(warmUp)
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Drop-in consent screen. Shows CrowdPlay's locked consent wording (fetched from
3
- * the native SDK so it can never drift from what the manifest records), a
4
- * switch, and a continue button that only enables once the participant
5
- * agrees. Yields the ConsentGrant that join() requires.
3
+ * the native SDK so it can never drift from what the manifest records), a link
4
+ * to the full CrowdPlay Terms of Use and Privacy Notice, a switch, and a
5
+ * continue button that only enables once the participant agrees. Yields the
6
+ * ConsentGrant that join() requires.
6
7
  *
7
8
  * <CrowdPlayConsentScreen onConsent={(grant) => joinWith(grant)} />
8
9
  *
@@ -1,9 +1,10 @@
1
1
  "use strict";
2
2
  /**
3
3
  * Drop-in consent screen. Shows CrowdPlay's locked consent wording (fetched from
4
- * the native SDK so it can never drift from what the manifest records), a
5
- * switch, and a continue button that only enables once the participant
6
- * agrees. Yields the ConsentGrant that join() requires.
4
+ * the native SDK so it can never drift from what the manifest records), a link
5
+ * to the full CrowdPlay Terms of Use and Privacy Notice, a switch, and a
6
+ * continue button that only enables once the participant agrees. Yields the
7
+ * ConsentGrant that join() requires.
7
8
  *
8
9
  * <CrowdPlayConsentScreen onConsent={(grant) => joinWith(grant)} />
9
10
  *
@@ -44,14 +45,11 @@ var __importStar = (this && this.__importStar) || (function () {
44
45
  return result;
45
46
  };
46
47
  })();
47
- var __importDefault = (this && this.__importDefault) || function (mod) {
48
- return (mod && mod.__esModule) ? mod : { "default": mod };
49
- };
50
48
  Object.defineProperty(exports, "__esModule", { value: true });
51
49
  exports.CrowdPlayConsentScreen = CrowdPlayConsentScreen;
52
50
  const react_1 = __importStar(require("react"));
53
51
  const react_native_1 = require("react-native");
54
- const index_1 = __importDefault(require("./index"));
52
+ const index_1 = __importStar(require("./index"));
55
53
  function CrowdPlayConsentScreen({ onConsent, style }) {
56
54
  const [text, setText] = (0, react_1.useState)(null);
57
55
  const [agreed, setAgreed] = (0, react_1.useState)(false);
@@ -74,12 +72,15 @@ function CrowdPlayConsentScreen({ onConsent, style }) {
74
72
  return (<react_native_1.View style={[styles.container, style]}>
75
73
  <react_native_1.Text style={styles.title}>Recording consent</react_native_1.Text>
76
74
  {text === null ? (<react_native_1.ActivityIndicator />) : (<react_native_1.Text style={styles.body}>{text}</react_native_1.Text>)}
75
+ <react_native_1.Pressable accessibilityRole="link" onPress={() => void react_native_1.Linking.openURL(index_1.CROWDPLAY_TERMS_URL)}>
76
+ <react_native_1.Text style={styles.link}>Read the Terms of Use and Privacy Notice</react_native_1.Text>
77
+ </react_native_1.Pressable>
77
78
  <react_native_1.View style={styles.row}>
78
79
  <react_native_1.Switch value={agreed} onValueChange={(value) => {
79
80
  setAgreed(value);
80
81
  setAgreedAtMs(value ? Date.now() : null);
81
82
  }}/>
82
- <react_native_1.Text style={styles.agree}>I agree</react_native_1.Text>
83
+ <react_native_1.Text style={styles.agree}>I have read and agree to the CrowdPlay Terms of Use and Privacy Notice.</react_native_1.Text>
83
84
  </react_native_1.View>
84
85
  <react_native_1.Text style={styles.note}>
85
86
  Recording starts automatically when you join, and ends when you leave.
@@ -105,7 +106,8 @@ const styles = react_native_1.StyleSheet.create({
105
106
  title: { fontSize: 20, fontWeight: '700', color: '#111111' },
106
107
  body: { fontSize: 15, lineHeight: 21, color: '#222222' },
107
108
  row: { flexDirection: 'row', alignItems: 'center', gap: 10 },
108
- agree: { fontSize: 15, color: '#111111' },
109
+ agree: { flex: 1, fontSize: 15, color: '#111111' },
110
+ link: { fontSize: 15, color: '#1f6feb', textDecorationLine: 'underline' },
109
111
  note: { fontSize: 12, color: '#666666' },
110
112
  button: { backgroundColor: '#1f6feb', borderRadius: 8, paddingVertical: 12, alignItems: 'center' },
111
113
  buttonDisabled: { opacity: 0.4 },
package/lib/index.d.ts CHANGED
@@ -59,6 +59,9 @@ export interface CrowdPlayConfig {
59
59
  otherAudio?: 'interrupt' | 'mix' | 'mixDucked';
60
60
  callQuality?: CallQuality;
61
61
  }
62
+ /** The CrowdPlay Terms of Use and Privacy Notice every participant accepts,
63
+ * as a PDF. CrowdPlayConsentScreen links here; a custom consent UI must too. */
64
+ export declare const CROWDPLAY_TERMS_URL = "https://dashboard.crowdplay.ai/legal/terms.pdf";
62
65
  /** Proof the participant agreed. Produce it with CrowdPlayConsentScreen, or,
63
66
  * if you render your own consent UI, call CrowdPlay.consentText() to show the
64
67
  * REQUIRED wording and construct the grant at the moment of agreement. */
@@ -74,6 +77,17 @@ export interface ConsentGrant {
74
77
  * apply to the whole room. The app can never change the provider, the
75
78
  * model, or the API key from here.
76
79
  */
80
+ /** A tool the app offers the voice AI for one session (D-103). Requires
81
+ * the app's dashboard config to use the GPT-Live provider with an AI brain
82
+ * (delegation) enabled; ignored otherwise. */
83
+ export interface AgentTool {
84
+ /** Letters, digits, underscore, dash; max 64 chars. */
85
+ name: string;
86
+ /** What the tool does and when to use it, for the model. */
87
+ description: string;
88
+ /** JSON-schema object describing the arguments. Omit for none. */
89
+ parameters?: Record<string, unknown>;
90
+ }
77
91
  export interface AgentOptions {
78
92
  /** A voice of the app's configured provider. Gemini: Charon, Aoede,
79
93
  * Fenrir, Kore, Puck. OpenAI: marin, cedar, alloy, ash, ballad, coral,
@@ -87,6 +101,9 @@ export interface AgentOptions {
87
101
  * keep a few sentences per user in your own storage and pass them
88
102
  * here. Max 4000 chars. */
89
103
  context?: string;
104
+ /** Tools the AI may call this session (D-103). Handle calls with
105
+ * CrowdPlay.onAgentToolCall(). Max 16. */
106
+ tools?: AgentTool[];
90
107
  }
91
108
  export type Phase = 'idle' | 'connecting' | 'connected' | 'failed';
92
109
  export interface RecordingSnapshot {
@@ -167,6 +184,13 @@ export interface CrowdPlayEvents {
167
184
  detectedOutputs: string[];
168
185
  headphonesConnected: boolean;
169
186
  };
187
+ /** The voice AI asked the app to run a tool (D-103). Prefer
188
+ * CrowdPlay.onAgentToolCall(), which answers for you. */
189
+ agentToolCall: {
190
+ callId: string;
191
+ name: string;
192
+ argumentsJSON: string;
193
+ };
170
194
  }
171
195
  export type CrowdPlayEventName = keyof CrowdPlayEvents;
172
196
  declare const CrowdPlay: {
@@ -216,6 +240,19 @@ declare const CrowdPlay: {
216
240
  /** Integration self-check: configuration, permissions, disk, backend
217
241
  * reachability, app-key auth. Every failing check names its fix. */
218
242
  doctor(): Promise<DoctorCheck[]>;
243
+ /** Handle the voice AI's tool calls (D-103). The handler gets the tool
244
+ * name and its arguments (parsed JSON object) and returns the result as
245
+ * an object or JSON string; return null to decline on this device
246
+ * (another participant's device may answer; first answer wins). Ordinary
247
+ * app code, no AI here. The AI waits about 12 seconds per call. Returns
248
+ * a subscription; call .remove(). */
249
+ onAgentToolCall(handler: (name: string, args: Record<string, unknown>) => Promise<Record<string, unknown> | string | null> | Record<string, unknown> | string | null): {
250
+ remove(): void;
251
+ };
252
+ /** Tell the AI what just happened in the app, mid-session ("Night ended.
253
+ * Sam was eliminated."). Keep it to a sentence or two; long pushes are
254
+ * truncated. GPT-Live provider only; other providers ignore it. */
255
+ updateAgentContext(text: string): void;
219
256
  /** Subscribe to state events. Returns a subscription; call .remove(). */
220
257
  addListener<E extends CrowdPlayEventName>(event: E, listener: (payload: CrowdPlayEvents[E]) => void): {
221
258
  remove(): void;
package/lib/index.js CHANGED
@@ -22,8 +22,11 @@
22
22
  * path that records without it.
23
23
  */
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
- exports.useLatestCaption = exports.useVoiceActivity = exports.VoiceReactive = exports.CrowdPlayVoiceView = exports.CrowdPlayVideoView = exports.CrowdPlayConsentScreen = void 0;
25
+ exports.useLatestCaption = exports.useVoiceActivity = exports.VoiceReactive = exports.CrowdPlayVoiceView = exports.CrowdPlayVideoView = exports.CrowdPlayConsentScreen = exports.CROWDPLAY_TERMS_URL = void 0;
26
26
  const react_native_1 = require("react-native");
27
+ /** The CrowdPlay Terms of Use and Privacy Notice every participant accepts,
28
+ * as a PDF. CrowdPlayConsentScreen links here; a custom consent UI must too. */
29
+ exports.CROWDPLAY_TERMS_URL = 'https://dashboard.crowdplay.ai/legal/terms.pdf';
27
30
  function native() {
28
31
  const module = react_native_1.NativeModules.CrowdPlayReactNative;
29
32
  if (!module) {
@@ -102,6 +105,11 @@ const CrowdPlay = {
102
105
  put('voice', agent.voice, 60);
103
106
  put('systemPrompt', agent.systemPrompt, 8192);
104
107
  put('context', agent.context, 4000);
108
+ // Tools cross the bridge as one JSON string; the native layer and the
109
+ // server both re-validate (D-103).
110
+ if (Array.isArray(agent.tools) && agent.tools.length) {
111
+ put('toolsJSON', JSON.stringify(agent.tools.slice(0, 16)), 8192);
112
+ }
105
113
  }
106
114
  return native().join(displayName.trim(), roomCode, consent.grantedAtMs, agentFields);
107
115
  },
@@ -144,6 +152,37 @@ const CrowdPlay = {
144
152
  doctor() {
145
153
  return native().doctor();
146
154
  },
155
+ /** Handle the voice AI's tool calls (D-103). The handler gets the tool
156
+ * name and its arguments (parsed JSON object) and returns the result as
157
+ * an object or JSON string; return null to decline on this device
158
+ * (another participant's device may answer; first answer wins). Ordinary
159
+ * app code, no AI here. The AI waits about 12 seconds per call. Returns
160
+ * a subscription; call .remove(). */
161
+ onAgentToolCall(handler) {
162
+ const subscription = events().addListener('crowdplay:agentToolCall', (payload) => {
163
+ void (async () => {
164
+ let args = {};
165
+ try {
166
+ args = JSON.parse(payload.argumentsJSON);
167
+ }
168
+ catch { /* {} */ }
169
+ const result = await handler(payload.name, args);
170
+ if (result === null || result === undefined)
171
+ return;
172
+ const output = typeof result === 'string' ? result : JSON.stringify(result);
173
+ native().respondToAgentToolCall(payload.callId, output);
174
+ })();
175
+ });
176
+ return { remove: () => subscription.remove() };
177
+ },
178
+ /** Tell the AI what just happened in the app, mid-session ("Night ended.
179
+ * Sam was eliminated."). Keep it to a sentence or two; long pushes are
180
+ * truncated. GPT-Live provider only; other providers ignore it. */
181
+ updateAgentContext(text) {
182
+ if (typeof text === 'string' && text.trim()) {
183
+ native().updateAgentContext(text.trim());
184
+ }
185
+ },
147
186
  /** Subscribe to state events. Returns a subscription; call .remove(). */
148
187
  addListener(event, listener) {
149
188
  const subscription = events().addListener(`crowdplay:${event}`, listener);
package/llms.txt CHANGED
@@ -57,7 +57,8 @@ its video calls invisibly; recording is infrastructure, not identity:
57
57
  in an app using this SDK. The engine owns the microphone and camera.
58
58
  3. NEVER skip or fake consent. CrowdPlay.join() THROWS without a ConsentGrant,
59
59
  and the user must actually SEE the consent wording first; use
60
- <CrowdPlayConsentScreen/>, or show CrowdPlay.consentText() verbatim in your own
60
+ <CrowdPlayConsentScreen/>, or show CrowdPlay.consentText() verbatim (plus a
61
+ link to CROWDPLAY_TERMS_URL, the full Terms of Use and Privacy Notice) in your own
61
62
  UI and construct { grantedAtMs: Date.now() } at the moment of
62
63
  agreement. Fabricating a grant creates false legal evidence.
63
64
  4. iOS only. Guard any non-iOS platform path; the module throws elsewhere
@@ -169,13 +170,38 @@ the voice and the persona and give a short context text for one session:
169
170
  agent: { voice: 'Aoede', context: "The user's name is Sam. Last time you practised past tense." } });
170
171
  voice must be one of the configured provider's voices (Gemini: Charon,
171
172
  Aoede, Fenrir, Kore, Puck; OpenAI: marin, cedar, alloy, ash, ballad,
172
- coral, echo, sage, shimmer, verse; Grok: Ara, Rex, Sal, Eve, Leo), unknown
173
+ coral, echo, sage, shimmer, verse; Grok: Ara, Rex, Sal, Eve, Leo;
174
+ OpenAI GPT-Live: quartz, ripple, vesper, willow, stone, gleam, meridian,
175
+ bossa, tempo, beacon, delta, cinder), unknown
173
176
  falls back to the dashboard voice. Caps: systemPrompt 8192 chars, context
174
177
  4000. The options of the join that CREATES the AI apply to the whole room;
175
178
  fixed for the session (leave and join again to change); provider, model
176
179
  and key cannot be changed from the app. MEMORY is built with `context`:
177
180
  keep a few sentences per user in the app's own storage and pass them at
178
181
  every join.
182
+ Agent tools + live context (0.5.0+, D-103; needs the app's dashboard
183
+ config on the OpenAI GPT-Live provider with an AI brain enabled —
184
+ ignored otherwise, never an error). Tools let the AI SEE app state and
185
+ ACT in the app: pass them at join, handle calls with ordinary JS.
186
+ await CrowdPlay.join({ displayName, roomCode, consent: grant,
187
+ agent: { tools: [
188
+ { name: 'get_game_state',
189
+ description: 'The current players, roles you know, and votes.' },
190
+ { name: 'cast_vote', description: 'Vote to eliminate one player.',
191
+ parameters: { type: 'object',
192
+ properties: { player: { type: 'string' } }, required: ['player'] } },
193
+ ] } });
194
+ const sub = CrowdPlay.onAgentToolCall(async (name, args) => {
195
+ if (name === 'get_game_state') return game.state();
196
+ if (name === 'cast_vote') return game.castVote(args.player);
197
+ return null; // decline on this device; first device to answer wins
198
+ });
199
+ Return an object (or JSON string), or null to decline. The AI waits
200
+ about 12 seconds per call, then carries on gracefully. Max 16 tools;
201
+ names letters/digits/underscore/dash up to 64 chars. To push events to
202
+ the AI as they happen (it has no other way to know):
203
+ CrowdPlay.updateAgentContext('Night ended. Sam was eliminated.') — a
204
+ sentence or two, long pushes are truncated.
179
205
  Conversations are recorded and transcribed by the platform automatically.
180
206
  RECOMMENDED: voice-AI apps should be AUDIO-ONLY (audioOnly: true; no
181
207
  camera permission, no video anywhere); a camera adds nothing to talking
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crowdplaysdk",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
4
4
  "description": "CrowdPlay lossless conversation capture for React Native (iOS). Studio-grade per-participant recording during live video calls, delivered to CrowdPlay automatically.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Drop-in consent screen. Shows CrowdPlay's locked consent wording (fetched from
3
- * the native SDK so it can never drift from what the manifest records), a
4
- * switch, and a continue button that only enables once the participant
5
- * agrees. Yields the ConsentGrant that join() requires.
3
+ * the native SDK so it can never drift from what the manifest records), a link
4
+ * to the full CrowdPlay Terms of Use and Privacy Notice, a switch, and a
5
+ * continue button that only enables once the participant agrees. Yields the
6
+ * ConsentGrant that join() requires.
6
7
  *
7
8
  * <CrowdPlayConsentScreen onConsent={(grant) => joinWith(grant)} />
8
9
  *
@@ -12,8 +13,8 @@
12
13
  */
13
14
 
14
15
  import React, { useEffect, useState } from 'react';
15
- import { ActivityIndicator, Pressable, StyleSheet, Switch, Text, View } from 'react-native';
16
- import CrowdPlay, { type ConsentGrant } from './index';
16
+ import { ActivityIndicator, Linking, Pressable, StyleSheet, Switch, Text, View } from 'react-native';
17
+ import CrowdPlay, { CROWDPLAY_TERMS_URL, type ConsentGrant } from './index';
17
18
 
18
19
  export interface CrowdPlayConsentScreenProps {
19
20
  onConsent: (grant: ConsentGrant) => void;
@@ -48,6 +49,9 @@ export function CrowdPlayConsentScreen({ onConsent, style }: CrowdPlayConsentScr
48
49
  ) : (
49
50
  <Text style={styles.body}>{text}</Text>
50
51
  )}
52
+ <Pressable accessibilityRole="link" onPress={() => void Linking.openURL(CROWDPLAY_TERMS_URL)}>
53
+ <Text style={styles.link}>Read the Terms of Use and Privacy Notice</Text>
54
+ </Pressable>
51
55
  <View style={styles.row}>
52
56
  <Switch
53
57
  value={agreed}
@@ -56,7 +60,7 @@ export function CrowdPlayConsentScreen({ onConsent, style }: CrowdPlayConsentScr
56
60
  setAgreedAtMs(value ? Date.now() : null);
57
61
  }}
58
62
  />
59
- <Text style={styles.agree}>I agree</Text>
63
+ <Text style={styles.agree}>I have read and agree to the CrowdPlay Terms of Use and Privacy Notice.</Text>
60
64
  </View>
61
65
  <Text style={styles.note}>
62
66
  Recording starts automatically when you join, and ends when you leave.
@@ -88,7 +92,8 @@ const styles = StyleSheet.create({
88
92
  title: { fontSize: 20, fontWeight: '700', color: '#111111' },
89
93
  body: { fontSize: 15, lineHeight: 21, color: '#222222' },
90
94
  row: { flexDirection: 'row', alignItems: 'center', gap: 10 },
91
- agree: { fontSize: 15, color: '#111111' },
95
+ agree: { flex: 1, fontSize: 15, color: '#111111' },
96
+ link: { fontSize: 15, color: '#1f6feb', textDecorationLine: 'underline' },
92
97
  note: { fontSize: 12, color: '#666666' },
93
98
  button: { backgroundColor: '#1f6feb', borderRadius: 8, paddingVertical: 12, alignItems: 'center' },
94
99
  buttonDisabled: { opacity: 0.4 },
package/src/index.ts CHANGED
@@ -68,6 +68,10 @@ export interface CrowdPlayConfig {
68
68
  callQuality?: CallQuality;
69
69
  }
70
70
 
71
+ /** The CrowdPlay Terms of Use and Privacy Notice every participant accepts,
72
+ * as a PDF. CrowdPlayConsentScreen links here; a custom consent UI must too. */
73
+ export const CROWDPLAY_TERMS_URL = 'https://dashboard.crowdplay.ai/legal/terms.pdf';
74
+
71
75
  /** Proof the participant agreed. Produce it with CrowdPlayConsentScreen, or,
72
76
  * if you render your own consent UI, call CrowdPlay.consentText() to show the
73
77
  * REQUIRED wording and construct the grant at the moment of agreement. */
@@ -84,6 +88,18 @@ export interface ConsentGrant {
84
88
  * apply to the whole room. The app can never change the provider, the
85
89
  * model, or the API key from here.
86
90
  */
91
+ /** A tool the app offers the voice AI for one session (D-103). Requires
92
+ * the app's dashboard config to use the GPT-Live provider with an AI brain
93
+ * (delegation) enabled; ignored otherwise. */
94
+ export interface AgentTool {
95
+ /** Letters, digits, underscore, dash; max 64 chars. */
96
+ name: string;
97
+ /** What the tool does and when to use it, for the model. */
98
+ description: string;
99
+ /** JSON-schema object describing the arguments. Omit for none. */
100
+ parameters?: Record<string, unknown>;
101
+ }
102
+
87
103
  export interface AgentOptions {
88
104
  /** A voice of the app's configured provider. Gemini: Charon, Aoede,
89
105
  * Fenrir, Kore, Puck. OpenAI: marin, cedar, alloy, ash, ballad, coral,
@@ -97,6 +113,9 @@ export interface AgentOptions {
97
113
  * keep a few sentences per user in your own storage and pass them
98
114
  * here. Max 4000 chars. */
99
115
  context?: string;
116
+ /** Tools the AI may call this session (D-103). Handle calls with
117
+ * CrowdPlay.onAgentToolCall(). Max 16. */
118
+ tools?: AgentTool[];
100
119
  }
101
120
 
102
121
  export type Phase = 'idle' | 'connecting' | 'connected' | 'failed';
@@ -164,6 +183,9 @@ export interface CrowdPlayEvents {
164
183
  * "iPhone speaker"), connected candidates, and whether headphones are
165
184
  * attached. Drive the call screen's output menu from this (D-079). */
166
185
  audioRoute: { currentOutputName: string; detectedOutputs: string[]; headphonesConnected: boolean };
186
+ /** The voice AI asked the app to run a tool (D-103). Prefer
187
+ * CrowdPlay.onAgentToolCall(), which answers for you. */
188
+ agentToolCall: { callId: string; name: string; argumentsJSON: string };
167
189
  }
168
190
 
169
191
  export type CrowdPlayEventName = keyof CrowdPlayEvents;
@@ -177,6 +199,8 @@ interface NativeCrowdPlay {
177
199
  consentText(): Promise<string>;
178
200
  join(displayName: string, roomCode: string, consentGrantedAtMs: number,
179
201
  agent: Record<string, string> | null): Promise<void>;
202
+ respondToAgentToolCall(callId: string, output: string): void;
203
+ updateAgentContext(text: string): void;
180
204
  leave(): Promise<void>;
181
205
  setMicMuted(muted: boolean): Promise<void>;
182
206
  setCameraEnabled(enabled: boolean): Promise<void>;
@@ -279,6 +303,11 @@ const CrowdPlay = {
279
303
  put('voice', agent.voice, 60);
280
304
  put('systemPrompt', agent.systemPrompt, 8192);
281
305
  put('context', agent.context, 4000);
306
+ // Tools cross the bridge as one JSON string; the native layer and the
307
+ // server both re-validate (D-103).
308
+ if (Array.isArray(agent.tools) && agent.tools.length) {
309
+ put('toolsJSON', JSON.stringify(agent.tools.slice(0, 16)), 8192);
310
+ }
282
311
  }
283
312
  return native().join(displayName.trim(), roomCode, consent.grantedAtMs, agentFields);
284
313
  },
@@ -330,6 +359,41 @@ const CrowdPlay = {
330
359
  return native().doctor();
331
360
  },
332
361
 
362
+ /** Handle the voice AI's tool calls (D-103). The handler gets the tool
363
+ * name and its arguments (parsed JSON object) and returns the result as
364
+ * an object or JSON string; return null to decline on this device
365
+ * (another participant's device may answer; first answer wins). Ordinary
366
+ * app code, no AI here. The AI waits about 12 seconds per call. Returns
367
+ * a subscription; call .remove(). */
368
+ onAgentToolCall(
369
+ handler: (name: string, args: Record<string, unknown>) =>
370
+ Promise<Record<string, unknown> | string | null> | Record<string, unknown> | string | null
371
+ ): { remove(): void } {
372
+ const subscription = events().addListener(
373
+ 'crowdplay:agentToolCall',
374
+ (payload: { callId: string; name: string; argumentsJSON: string }) => {
375
+ void (async () => {
376
+ let args: Record<string, unknown> = {};
377
+ try { args = JSON.parse(payload.argumentsJSON); } catch { /* {} */ }
378
+ const result = await handler(payload.name, args);
379
+ if (result === null || result === undefined) return;
380
+ const output = typeof result === 'string' ? result : JSON.stringify(result);
381
+ native().respondToAgentToolCall(payload.callId, output);
382
+ })();
383
+ }
384
+ );
385
+ return { remove: () => subscription.remove() };
386
+ },
387
+
388
+ /** Tell the AI what just happened in the app, mid-session ("Night ended.
389
+ * Sam was eliminated."). Keep it to a sentence or two; long pushes are
390
+ * truncated. GPT-Live provider only; other providers ignore it. */
391
+ updateAgentContext(text: string): void {
392
+ if (typeof text === 'string' && text.trim()) {
393
+ native().updateAgentContext(text.trim());
394
+ }
395
+ },
396
+
333
397
  /** Subscribe to state events. Returns a subscription; call .remove(). */
334
398
  addListener<E extends CrowdPlayEventName>(
335
399
  event: E,