@vapi-ai/web 3.0.1 → 3.1.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/vapi.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  CreateSquadDTO,
24
24
  CreateAssistantDTO,
25
25
  AssistantOverrides,
26
+ CreateWebCallDTO,
26
27
  CreateWorkflowDTO,
27
28
  WorkflowOverrides,
28
29
  } from './api';
@@ -205,6 +206,21 @@ type VapiEventListeners = {
205
206
  'recording-upload-completed': (event: DailyEventObjectNoPayload) => void;
206
207
  };
207
208
 
209
+ type VideoRecordingStartMode = NonNullable<
210
+ CreateWebCallDTO['videoRecordingStartMode']
211
+ >;
212
+
213
+ // The app message Vapi sends when it couldn't start a server-mode recording.
214
+ const VIDEO_RECORDING_START_FAILED_MESSAGE_TYPE = 'video-recording.start-failed';
215
+
216
+ type VideoRecordingStartFailedMessage = {
217
+ type: typeof VIDEO_RECORDING_START_FAILED_MESSAGE_TYPE;
218
+ /** `not-hosting` when Daily saw nobody in the room; `failed` for any other failure. */
219
+ outcome: 'not-hosting' | 'failed';
220
+ /** A short description of the failure. */
221
+ error: string;
222
+ };
223
+
208
224
  type StartCallOptions = {
209
225
  /** Acquire the microphone before creating a backend call. Defaults to true. */
210
226
  microphonePreflightEnabled?: boolean;
@@ -217,6 +233,25 @@ type StartCallOptions = {
217
233
  * @example true
218
234
  */
219
235
  roomDeleteOnUserLeaveEnabled?: boolean;
236
+ /**
237
+ * This determines who starts the video recording. Only applies when video
238
+ * recording is enabled (`artifactPlan.videoRecordingEnabled`).
239
+ *
240
+ * - `client`: the web client starts the recording when it joins.
241
+ * - `server`: Vapi starts the recording once the user has joined, so a weak
242
+ * client connection can't stop it from starting. If Vapi can't set up the
243
+ * server-side start, or the start itself fails, the SDK emits
244
+ * `recording-error`.
245
+ *
246
+ * In `server` mode the SDK never calls `startRecording()` itself. If the
247
+ * server-side start couldn't be set up, calling it yourself may still start
248
+ * the recording.
249
+ *
250
+ * Requires a Vapi API that supports this field; older servers reject the
251
+ * request. The server defaults to `client` when it is not set.
252
+ * @example "server"
253
+ */
254
+ videoRecordingStartMode?: VideoRecordingStartMode;
220
255
  }
221
256
 
222
257
  type WebCall = {
@@ -244,12 +279,22 @@ type WebCall = {
244
279
  assistant?: { voice?: { provider?: string } };
245
280
  /**
246
281
  * The Vapi WebCall transport. When video recording is enabled, the server
247
- * sets `callToken` to a Daily meeting token that auto-starts the cloud
248
- * recording on join.
282
+ * sets `callToken` to the Daily meeting token to join with. In `client` mode
283
+ * the token auto-starts the cloud recording on join; in `server` mode it
284
+ * starts nothing and Vapi starts the recording. If there is no token, the
285
+ * SDK emits `recording-error` and starts nothing, though `startRecording()`
286
+ * may still start the recording from the client.
287
+ *
288
+ * `videoRecordingStartMode` is `server` when Vapi starts the recording, and
289
+ * is absent when video recording is off or the server-side start couldn't
290
+ * be set up.
249
291
  *
250
292
  * call.transport
251
293
  */
252
- transport?: { callToken?: string };
294
+ transport?: {
295
+ callToken?: string;
296
+ videoRecordingStartMode?: VideoRecordingStartMode;
297
+ };
253
298
  }
254
299
 
255
300
  async function startAudioPlayer(
@@ -297,14 +342,13 @@ function subscribeToTracks(
297
342
  e: DailyEventObjectParticipant,
298
343
  call: DailyCall,
299
344
  isVideoRecordingEnabled?: boolean,
300
- isVideoEnabled?: boolean,
301
345
  ) {
302
346
  if (e.participant.local) return;
303
347
 
304
348
  call.updateParticipant(e.participant.session_id, {
305
349
  setSubscribedTracks: {
306
350
  audio: true,
307
- video: isVideoRecordingEnabled || isVideoEnabled,
351
+ video: isVideoRecordingEnabled,
308
352
  },
309
353
  });
310
354
  }
@@ -555,18 +599,23 @@ export default class Vapi extends VapiEventEmitter {
555
599
 
556
600
  const webCallStartTime = Date.now();
557
601
 
602
+ // An unset option stays undefined, so JSON.stringify drops the key and
603
+ // older servers never see it.
604
+ const createWebCallBody: CreateWebCallDTO = {
605
+ assistant: typeof assistant === 'string' ? undefined : assistant,
606
+ assistantId: typeof assistant === 'string' ? assistant : undefined,
607
+ assistantOverrides,
608
+ squad: typeof squad === 'string' ? undefined : squad,
609
+ squadId: typeof squad === 'string' ? squad : undefined,
610
+ workflow: typeof workflow === 'string' ? undefined : workflow,
611
+ workflowId: typeof workflow === 'string' ? workflow : undefined,
612
+ workflowOverrides,
613
+ roomDeleteOnUserLeaveEnabled: options?.roomDeleteOnUserLeaveEnabled,
614
+ videoRecordingStartMode: options?.videoRecordingStartMode,
615
+ };
616
+
558
617
  const webCall = (
559
- await client.call.callControllerCreateWebCall({
560
- assistant: typeof assistant === 'string' ? undefined : assistant,
561
- assistantId: typeof assistant === 'string' ? assistant : undefined,
562
- assistantOverrides,
563
- squad: typeof squad === 'string' ? undefined : squad,
564
- squadId: typeof squad === 'string' ? squad : undefined,
565
- workflow: typeof workflow === 'string' ? undefined : workflow,
566
- workflowId: typeof workflow === 'string' ? workflow : undefined,
567
- workflowOverrides,
568
- roomDeleteOnUserLeaveEnabled: options?.roomDeleteOnUserLeaveEnabled,
569
- })
618
+ await client.call.callControllerCreateWebCall(createWebCallBody)
570
619
  ).data;
571
620
 
572
621
  const webCallDuration = Date.now() - webCallStartTime;
@@ -585,15 +634,17 @@ export default class Vapi extends VapiEventEmitter {
585
634
  const isVideoRecordingEnabled =
586
635
  webCall?.artifactPlan?.videoRecordingEnabled ?? false;
587
636
 
588
- const isVideoEnabled = webCall?.assistant?.voice?.provider === 'tavus';
589
-
590
637
  // When video recording is enabled, the server sends a Daily meeting
591
- // token that auto-starts the cloud recording on join — more reliable
592
- // than calling startRecording() from a weak client network. Older
593
- // servers don't send one; then the client starts the recording itself.
594
- const callToken = (
595
- webCall?.transport as { callToken?: string } | undefined
596
- )?.callToken;
638
+ // token. In client mode it auto-starts the cloud recording on join. In
639
+ // server mode it starts nothing: Vapi starts the recording from its
640
+ // servers once the user has joined, which a weak client network can't
641
+ // stop. Without a token the SDK emits recording-error and starts
642
+ // nothing.
643
+ const transport = webCall?.transport as WebCall['transport'];
644
+ const callToken = transport?.callToken;
645
+ const isRecordingStartedByServer =
646
+ transport?.videoRecordingStartMode === 'server';
647
+ const recordingTokenMissing = isVideoRecordingEnabled && !callToken;
597
648
 
598
649
  // Stage 2: Create Daily call object
599
650
  this.emit('call-start-progress', {
@@ -604,7 +655,6 @@ export default class Vapi extends VapiEventEmitter {
604
655
  audioSource: this.dailyCallObject.audioSource ?? true,
605
656
  videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled,
606
657
  isVideoRecordingEnabled,
607
- isVideoEnabled
608
658
  }
609
659
  });
610
660
 
@@ -714,12 +764,7 @@ export default class Vapi extends VapiEventEmitter {
714
764
 
715
765
  this.call.on('participant-joined', (e) => {
716
766
  if (!e || !this.call) return;
717
- subscribeToTracks(
718
- e,
719
- this.call,
720
- isVideoRecordingEnabled,
721
- isVideoEnabled,
722
- );
767
+ subscribeToTracks(e, this.call, isVideoRecordingEnabled);
723
768
  });
724
769
 
725
770
  this.call.on('participant-updated', (e) => {
@@ -736,6 +781,11 @@ export default class Vapi extends VapiEventEmitter {
736
781
  this.detachAudioPlayer(e.participant.session_id);
737
782
  });
738
783
 
784
+ // Registered before join(), as in reconnect(): Vapi starts a server-mode
785
+ // recording when it sees the user join, so its start-failed message can
786
+ // arrive while join() is still in flight.
787
+ this.call.on('app-message', (e) => this.onAppMessage(e));
788
+
739
789
  // Registered before join(): a token-auto-started recording can begin
740
790
  // while join() is still in flight, so a listener added after join
741
791
  // could miss the event.
@@ -836,52 +886,28 @@ export default class Vapi extends VapiEventEmitter {
836
886
 
837
887
  // Stage 5: Video recording setup (if enabled)
838
888
  if (isVideoRecordingEnabled && callToken) {
839
- // The meeting token auto-started the recording at join, so there is
840
- // nothing to request from the client.
889
+ // The meeting token auto-started the recording at join, or Vapi
890
+ // starts it from its servers, so there is nothing to request from the
891
+ // client.
841
892
  this.emit('call-start-progress', {
842
893
  stage: 'video-recording-setup',
843
894
  status: 'completed',
844
895
  timestamp: new Date().toISOString(),
845
- metadata: { action: 'auto-started-via-meeting-token' }
896
+ metadata: {
897
+ action: isRecordingStartedByServer
898
+ ? 'started-by-server'
899
+ : 'auto-started-via-meeting-token'
900
+ }
846
901
  });
847
902
  } else if (isVideoRecordingEnabled) {
903
+ // No token: nothing starts the recording. The recording-error is
904
+ // reported once start() has finished.
848
905
  this.emit('call-start-progress', {
849
906
  stage: 'video-recording-setup',
850
- status: 'started',
851
- timestamp: new Date().toISOString()
907
+ status: 'failed',
908
+ timestamp: new Date().toISOString(),
909
+ metadata: { action: 'no-meeting-token' }
852
910
  });
853
-
854
- const recordingStartTime = Date.now();
855
-
856
- try {
857
- recordingRequestedTime = new Date().getTime();
858
- this.startRecording();
859
-
860
- const recordingSetupDuration = Date.now() - recordingStartTime;
861
- this.emit('call-start-progress', {
862
- stage: 'video-recording-setup',
863
- status: 'completed',
864
- duration: recordingSetupDuration,
865
- timestamp: new Date().toISOString()
866
- });
867
- } catch (error) {
868
- const recordingSetupDuration = Date.now() - recordingStartTime;
869
- const serializedError = serializeError(error);
870
- this.emit('call-start-progress', {
871
- stage: 'video-recording-setup',
872
- status: 'failed',
873
- duration: recordingSetupDuration,
874
- timestamp: new Date().toISOString(),
875
- metadata: { error: serializedError.message }
876
- });
877
- this.emit('error', {
878
- type: 'video-recording-setup-error',
879
- stage: 'video-recording-setup',
880
- error: serializedError,
881
- timestamp: new Date().toISOString()
882
- });
883
- // Don't throw here, video recording is optional
884
- }
885
911
  } else {
886
912
  this.emit('call-start-progress', {
887
913
  stage: 'video-recording-setup',
@@ -936,8 +962,6 @@ export default class Vapi extends VapiEventEmitter {
936
962
  if (e) this.handleLocalAudioLevel(e);
937
963
  });
938
964
 
939
- this.call.on('app-message', (e) => this.onAppMessage(e));
940
-
941
965
  this.call.on('nonfatal-error', (e) => {
942
966
  // https://docs.daily.co/reference/daily-js/events/meeting-events#type-audio-processor-error
943
967
  if (e?.type === 'local-audio-level-observer-error') {
@@ -1027,6 +1051,10 @@ export default class Vapi extends VapiEventEmitter {
1027
1051
  timestamp: new Date().toISOString()
1028
1052
  });
1029
1053
 
1054
+ if (recordingTokenMissing) {
1055
+ this.recordingTokenMissingReportSchedule();
1056
+ }
1057
+
1030
1058
  return webCall;
1031
1059
  } catch (e) {
1032
1060
  const totalDuration = Date.now() - startTime;
@@ -1078,6 +1106,12 @@ export default class Vapi extends VapiEventEmitter {
1078
1106
  } else {
1079
1107
  try {
1080
1108
  const parsedMessage = JSON.parse(e.data);
1109
+ // Not a Vapi client message: re-emitted as recording-error, and kept
1110
+ // out of 'message' because its consumers switch on `type`.
1111
+ if (parsedMessage?.type === VIDEO_RECORDING_START_FAILED_MESSAGE_TYPE) {
1112
+ this.videoRecordingStartFailedReport(parsedMessage);
1113
+ return;
1114
+ }
1081
1115
  this.emit('message', parsedMessage);
1082
1116
  if (parsedMessage && 'type' in parsedMessage && 'status' in parsedMessage && parsedMessage.type === 'status-update' && parsedMessage.status === 'ended') {
1083
1117
  this.hasEmittedCallEndedStatus = true;
@@ -1276,6 +1310,41 @@ export default class Vapi extends VapiEventEmitter {
1276
1310
  }
1277
1311
  }
1278
1312
 
1313
+ /**
1314
+ * Reported in a later task, like Daily's own recording-error: start() or
1315
+ * reconnect() has resolved and the caller's await continuation has run, so a
1316
+ * listener registered right after it still hears this, and one that calls
1317
+ * stop() can't interrupt call setup. Skipped if that call was stopped or
1318
+ * replaced.
1319
+ */
1320
+ private recordingTokenMissingReportSchedule() {
1321
+ const call = this.call;
1322
+ if (!call) return;
1323
+ setTimeout(() => {
1324
+ if (!this.started || this.call !== call) return;
1325
+ this.emitToConsumer('recording-error', {
1326
+ action: 'recording-error',
1327
+ callClientId: call.callClientId,
1328
+ errorMsg:
1329
+ 'Vapi returned no recording token, so the recording was not started. Call startRecording() to try starting it from the client.',
1330
+ });
1331
+ }, 0);
1332
+ }
1333
+
1334
+ /** The fields are read defensively: the app message is untrusted JSON. */
1335
+ private videoRecordingStartFailedReport(
1336
+ message: Partial<VideoRecordingStartFailedMessage>,
1337
+ ) {
1338
+ const outcome =
1339
+ typeof message.outcome === 'string' ? message.outcome : 'unknown';
1340
+ const error = typeof message.error === 'string' ? message.error : 'unknown';
1341
+ this.emitToConsumer('recording-error', {
1342
+ action: 'recording-error',
1343
+ callClientId: this.call?.callClientId ?? '',
1344
+ errorMsg: `Vapi couldn't start the recording (${outcome}): ${error}`,
1345
+ });
1346
+ }
1347
+
1279
1348
  /** Drops the reference before removing, so nothing hands out a detached element. */
1280
1349
  private detachAudioPlayer(participantId: string) {
1281
1350
  if (this.audioPlayer?.dataset.participantId === participantId) {
@@ -1543,12 +1612,16 @@ export default class Vapi extends VapiEventEmitter {
1543
1612
  }
1544
1613
 
1545
1614
  const isVideoRecordingEnabled = webCall?.artifactPlan?.videoRecordingEnabled ?? false;
1546
- const isVideoEnabled = webCall?.assistant?.voice?.provider === 'tavus';
1547
1615
 
1548
- // Same auto-start token as in start(). Rejoining with it while the
1616
+ // Same token as in start(). In client mode, rejoining with it while the
1549
1617
  // recording is still running is safe; if the recording stopped when the
1550
- // user left, the token starts a new one.
1618
+ // user left, the token starts a new one. In server mode it starts
1619
+ // nothing. Without a token the SDK emits recording-error and starts
1620
+ // nothing.
1551
1621
  const callToken = webCall?.transport?.callToken;
1622
+ const isRecordingStartedByServer =
1623
+ webCall?.transport?.videoRecordingStartMode === 'server';
1624
+ const recordingTokenMissing = isVideoRecordingEnabled && !callToken;
1552
1625
 
1553
1626
  // Stage 1: Create Daily call object
1554
1627
  this.emit('call-start-progress', {
@@ -1559,7 +1632,6 @@ export default class Vapi extends VapiEventEmitter {
1559
1632
  audioSource: this.dailyCallObject.audioSource ?? true,
1560
1633
  videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled,
1561
1634
  isVideoRecordingEnabled,
1562
- isVideoEnabled
1563
1635
  }
1564
1636
  });
1565
1637
 
@@ -1650,12 +1722,7 @@ export default class Vapi extends VapiEventEmitter {
1650
1722
 
1651
1723
  this.call.on('participant-joined', (e) => {
1652
1724
  if (!e || !this.call) return;
1653
- subscribeToTracks(
1654
- e,
1655
- this.call,
1656
- isVideoRecordingEnabled,
1657
- isVideoEnabled,
1658
- );
1725
+ subscribeToTracks(e, this.call, isVideoRecordingEnabled);
1659
1726
  });
1660
1727
 
1661
1728
  this.call.on('participant-updated', (e) => {
@@ -1783,46 +1850,28 @@ export default class Vapi extends VapiEventEmitter {
1783
1850
 
1784
1851
  // Stage 4: Video recording setup (if enabled)
1785
1852
  if (isVideoRecordingEnabled && callToken) {
1786
- // The meeting token auto-started the recording at join, so there is
1787
- // nothing to request from the client.
1853
+ // The meeting token auto-started the recording at join, or Vapi
1854
+ // starts it from its servers, so there is nothing to request from the
1855
+ // client.
1788
1856
  this.emit('call-start-progress', {
1789
1857
  stage: 'video-recording-setup',
1790
1858
  status: 'completed',
1791
1859
  timestamp: new Date().toISOString(),
1792
- metadata: { action: 'auto-started-via-meeting-token' }
1860
+ metadata: {
1861
+ action: isRecordingStartedByServer
1862
+ ? 'started-by-server'
1863
+ : 'auto-started-via-meeting-token'
1864
+ }
1793
1865
  });
1794
1866
  } else if (isVideoRecordingEnabled) {
1867
+ // No token: nothing starts the recording. The recording-error is
1868
+ // reported once reconnect() has finished.
1795
1869
  this.emit('call-start-progress', {
1796
1870
  stage: 'video-recording-setup',
1797
- status: 'started',
1798
- timestamp: new Date().toISOString()
1871
+ status: 'failed',
1872
+ timestamp: new Date().toISOString(),
1873
+ metadata: { action: 'no-meeting-token' }
1799
1874
  });
1800
-
1801
- const recordingStartTime = Date.now();
1802
-
1803
- try {
1804
- recordingRequestedTime = new Date().getTime();
1805
- this.startRecording();
1806
-
1807
- const recordingSetupDuration = Date.now() - recordingStartTime;
1808
- this.emit('call-start-progress', {
1809
- stage: 'video-recording-setup',
1810
- status: 'completed',
1811
- duration: recordingSetupDuration,
1812
- timestamp: new Date().toISOString()
1813
- });
1814
- } catch (error) {
1815
- const recordingSetupDuration = Date.now() - recordingStartTime;
1816
- const serializedError = serializeError(error);
1817
- this.emit('call-start-progress', {
1818
- stage: 'video-recording-setup',
1819
- status: 'failed',
1820
- duration: recordingSetupDuration,
1821
- timestamp: new Date().toISOString(),
1822
- metadata: { error: serializedError.message }
1823
- });
1824
- // Don't throw here, video recording is optional
1825
- }
1826
1875
  } else {
1827
1876
  this.emit('call-start-progress', {
1828
1877
  stage: 'video-recording-setup',
@@ -1925,6 +1974,10 @@ export default class Vapi extends VapiEventEmitter {
1925
1974
 
1926
1975
  this.emit('call-start');
1927
1976
 
1977
+ if (recordingTokenMissing) {
1978
+ this.recordingTokenMissingReportSchedule();
1979
+ }
1980
+
1928
1981
  } catch (e) {
1929
1982
  const totalDuration = Date.now() - startTime;
1930
1983
  const serializedError = serializeError(e);