@vapi-ai/web 3.0.0 → 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,7 +206,24 @@ 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 = {
225
+ /** Acquire the microphone before creating a backend call. Defaults to true. */
226
+ microphonePreflightEnabled?: boolean;
209
227
  /**
210
228
  * This determines whether the daily room will be deleted and all participants will be kicked once the user leaves the room.
211
229
  * If set to `false`, the room will be kept alive even after the user leaves, allowing clients to reconnect to the same room.
@@ -215,6 +233,25 @@ type StartCallOptions = {
215
233
  * @example true
216
234
  */
217
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;
218
255
  }
219
256
 
220
257
  type WebCall = {
@@ -242,12 +279,22 @@ type WebCall = {
242
279
  assistant?: { voice?: { provider?: string } };
243
280
  /**
244
281
  * The Vapi WebCall transport. When video recording is enabled, the server
245
- * sets `callToken` to a Daily meeting token that auto-starts the cloud
246
- * 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.
247
291
  *
248
292
  * call.transport
249
293
  */
250
- transport?: { callToken?: string };
294
+ transport?: {
295
+ callToken?: string;
296
+ videoRecordingStartMode?: VideoRecordingStartMode;
297
+ };
251
298
  }
252
299
 
253
300
  async function startAudioPlayer(
@@ -295,14 +342,13 @@ function subscribeToTracks(
295
342
  e: DailyEventObjectParticipant,
296
343
  call: DailyCall,
297
344
  isVideoRecordingEnabled?: boolean,
298
- isVideoEnabled?: boolean,
299
345
  ) {
300
346
  if (e.participant.local) return;
301
347
 
302
348
  call.updateParticipant(e.participant.session_id, {
303
349
  setSubscribedTracks: {
304
350
  audio: true,
305
- video: isVideoRecordingEnabled || isVideoEnabled,
351
+ video: isVideoRecordingEnabled,
306
352
  },
307
353
  });
308
354
  }
@@ -356,6 +402,40 @@ export default class Vapi extends VapiEventEmitter {
356
402
  private speakingTimeout: NodeJS.Timeout | null = null;
357
403
  private dailyCallConfig: DailyAdvancedConfig = {};
358
404
  private dailyCallObject: DailyFactoryOptions = {};
405
+ private microphoneTrack: MediaStreamTrack | null = null;
406
+ private cancelMicrophonePreflight: (() => void) | null = null;
407
+
408
+ private async microphoneAcquire(): Promise<MediaStreamTrack> {
409
+ const source = this.dailyCallObject.audioSource;
410
+ if (source && typeof source === 'object') {
411
+ if (source.readyState === 'ended') {
412
+ throw new DOMException('The supplied microphone track has ended.', 'NotReadableError');
413
+ }
414
+ return source;
415
+ }
416
+ return new Promise<MediaStreamTrack>((resolve, reject) => {
417
+ let cancelled = false;
418
+ this.cancelMicrophonePreflight = () => {
419
+ cancelled = true;
420
+ reject(new DOMException('Microphone preflight cancelled.', 'AbortError'));
421
+ };
422
+ navigator.mediaDevices.getUserMedia({
423
+ audio: typeof source === 'string' ? { deviceId: { exact: source } } : true,
424
+ }).then((stream) => {
425
+ const track = stream.getAudioTracks()[0];
426
+ if (cancelled || !track || track.readyState === 'ended') {
427
+ stream.getTracks().forEach((item) => item.stop());
428
+ reject(new DOMException('No live microphone track available.', 'NotFoundError'));
429
+ return;
430
+ }
431
+ stream.getTracks().filter((item) => item !== track).forEach((item) => item.stop());
432
+ this.microphoneTrack = track;
433
+ resolve(track);
434
+ }, reject);
435
+ }).finally(() => {
436
+ this.cancelMicrophonePreflight = null;
437
+ });
438
+ }
359
439
 
360
440
  private hasEmittedCallEndedStatus: boolean = false;
361
441
 
@@ -376,6 +456,8 @@ export default class Vapi extends VapiEventEmitter {
376
456
  }
377
457
 
378
458
  private async cleanup() {
459
+ this.microphoneTrack?.stop();
460
+ this.microphoneTrack = null;
379
461
  this.started = false;
380
462
  this.hasEmittedCallEndedStatus = false;
381
463
  if (this.call) {
@@ -498,7 +580,16 @@ export default class Vapi extends VapiEventEmitter {
498
580
 
499
581
  this.started = true;
500
582
 
583
+ let failureStage = 'microphone-preflight';
501
584
  try {
585
+ if (this.call) {
586
+ await this.cleanup();
587
+ this.started = true;
588
+ }
589
+ const audioSource = options?.microphonePreflightEnabled === false
590
+ ? this.dailyCallObject.audioSource ?? true
591
+ : await this.microphoneAcquire();
592
+ failureStage = 'unknown';
502
593
  // Stage 1: Create web call
503
594
  this.emit('call-start-progress', {
504
595
  stage: 'web-call-creation',
@@ -508,18 +599,23 @@ export default class Vapi extends VapiEventEmitter {
508
599
 
509
600
  const webCallStartTime = Date.now();
510
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
+
511
617
  const webCall = (
512
- await client.call.callControllerCreateWebCall({
513
- assistant: typeof assistant === 'string' ? undefined : assistant,
514
- assistantId: typeof assistant === 'string' ? assistant : undefined,
515
- assistantOverrides,
516
- squad: typeof squad === 'string' ? undefined : squad,
517
- squadId: typeof squad === 'string' ? squad : undefined,
518
- workflow: typeof workflow === 'string' ? undefined : workflow,
519
- workflowId: typeof workflow === 'string' ? workflow : undefined,
520
- workflowOverrides,
521
- roomDeleteOnUserLeaveEnabled: options?.roomDeleteOnUserLeaveEnabled,
522
- })
618
+ await client.call.callControllerCreateWebCall(createWebCallBody)
523
619
  ).data;
524
620
 
525
621
  const webCallDuration = Date.now() - webCallStartTime;
@@ -535,28 +631,20 @@ export default class Vapi extends VapiEventEmitter {
535
631
  }
536
632
  });
537
633
 
538
- if (this.call) {
539
- this.emit('call-start-progress', {
540
- stage: 'daily-call-object-creation',
541
- status: 'started',
542
- timestamp: new Date().toISOString(),
543
- metadata: { action: 'cleanup-existing' }
544
- });
545
- await this.cleanup();
546
- }
547
-
548
634
  const isVideoRecordingEnabled =
549
635
  webCall?.artifactPlan?.videoRecordingEnabled ?? false;
550
636
 
551
- const isVideoEnabled = webCall?.assistant?.voice?.provider === 'tavus';
552
-
553
637
  // When video recording is enabled, the server sends a Daily meeting
554
- // token that auto-starts the cloud recording on join — more reliable
555
- // than calling startRecording() from a weak client network. Older
556
- // servers don't send one; then the client starts the recording itself.
557
- const callToken = (
558
- webCall?.transport as { callToken?: string } | undefined
559
- )?.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;
560
648
 
561
649
  // Stage 2: Create Daily call object
562
650
  this.emit('call-start-progress', {
@@ -567,7 +655,6 @@ export default class Vapi extends VapiEventEmitter {
567
655
  audioSource: this.dailyCallObject.audioSource ?? true,
568
656
  videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled,
569
657
  isVideoRecordingEnabled,
570
- isVideoEnabled
571
658
  }
572
659
  });
573
660
 
@@ -575,7 +662,7 @@ export default class Vapi extends VapiEventEmitter {
575
662
 
576
663
  try {
577
664
  this.call = DailyIframe.createCallObject({
578
- audioSource: this.dailyCallObject.audioSource ?? true,
665
+ audioSource,
579
666
  videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled,
580
667
  dailyConfig: this.dailyCallConfig,
581
668
  });
@@ -677,12 +764,7 @@ export default class Vapi extends VapiEventEmitter {
677
764
 
678
765
  this.call.on('participant-joined', (e) => {
679
766
  if (!e || !this.call) return;
680
- subscribeToTracks(
681
- e,
682
- this.call,
683
- isVideoRecordingEnabled,
684
- isVideoEnabled,
685
- );
767
+ subscribeToTracks(e, this.call, isVideoRecordingEnabled);
686
768
  });
687
769
 
688
770
  this.call.on('participant-updated', (e) => {
@@ -699,6 +781,11 @@ export default class Vapi extends VapiEventEmitter {
699
781
  this.detachAudioPlayer(e.participant.session_id);
700
782
  });
701
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
+
702
789
  // Registered before join(): a token-auto-started recording can begin
703
790
  // while join() is still in flight, so a listener added after join
704
791
  // could miss the event.
@@ -799,52 +886,28 @@ export default class Vapi extends VapiEventEmitter {
799
886
 
800
887
  // Stage 5: Video recording setup (if enabled)
801
888
  if (isVideoRecordingEnabled && callToken) {
802
- // The meeting token auto-started the recording at join, so there is
803
- // 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.
804
892
  this.emit('call-start-progress', {
805
893
  stage: 'video-recording-setup',
806
894
  status: 'completed',
807
895
  timestamp: new Date().toISOString(),
808
- metadata: { action: 'auto-started-via-meeting-token' }
896
+ metadata: {
897
+ action: isRecordingStartedByServer
898
+ ? 'started-by-server'
899
+ : 'auto-started-via-meeting-token'
900
+ }
809
901
  });
810
902
  } else if (isVideoRecordingEnabled) {
903
+ // No token: nothing starts the recording. The recording-error is
904
+ // reported once start() has finished.
811
905
  this.emit('call-start-progress', {
812
906
  stage: 'video-recording-setup',
813
- status: 'started',
814
- timestamp: new Date().toISOString()
907
+ status: 'failed',
908
+ timestamp: new Date().toISOString(),
909
+ metadata: { action: 'no-meeting-token' }
815
910
  });
816
-
817
- const recordingStartTime = Date.now();
818
-
819
- try {
820
- recordingRequestedTime = new Date().getTime();
821
- this.startRecording();
822
-
823
- const recordingSetupDuration = Date.now() - recordingStartTime;
824
- this.emit('call-start-progress', {
825
- stage: 'video-recording-setup',
826
- status: 'completed',
827
- duration: recordingSetupDuration,
828
- timestamp: new Date().toISOString()
829
- });
830
- } catch (error) {
831
- const recordingSetupDuration = Date.now() - recordingStartTime;
832
- const serializedError = serializeError(error);
833
- this.emit('call-start-progress', {
834
- stage: 'video-recording-setup',
835
- status: 'failed',
836
- duration: recordingSetupDuration,
837
- timestamp: new Date().toISOString(),
838
- metadata: { error: serializedError.message }
839
- });
840
- this.emit('error', {
841
- type: 'video-recording-setup-error',
842
- stage: 'video-recording-setup',
843
- error: serializedError,
844
- timestamp: new Date().toISOString()
845
- });
846
- // Don't throw here, video recording is optional
847
- }
848
911
  } else {
849
912
  this.emit('call-start-progress', {
850
913
  stage: 'video-recording-setup',
@@ -899,8 +962,6 @@ export default class Vapi extends VapiEventEmitter {
899
962
  if (e) this.handleLocalAudioLevel(e);
900
963
  });
901
964
 
902
- this.call.on('app-message', (e) => this.onAppMessage(e));
903
-
904
965
  this.call.on('nonfatal-error', (e) => {
905
966
  // https://docs.daily.co/reference/daily-js/events/meeting-events#type-audio-processor-error
906
967
  if (e?.type === 'local-audio-level-observer-error') {
@@ -990,13 +1051,18 @@ export default class Vapi extends VapiEventEmitter {
990
1051
  timestamp: new Date().toISOString()
991
1052
  });
992
1053
 
1054
+ if (recordingTokenMissing) {
1055
+ this.recordingTokenMissingReportSchedule();
1056
+ }
1057
+
993
1058
  return webCall;
994
1059
  } catch (e) {
995
1060
  const totalDuration = Date.now() - startTime;
996
1061
  const serializedError = serializeError(e);
1062
+ await this.cleanup();
997
1063
 
998
1064
  this.emit('call-start-failed', {
999
- stage: 'unknown',
1065
+ stage: failureStage,
1000
1066
  totalDuration,
1001
1067
  error: serializedError.message,
1002
1068
  errorStack: serializedError.stack || 'No stack trace available',
@@ -1011,8 +1077,10 @@ export default class Vapi extends VapiEventEmitter {
1011
1077
 
1012
1078
  // Also emit the generic error event for backward compatibility
1013
1079
  this.emit('error', {
1014
- type: 'start-method-error',
1015
- stage: 'unknown',
1080
+ type: failureStage === 'microphone-preflight'
1081
+ ? `microphone-preflight-${e && typeof e === 'object' && 'name' in e ? e.name : 'error'}`
1082
+ : 'start-method-error',
1083
+ stage: failureStage,
1016
1084
  error: serializedError,
1017
1085
  totalDuration,
1018
1086
  timestamp: new Date().toISOString(),
@@ -1024,7 +1092,6 @@ export default class Vapi extends VapiEventEmitter {
1024
1092
  }
1025
1093
  });
1026
1094
 
1027
- await this.cleanup();
1028
1095
  return null;
1029
1096
  }
1030
1097
  }
@@ -1039,6 +1106,12 @@ export default class Vapi extends VapiEventEmitter {
1039
1106
  } else {
1040
1107
  try {
1041
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
+ }
1042
1115
  this.emit('message', parsedMessage);
1043
1116
  if (parsedMessage && 'type' in parsedMessage && 'status' in parsedMessage && parsedMessage.type === 'status-update' && parsedMessage.status === 'ended') {
1044
1117
  this.hasEmittedCallEndedStatus = true;
@@ -1092,6 +1165,12 @@ export default class Vapi extends VapiEventEmitter {
1092
1165
  * If `roomDeleteOnUserLeaveEnabled` is set to `true`, the Vapi call will also be destroyed, preventing any reconnections.
1093
1166
  */
1094
1167
  async stop(): Promise<void> {
1168
+ if (this.cancelMicrophonePreflight) {
1169
+ this.cancelMicrophonePreflight();
1170
+ return;
1171
+ }
1172
+ this.microphoneTrack?.stop();
1173
+ this.microphoneTrack = null;
1095
1174
  this.started = false;
1096
1175
  if (this.call) {
1097
1176
  await this.call.destroy();
@@ -1231,6 +1310,41 @@ export default class Vapi extends VapiEventEmitter {
1231
1310
  }
1232
1311
  }
1233
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
+
1234
1348
  /** Drops the reference before removing, so nothing hands out a detached element. */
1235
1349
  private detachAudioPlayer(participantId: string) {
1236
1350
  if (this.audioPlayer?.dataset.participantId === participantId) {
@@ -1498,12 +1612,16 @@ export default class Vapi extends VapiEventEmitter {
1498
1612
  }
1499
1613
 
1500
1614
  const isVideoRecordingEnabled = webCall?.artifactPlan?.videoRecordingEnabled ?? false;
1501
- const isVideoEnabled = webCall?.assistant?.voice?.provider === 'tavus';
1502
1615
 
1503
- // 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
1504
1617
  // recording is still running is safe; if the recording stopped when the
1505
- // 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.
1506
1621
  const callToken = webCall?.transport?.callToken;
1622
+ const isRecordingStartedByServer =
1623
+ webCall?.transport?.videoRecordingStartMode === 'server';
1624
+ const recordingTokenMissing = isVideoRecordingEnabled && !callToken;
1507
1625
 
1508
1626
  // Stage 1: Create Daily call object
1509
1627
  this.emit('call-start-progress', {
@@ -1514,7 +1632,6 @@ export default class Vapi extends VapiEventEmitter {
1514
1632
  audioSource: this.dailyCallObject.audioSource ?? true,
1515
1633
  videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled,
1516
1634
  isVideoRecordingEnabled,
1517
- isVideoEnabled
1518
1635
  }
1519
1636
  });
1520
1637
 
@@ -1605,12 +1722,7 @@ export default class Vapi extends VapiEventEmitter {
1605
1722
 
1606
1723
  this.call.on('participant-joined', (e) => {
1607
1724
  if (!e || !this.call) return;
1608
- subscribeToTracks(
1609
- e,
1610
- this.call,
1611
- isVideoRecordingEnabled,
1612
- isVideoEnabled,
1613
- );
1725
+ subscribeToTracks(e, this.call, isVideoRecordingEnabled);
1614
1726
  });
1615
1727
 
1616
1728
  this.call.on('participant-updated', (e) => {
@@ -1738,46 +1850,28 @@ export default class Vapi extends VapiEventEmitter {
1738
1850
 
1739
1851
  // Stage 4: Video recording setup (if enabled)
1740
1852
  if (isVideoRecordingEnabled && callToken) {
1741
- // The meeting token auto-started the recording at join, so there is
1742
- // 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.
1743
1856
  this.emit('call-start-progress', {
1744
1857
  stage: 'video-recording-setup',
1745
1858
  status: 'completed',
1746
1859
  timestamp: new Date().toISOString(),
1747
- metadata: { action: 'auto-started-via-meeting-token' }
1860
+ metadata: {
1861
+ action: isRecordingStartedByServer
1862
+ ? 'started-by-server'
1863
+ : 'auto-started-via-meeting-token'
1864
+ }
1748
1865
  });
1749
1866
  } else if (isVideoRecordingEnabled) {
1867
+ // No token: nothing starts the recording. The recording-error is
1868
+ // reported once reconnect() has finished.
1750
1869
  this.emit('call-start-progress', {
1751
1870
  stage: 'video-recording-setup',
1752
- status: 'started',
1753
- timestamp: new Date().toISOString()
1871
+ status: 'failed',
1872
+ timestamp: new Date().toISOString(),
1873
+ metadata: { action: 'no-meeting-token' }
1754
1874
  });
1755
-
1756
- const recordingStartTime = Date.now();
1757
-
1758
- try {
1759
- recordingRequestedTime = new Date().getTime();
1760
- this.startRecording();
1761
-
1762
- const recordingSetupDuration = Date.now() - recordingStartTime;
1763
- this.emit('call-start-progress', {
1764
- stage: 'video-recording-setup',
1765
- status: 'completed',
1766
- duration: recordingSetupDuration,
1767
- timestamp: new Date().toISOString()
1768
- });
1769
- } catch (error) {
1770
- const recordingSetupDuration = Date.now() - recordingStartTime;
1771
- const serializedError = serializeError(error);
1772
- this.emit('call-start-progress', {
1773
- stage: 'video-recording-setup',
1774
- status: 'failed',
1775
- duration: recordingSetupDuration,
1776
- timestamp: new Date().toISOString(),
1777
- metadata: { error: serializedError.message }
1778
- });
1779
- // Don't throw here, video recording is optional
1780
- }
1781
1875
  } else {
1782
1876
  this.emit('call-start-progress', {
1783
1877
  stage: 'video-recording-setup',
@@ -1880,6 +1974,10 @@ export default class Vapi extends VapiEventEmitter {
1880
1974
 
1881
1975
  this.emit('call-start');
1882
1976
 
1977
+ if (recordingTokenMissing) {
1978
+ this.recordingTokenMissingReportSchedule();
1979
+ }
1980
+
1883
1981
  } catch (e) {
1884
1982
  const totalDuration = Date.now() - startTime;
1885
1983
  const serializedError = serializeError(e);