@vapi-ai/web 2.4.0 → 2.5.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/api.ts CHANGED
@@ -18143,6 +18143,15 @@ export interface CreateWebCallDTO {
18143
18143
  workflow?: CreateWorkflowDTO;
18144
18144
  /** These are the overrides for the `workflow` or `workflowId`'s settings and template variables. */
18145
18145
  workflowOverrides?: WorkflowOverrides;
18146
+ /**
18147
+ * This determines whether the daily room will be deleted and all participants will be kicked once the user leaves the room.
18148
+ * If set to `false`, the room will be kept alive even after the user leaves, allowing clients to reconnect to the same room.
18149
+ * If set to `true`, the room will be deleted and reconnection will not be allowed.
18150
+ *
18151
+ * Defaults to `true`.
18152
+ * @example true
18153
+ */
18154
+ roomDeleteOnUserLeaveEnabled?: boolean;
18146
18155
  }
18147
18156
 
18148
18157
  export interface UpdateCallDTO {
package/dist/api.d.ts CHANGED
@@ -11558,6 +11558,15 @@ export interface CreateWebCallDTO {
11558
11558
  workflow?: CreateWorkflowDTO;
11559
11559
  /** These are the overrides for the `workflow` or `workflowId`'s settings and template variables. */
11560
11560
  workflowOverrides?: WorkflowOverrides;
11561
+ /**
11562
+ * This determines whether the daily room will be deleted and all participants will be kicked once the user leaves the room.
11563
+ * If set to `false`, the room will be kept alive even after the user leaves, allowing clients to reconnect to the same room.
11564
+ * If set to `true`, the room will be deleted and reconnection will not be allowed.
11565
+ *
11566
+ * Defaults to `true`.
11567
+ * @example true
11568
+ */
11569
+ roomDeleteOnUserLeaveEnabled?: boolean;
11561
11570
  }
11562
11571
  export interface UpdateCallDTO {
11563
11572
  /**
package/dist/vapi.d.ts CHANGED
@@ -2,6 +2,9 @@ import type { ChatCompletionMessageParam } from 'openai/resources';
2
2
  import { DailyCall, DailyAdvancedConfig, DailyFactoryOptions, DailyParticipant, DailyVideoSendSettings } from '@daily-co/daily-js';
3
3
  import EventEmitter from 'events';
4
4
  import { Call, CreateSquadDTO, CreateAssistantDTO, AssistantOverrides, CreateWorkflowDTO, WorkflowOverrides } from './api';
5
+ export interface EndCallMessage {
6
+ type: 'end-call';
7
+ }
5
8
  export interface AddMessageMessage {
6
9
  type: 'add-message';
7
10
  message: ChatCompletionMessageParam;
@@ -19,7 +22,7 @@ export interface SayMessage {
19
22
  interruptionsEnabled?: boolean;
20
23
  interruptAssistantEnabled?: boolean;
21
24
  }
22
- type VapiClientToServerMessage = AddMessageMessage | ControlMessages | SayMessage;
25
+ type VapiClientToServerMessage = AddMessageMessage | ControlMessages | SayMessage | EndCallMessage;
23
26
  type VapiEventNames = 'call-end' | 'call-start' | 'volume-level' | 'speech-start' | 'speech-end' | 'message' | 'video' | 'error' | 'camera-error' | 'network-quality-change' | 'network-connection' | 'daily-participant-updated' | 'call-start-progress' | 'call-start-success' | 'call-start-failed';
24
27
  interface CallStartProgressEvent {
25
28
  stage: string;
@@ -58,6 +61,47 @@ type VapiEventListeners = {
58
61
  'call-start-success': (event: CallStartSuccessEvent) => void;
59
62
  'call-start-failed': (event: CallStartFailedEvent) => void;
60
63
  };
64
+ type StartCallOptions = {
65
+ /**
66
+ * This determines whether the daily room will be deleted and all participants will be kicked once the user leaves the room.
67
+ * If set to `false`, the room will be kept alive even after the user leaves, allowing clients to reconnect to the same room.
68
+ * If set to `true`, the room will be deleted and reconnection will not be allowed.
69
+ *
70
+ * Defaults to `true`.
71
+ * @example true
72
+ */
73
+ roomDeleteOnUserLeaveEnabled?: boolean;
74
+ };
75
+ type WebCall = {
76
+ /**
77
+ * The Vapi WebCall URL. This is the URL that the call will be joined on.
78
+ *
79
+ * call.webCallUrl or call.transport.callUrl
80
+ */
81
+ webCallUrl: string;
82
+ /**
83
+ * The Vapi WebCall ID. This is the ID of the call.
84
+ *
85
+ * call.id
86
+ */
87
+ id?: string;
88
+ /**
89
+ * The Vapi WebCall artifact plan. This is the artifact plan of the call.
90
+ */
91
+ artifactPlan?: {
92
+ videoRecordingEnabled?: boolean;
93
+ };
94
+ /**
95
+ * The Vapi WebCall assistant. This is the assistant of the call.
96
+ *
97
+ * call.assistant
98
+ */
99
+ assistant?: {
100
+ voice?: {
101
+ provider?: string;
102
+ };
103
+ };
104
+ };
61
105
  declare class VapiEventEmitter extends EventEmitter {
62
106
  on<E extends VapiEventNames>(event: E, listener: VapiEventListeners[E]): this;
63
107
  once<E extends VapiEventNames>(event: E, listener: VapiEventListeners[E]): this;
@@ -76,19 +120,43 @@ export default class Vapi extends VapiEventEmitter {
76
120
  private cleanup;
77
121
  private isMobileDevice;
78
122
  private sleep;
79
- start(assistant?: CreateAssistantDTO | string, assistantOverrides?: AssistantOverrides, squad?: CreateSquadDTO | string, workflow?: CreateWorkflowDTO | string, workflowOverrides?: WorkflowOverrides): Promise<Call | null>;
123
+ start(assistant?: CreateAssistantDTO | string, assistantOverrides?: AssistantOverrides, squad?: CreateSquadDTO | string, workflow?: CreateWorkflowDTO | string, workflowOverrides?: WorkflowOverrides, options?: StartCallOptions): Promise<Call | null>;
80
124
  private onAppMessage;
81
125
  private handleRemoteParticipantsAudioLevel;
126
+ /**
127
+ * Stops the call by destroying the Daily call object.
128
+ *
129
+ * If `roomDeleteOnUserLeaveEnabled` is set to `false`, the Vapi call will be kept alive, allowing reconnections to the same call using the `reconnect` method.
130
+ * If `roomDeleteOnUserLeaveEnabled` is set to `true`, the Vapi call will also be destroyed, preventing any reconnections.
131
+ */
82
132
  stop(): Promise<void>;
133
+ /**
134
+ * Sends a Live Call Control message to the Vapi server.
135
+ *
136
+ * Docs: https://docs.vapi.ai/calls/call-features
137
+ */
83
138
  send(message: VapiClientToServerMessage): void;
84
139
  setMuted(mute: boolean): void;
85
140
  isMuted(): boolean;
86
141
  say(message: string, endCallAfterSpoken?: boolean, interruptionsEnabled?: boolean, interruptAssistantEnabled?: boolean): void;
142
+ /**
143
+ * Ends the call immediately by sending a `end-call` message using Live Call Control, and destroys the Daily call object.
144
+ *
145
+ * This method always ends the call, regardless of the `roomDeleteOnUserLeaveEnabled` option.
146
+ */
147
+ end(): void;
87
148
  setInputDevicesAsync(options: Parameters<DailyCall['setInputDevicesAsync']>[0]): Promise<void>;
88
149
  increaseMicLevel(gain: number): Promise<void>;
89
150
  setOutputDeviceAsync(options: Parameters<DailyCall['setOutputDeviceAsync']>[0]): void;
90
151
  getDailyCallObject(): DailyCall | null;
91
152
  startScreenSharing(displayMediaOptions?: DisplayMediaStreamOptions, screenVideoSendSettings?: DailyVideoSendSettings): void;
92
153
  stopScreenSharing(): void;
154
+ /**
155
+ * Reconnects to an active call.
156
+ *
157
+ *
158
+ * @param webCall
159
+ */
160
+ reconnect(webCall: WebCall): Promise<void>;
93
161
  }
94
162
  export {};
package/dist/vapi.js CHANGED
@@ -90,7 +90,7 @@ class Vapi extends VapiEventEmitter {
90
90
  async sleep(ms) {
91
91
  return new Promise((resolve) => setTimeout(resolve, ms));
92
92
  }
93
- async start(assistant, assistantOverrides, squad, workflow, workflowOverrides) {
93
+ async start(assistant, assistantOverrides, squad, workflow, workflowOverrides, options) {
94
94
  const startTime = Date.now();
95
95
  // Input validation with detailed error messages
96
96
  if (!assistant && !squad && !workflow) {
@@ -140,6 +140,7 @@ class Vapi extends VapiEventEmitter {
140
140
  workflow: typeof workflow === 'string' ? undefined : workflow,
141
141
  workflowId: typeof workflow === 'string' ? workflow : undefined,
142
142
  workflowOverrides,
143
+ roomDeleteOnUserLeaveEnabled: options?.roomDeleteOnUserLeaveEnabled,
143
144
  })).data;
144
145
  const webCallDuration = Date.now() - webCallStartTime;
145
146
  this.emit('call-start-progress', {
@@ -587,6 +588,12 @@ class Vapi extends VapiEventEmitter {
587
588
  this.speakingTimeout = null;
588
589
  }, 1000);
589
590
  }
591
+ /**
592
+ * Stops the call by destroying the Daily call object.
593
+ *
594
+ * If `roomDeleteOnUserLeaveEnabled` is set to `false`, the Vapi call will be kept alive, allowing reconnections to the same call using the `reconnect` method.
595
+ * If `roomDeleteOnUserLeaveEnabled` is set to `true`, the Vapi call will also be destroyed, preventing any reconnections.
596
+ */
590
597
  async stop() {
591
598
  this.started = false;
592
599
  if (this.call) {
@@ -595,6 +602,11 @@ class Vapi extends VapiEventEmitter {
595
602
  }
596
603
  this.speakingTimeout = null;
597
604
  }
605
+ /**
606
+ * Sends a Live Call Control message to the Vapi server.
607
+ *
608
+ * Docs: https://docs.vapi.ai/calls/call-features
609
+ */
598
610
  send(message) {
599
611
  this.call?.sendAppMessage(JSON.stringify(message));
600
612
  }
@@ -616,6 +628,17 @@ class Vapi extends VapiEventEmitter {
616
628
  interruptAssistantEnabled: interruptAssistantEnabled ?? false,
617
629
  });
618
630
  }
631
+ /**
632
+ * Ends the call immediately by sending a `end-call` message using Live Call Control, and destroys the Daily call object.
633
+ *
634
+ * This method always ends the call, regardless of the `roomDeleteOnUserLeaveEnabled` option.
635
+ */
636
+ end() {
637
+ this.send({
638
+ type: 'end-call',
639
+ });
640
+ this.stop();
641
+ }
619
642
  setInputDevicesAsync(options) {
620
643
  return (0, daily_guards_1.safeSetInputDevicesAsync)(this.call, options);
621
644
  }
@@ -654,5 +677,370 @@ class Vapi extends VapiEventEmitter {
654
677
  stopScreenSharing() {
655
678
  this.call?.stopScreenShare();
656
679
  }
680
+ /**
681
+ * Reconnects to an active call.
682
+ *
683
+ *
684
+ * @param webCall
685
+ */
686
+ async reconnect(webCall) {
687
+ const startTime = Date.now();
688
+ if (this.started) {
689
+ throw new Error('Cannot reconnect while a call is already in progress. Call stop() first.');
690
+ }
691
+ if (!webCall.webCallUrl) {
692
+ throw new Error('webCallUrl is required for reconnection.');
693
+ }
694
+ this.emit('call-start-progress', {
695
+ stage: 'reconnect-initialization',
696
+ status: 'started',
697
+ timestamp: new Date().toISOString(),
698
+ metadata: {
699
+ callId: webCall.id || 'unknown',
700
+ hasVideoRecording: !!webCall?.artifactPlan?.videoRecordingEnabled,
701
+ voiceProvider: webCall?.assistant?.voice?.provider || 'unknown'
702
+ }
703
+ });
704
+ this.started = true;
705
+ try {
706
+ // Clean up any existing call object
707
+ if (this.call) {
708
+ this.emit('call-start-progress', {
709
+ stage: 'cleanup-existing-call',
710
+ status: 'started',
711
+ timestamp: new Date().toISOString()
712
+ });
713
+ await this.cleanup();
714
+ this.emit('call-start-progress', {
715
+ stage: 'cleanup-existing-call',
716
+ status: 'completed',
717
+ timestamp: new Date().toISOString()
718
+ });
719
+ }
720
+ const isVideoRecordingEnabled = webCall?.artifactPlan?.videoRecordingEnabled ?? false;
721
+ const isVideoEnabled = webCall?.assistant?.voice?.provider === 'tavus';
722
+ // Stage 1: Create Daily call object
723
+ this.emit('call-start-progress', {
724
+ stage: 'daily-call-object-creation',
725
+ status: 'started',
726
+ timestamp: new Date().toISOString(),
727
+ metadata: {
728
+ audioSource: this.dailyCallObject.audioSource ?? true,
729
+ videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled,
730
+ isVideoRecordingEnabled,
731
+ isVideoEnabled
732
+ }
733
+ });
734
+ const dailyCallStartTime = Date.now();
735
+ this.call = daily_js_1.default.createCallObject({
736
+ audioSource: this.dailyCallObject.audioSource ?? true,
737
+ videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled,
738
+ dailyConfig: this.dailyCallConfig,
739
+ });
740
+ const dailyCallDuration = Date.now() - dailyCallStartTime;
741
+ this.emit('call-start-progress', {
742
+ stage: 'daily-call-object-creation',
743
+ status: 'completed',
744
+ duration: dailyCallDuration,
745
+ timestamp: new Date().toISOString()
746
+ });
747
+ this.call.iframe()?.style.setProperty('display', 'none');
748
+ // Set up event listeners
749
+ this.call.on('left-meeting', () => {
750
+ this.emit('call-end');
751
+ if (!this.hasEmittedCallEndedStatus) {
752
+ this.emit('message', {
753
+ type: 'status-update',
754
+ status: 'ended',
755
+ 'endedReason': 'customer-ended-call',
756
+ });
757
+ this.hasEmittedCallEndedStatus = true;
758
+ }
759
+ if (isVideoRecordingEnabled) {
760
+ this.call?.stopRecording();
761
+ }
762
+ this.cleanup().catch(console.error);
763
+ });
764
+ this.call.on('error', (error) => {
765
+ this.emit('error', error);
766
+ if (isVideoRecordingEnabled) {
767
+ this.call?.stopRecording();
768
+ }
769
+ });
770
+ this.call.on('camera-error', (error) => {
771
+ this.emit('camera-error', error);
772
+ });
773
+ this.call.on('network-quality-change', (event) => {
774
+ this.emit('network-quality-change', event);
775
+ });
776
+ this.call.on('network-connection', (event) => {
777
+ this.emit('network-connection', event);
778
+ });
779
+ this.call.on('track-started', async (e) => {
780
+ if (!e || !e.participant) {
781
+ return;
782
+ }
783
+ if (e.participant?.local) {
784
+ return;
785
+ }
786
+ if (e.participant?.user_name !== 'Vapi Speaker') {
787
+ return;
788
+ }
789
+ if (e.track.kind === 'video') {
790
+ this.emit('video', e.track);
791
+ }
792
+ if (e.track.kind === 'audio') {
793
+ await buildAudioPlayer(e.track, e.participant.session_id);
794
+ }
795
+ this.call?.sendAppMessage('playable');
796
+ });
797
+ this.call.on('participant-joined', (e) => {
798
+ if (!e || !this.call)
799
+ return;
800
+ subscribeToTracks(e, this.call, isVideoRecordingEnabled, isVideoEnabled);
801
+ });
802
+ this.call.on('participant-updated', (e) => {
803
+ if (!e) {
804
+ return;
805
+ }
806
+ this.emit('daily-participant-updated', e.participant);
807
+ });
808
+ this.call.on('participant-left', (e) => {
809
+ if (!e) {
810
+ return;
811
+ }
812
+ destroyAudioPlayer(e.participant.session_id);
813
+ });
814
+ this.call.on('remote-participants-audio-level', (e) => {
815
+ if (e)
816
+ this.handleRemoteParticipantsAudioLevel(e);
817
+ });
818
+ this.call.on('app-message', (e) => this.onAppMessage(e));
819
+ this.call.on('nonfatal-error', (e) => {
820
+ // https://docs.daily.co/reference/daily-js/events/meeting-events#type-audio-processor-error
821
+ if (e?.type === 'audio-processor-error') {
822
+ this.call
823
+ ?.updateInputSettings({
824
+ audio: {
825
+ processor: {
826
+ type: 'none',
827
+ },
828
+ },
829
+ })
830
+ .then(() => {
831
+ (0, daily_guards_1.safeSetLocalAudio)(this.call, true);
832
+ });
833
+ }
834
+ });
835
+ // Stage 2: Mobile device handling and permissions
836
+ const isMobile = this.isMobileDevice();
837
+ this.emit('call-start-progress', {
838
+ stage: 'mobile-permissions',
839
+ status: 'started',
840
+ timestamp: new Date().toISOString(),
841
+ metadata: { isMobile }
842
+ });
843
+ if (isMobile) {
844
+ const mobileWaitStartTime = Date.now();
845
+ await this.sleep(1000);
846
+ const mobileWaitDuration = Date.now() - mobileWaitStartTime;
847
+ this.emit('call-start-progress', {
848
+ stage: 'mobile-permissions',
849
+ status: 'completed',
850
+ duration: mobileWaitDuration,
851
+ timestamp: new Date().toISOString(),
852
+ metadata: { action: 'permissions-wait' }
853
+ });
854
+ }
855
+ else {
856
+ this.emit('call-start-progress', {
857
+ stage: 'mobile-permissions',
858
+ status: 'completed',
859
+ timestamp: new Date().toISOString(),
860
+ metadata: { action: 'skipped-not-mobile' }
861
+ });
862
+ }
863
+ // Stage 3: Join the call
864
+ this.emit('call-start-progress', {
865
+ stage: 'daily-call-join',
866
+ status: 'started',
867
+ timestamp: new Date().toISOString()
868
+ });
869
+ const joinStartTime = Date.now();
870
+ await this.call.join({
871
+ url: webCall.webCallUrl,
872
+ subscribeToTracksAutomatically: false,
873
+ });
874
+ const joinDuration = Date.now() - joinStartTime;
875
+ this.emit('call-start-progress', {
876
+ stage: 'daily-call-join',
877
+ status: 'completed',
878
+ duration: joinDuration,
879
+ timestamp: new Date().toISOString()
880
+ });
881
+ // Stage 4: Video recording setup (if enabled)
882
+ if (isVideoRecordingEnabled) {
883
+ this.emit('call-start-progress', {
884
+ stage: 'video-recording-setup',
885
+ status: 'started',
886
+ timestamp: new Date().toISOString()
887
+ });
888
+ const recordingStartTime = Date.now();
889
+ const recordingRequestedTime = new Date().getTime();
890
+ try {
891
+ this.call.startRecording({
892
+ width: 1280,
893
+ height: 720,
894
+ backgroundColor: '#FF1F2D3D',
895
+ layout: {
896
+ preset: 'default',
897
+ },
898
+ });
899
+ const recordingSetupDuration = Date.now() - recordingStartTime;
900
+ this.emit('call-start-progress', {
901
+ stage: 'video-recording-setup',
902
+ status: 'completed',
903
+ duration: recordingSetupDuration,
904
+ timestamp: new Date().toISOString()
905
+ });
906
+ this.call.on('recording-started', () => {
907
+ const totalRecordingDelay = (new Date().getTime() - recordingRequestedTime) / 1000;
908
+ this.emit('call-start-progress', {
909
+ stage: 'video-recording-started',
910
+ status: 'completed',
911
+ timestamp: new Date().toISOString(),
912
+ metadata: { delaySeconds: totalRecordingDelay }
913
+ });
914
+ this.send({
915
+ type: 'control',
916
+ control: 'say-first-message',
917
+ videoRecordingStartDelaySeconds: totalRecordingDelay,
918
+ });
919
+ });
920
+ }
921
+ catch (error) {
922
+ const recordingSetupDuration = Date.now() - recordingStartTime;
923
+ this.emit('call-start-progress', {
924
+ stage: 'video-recording-setup',
925
+ status: 'failed',
926
+ duration: recordingSetupDuration,
927
+ timestamp: new Date().toISOString(),
928
+ metadata: { error: error?.toString() }
929
+ });
930
+ // Don't throw here, video recording is optional
931
+ }
932
+ }
933
+ else {
934
+ this.emit('call-start-progress', {
935
+ stage: 'video-recording-setup',
936
+ status: 'completed',
937
+ timestamp: new Date().toISOString(),
938
+ metadata: { action: 'skipped-not-enabled' }
939
+ });
940
+ }
941
+ // Stage 5: Audio level observer setup
942
+ this.emit('call-start-progress', {
943
+ stage: 'audio-observer-setup',
944
+ status: 'started',
945
+ timestamp: new Date().toISOString()
946
+ });
947
+ const audioObserverStartTime = Date.now();
948
+ try {
949
+ this.call.startRemoteParticipantsAudioLevelObserver(100);
950
+ const audioObserverDuration = Date.now() - audioObserverStartTime;
951
+ this.emit('call-start-progress', {
952
+ stage: 'audio-observer-setup',
953
+ status: 'completed',
954
+ duration: audioObserverDuration,
955
+ timestamp: new Date().toISOString()
956
+ });
957
+ }
958
+ catch (error) {
959
+ const audioObserverDuration = Date.now() - audioObserverStartTime;
960
+ this.emit('call-start-progress', {
961
+ stage: 'audio-observer-setup',
962
+ status: 'failed',
963
+ duration: audioObserverDuration,
964
+ timestamp: new Date().toISOString(),
965
+ metadata: { error: error?.toString() }
966
+ });
967
+ // Don't throw here, this is non-critical
968
+ }
969
+ // Stage 6: Audio processing setup
970
+ this.emit('call-start-progress', {
971
+ stage: 'audio-processing-setup',
972
+ status: 'started',
973
+ timestamp: new Date().toISOString()
974
+ });
975
+ const audioProcessingStartTime = Date.now();
976
+ try {
977
+ this.call.updateInputSettings({
978
+ audio: {
979
+ processor: {
980
+ type: 'noise-cancellation',
981
+ },
982
+ },
983
+ });
984
+ const audioProcessingDuration = Date.now() - audioProcessingStartTime;
985
+ this.emit('call-start-progress', {
986
+ stage: 'audio-processing-setup',
987
+ status: 'completed',
988
+ duration: audioProcessingDuration,
989
+ timestamp: new Date().toISOString()
990
+ });
991
+ }
992
+ catch (error) {
993
+ const audioProcessingDuration = Date.now() - audioProcessingStartTime;
994
+ this.emit('call-start-progress', {
995
+ stage: 'audio-processing-setup',
996
+ status: 'failed',
997
+ duration: audioProcessingDuration,
998
+ timestamp: new Date().toISOString(),
999
+ metadata: { error: error?.toString() }
1000
+ });
1001
+ // Don't throw here, this is non-critical
1002
+ }
1003
+ const totalDuration = Date.now() - startTime;
1004
+ this.emit('call-start-success', {
1005
+ totalDuration,
1006
+ callId: webCall?.id || 'unknown',
1007
+ timestamp: new Date().toISOString()
1008
+ });
1009
+ this.emit('call-start');
1010
+ }
1011
+ catch (e) {
1012
+ const totalDuration = Date.now() - startTime;
1013
+ this.emit('call-start-failed', {
1014
+ stage: 'reconnect',
1015
+ totalDuration,
1016
+ error: e?.toString() || 'Unknown error occurred',
1017
+ errorStack: e instanceof Error ? e.stack : 'No stack trace available',
1018
+ timestamp: new Date().toISOString(),
1019
+ context: {
1020
+ isReconnect: true,
1021
+ callId: webCall?.id || 'unknown',
1022
+ hasVideoRecording: !!webCall?.artifactPlan?.videoRecordingEnabled,
1023
+ voiceProvider: webCall?.assistant?.voice?.provider || 'unknown',
1024
+ isMobile: this.isMobileDevice()
1025
+ }
1026
+ });
1027
+ // Also emit the generic error event for backward compatibility
1028
+ this.emit('error', {
1029
+ type: 'reconnect-error',
1030
+ error: e,
1031
+ totalDuration,
1032
+ timestamp: new Date().toISOString(),
1033
+ context: {
1034
+ isReconnect: true,
1035
+ callId: webCall?.id || 'unknown',
1036
+ hasVideoRecording: !!webCall?.artifactPlan?.videoRecordingEnabled,
1037
+ voiceProvider: webCall?.assistant?.voice?.provider || 'unknown',
1038
+ isMobile: this.isMobileDevice()
1039
+ }
1040
+ });
1041
+ await this.cleanup();
1042
+ throw e;
1043
+ }
1044
+ }
657
1045
  }
658
1046
  exports.default = Vapi;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vapi-ai/web",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "",
5
5
  "main": "dist/vapi.js",
6
6
  "types": "dist/vapi.d.ts",
@@ -14,7 +14,8 @@
14
14
  "test:example": "npm run pack:local && cd example && npm install && npm run build",
15
15
  "pack:local": "./scripts/build-latest.sh",
16
16
  "clean-builds": "rm -f vapi-ai-web-*.tgz && echo '🧹 Cleaned up all build tarballs'",
17
- "dev:example": "npm run pack:local && cd example && npm install && npm run dev"
17
+ "dev:example": "npm run pack:local && cd example && npm install && npm run dev",
18
+ "re-build-local-example": "./scripts/re-build-local-example.sh"
18
19
  },
19
20
  "repository": {
20
21
  "type": "git",
package/vapi.ts CHANGED
@@ -28,6 +28,10 @@ import {
28
28
  safeSetInputDevicesAsync,
29
29
  } from './daily-guards';
30
30
 
31
+ export interface EndCallMessage {
32
+ type: 'end-call';
33
+ }
34
+
31
35
  export interface AddMessageMessage {
32
36
  type: 'add-message';
33
37
  message: ChatCompletionMessageParam;
@@ -51,7 +55,8 @@ export interface SayMessage {
51
55
  type VapiClientToServerMessage =
52
56
  | AddMessageMessage
53
57
  | ControlMessages
54
- | SayMessage;
58
+ | SayMessage
59
+ | EndCallMessage;
55
60
 
56
61
  type VapiEventNames =
57
62
  | 'call-end'
@@ -111,6 +116,43 @@ type VapiEventListeners = {
111
116
  'call-start-failed': (event: CallStartFailedEvent) => void;
112
117
  };
113
118
 
119
+ type StartCallOptions = {
120
+ /**
121
+ * This determines whether the daily room will be deleted and all participants will be kicked once the user leaves the room.
122
+ * If set to `false`, the room will be kept alive even after the user leaves, allowing clients to reconnect to the same room.
123
+ * If set to `true`, the room will be deleted and reconnection will not be allowed.
124
+ *
125
+ * Defaults to `true`.
126
+ * @example true
127
+ */
128
+ roomDeleteOnUserLeaveEnabled?: boolean;
129
+ }
130
+
131
+ type WebCall = {
132
+ /**
133
+ * The Vapi WebCall URL. This is the URL that the call will be joined on.
134
+ *
135
+ * call.webCallUrl or call.transport.callUrl
136
+ */
137
+ webCallUrl: string;
138
+ /**
139
+ * The Vapi WebCall ID. This is the ID of the call.
140
+ *
141
+ * call.id
142
+ */
143
+ id?: string;
144
+ /**
145
+ * The Vapi WebCall artifact plan. This is the artifact plan of the call.
146
+ */
147
+ artifactPlan?: { videoRecordingEnabled?: boolean };
148
+ /**
149
+ * The Vapi WebCall assistant. This is the assistant of the call.
150
+ *
151
+ * call.assistant
152
+ */
153
+ assistant?: { voice?: { provider?: string } };
154
+ }
155
+
114
156
  async function startAudioPlayer(
115
157
  player: HTMLAudioElement,
116
158
  track: MediaStreamTrack,
@@ -246,6 +288,7 @@ export default class Vapi extends VapiEventEmitter {
246
288
  squad?: CreateSquadDTO | string,
247
289
  workflow?: CreateWorkflowDTO | string,
248
290
  workflowOverrides?: WorkflowOverrides,
291
+ options?: StartCallOptions
249
292
  ): Promise<Call | null> {
250
293
  const startTime = Date.now();
251
294
 
@@ -304,6 +347,7 @@ export default class Vapi extends VapiEventEmitter {
304
347
  workflow: typeof workflow === 'string' ? undefined : workflow,
305
348
  workflowId: typeof workflow === 'string' ? workflow : undefined,
306
349
  workflowOverrides,
350
+ roomDeleteOnUserLeaveEnabled: options?.roomDeleteOnUserLeaveEnabled,
307
351
  })
308
352
  ).data;
309
353
 
@@ -802,6 +846,12 @@ export default class Vapi extends VapiEventEmitter {
802
846
  }, 1000);
803
847
  }
804
848
 
849
+ /**
850
+ * Stops the call by destroying the Daily call object.
851
+ *
852
+ * If `roomDeleteOnUserLeaveEnabled` is set to `false`, the Vapi call will be kept alive, allowing reconnections to the same call using the `reconnect` method.
853
+ * If `roomDeleteOnUserLeaveEnabled` is set to `true`, the Vapi call will also be destroyed, preventing any reconnections.
854
+ */
805
855
  async stop(): Promise<void> {
806
856
  this.started = false;
807
857
  if (this.call) {
@@ -811,6 +861,11 @@ export default class Vapi extends VapiEventEmitter {
811
861
  this.speakingTimeout = null;
812
862
  }
813
863
 
864
+ /**
865
+ * Sends a Live Call Control message to the Vapi server.
866
+ *
867
+ * Docs: https://docs.vapi.ai/calls/call-features
868
+ */
814
869
  send(message: VapiClientToServerMessage): void {
815
870
  this.call?.sendAppMessage(JSON.stringify(message));
816
871
  }
@@ -837,6 +892,18 @@ export default class Vapi extends VapiEventEmitter {
837
892
  });
838
893
  }
839
894
 
895
+ /**
896
+ * Ends the call immediately by sending a `end-call` message using Live Call Control, and destroys the Daily call object.
897
+ *
898
+ * This method always ends the call, regardless of the `roomDeleteOnUserLeaveEnabled` option.
899
+ */
900
+ public end() {
901
+ this.send({
902
+ type: 'end-call',
903
+ });
904
+ this.stop();
905
+ }
906
+
840
907
  public setInputDevicesAsync(
841
908
  options: Parameters<DailyCall['setInputDevicesAsync']>[0],
842
909
  ) {
@@ -889,4 +956,413 @@ export default class Vapi extends VapiEventEmitter {
889
956
  public stopScreenSharing() {
890
957
  this.call?.stopScreenShare();
891
958
  }
959
+
960
+ /**
961
+ * Reconnects to an active call.
962
+ *
963
+ *
964
+ * @param webCall
965
+ */
966
+ async reconnect(webCall: WebCall): Promise<void> {
967
+ const startTime = Date.now();
968
+
969
+ if (this.started) {
970
+ throw new Error('Cannot reconnect while a call is already in progress. Call stop() first.');
971
+ }
972
+
973
+ if (!webCall.webCallUrl) {
974
+ throw new Error('webCallUrl is required for reconnection.');
975
+ }
976
+
977
+ this.emit('call-start-progress', {
978
+ stage: 'reconnect-initialization',
979
+ status: 'started',
980
+ timestamp: new Date().toISOString(),
981
+ metadata: {
982
+ callId: webCall.id || 'unknown',
983
+ hasVideoRecording: !!webCall?.artifactPlan?.videoRecordingEnabled,
984
+ voiceProvider: webCall?.assistant?.voice?.provider || 'unknown'
985
+ }
986
+ });
987
+
988
+ this.started = true;
989
+
990
+ try {
991
+ // Clean up any existing call object
992
+ if (this.call) {
993
+ this.emit('call-start-progress', {
994
+ stage: 'cleanup-existing-call',
995
+ status: 'started',
996
+ timestamp: new Date().toISOString()
997
+ });
998
+ await this.cleanup();
999
+ this.emit('call-start-progress', {
1000
+ stage: 'cleanup-existing-call',
1001
+ status: 'completed',
1002
+ timestamp: new Date().toISOString()
1003
+ });
1004
+ }
1005
+
1006
+ const isVideoRecordingEnabled = webCall?.artifactPlan?.videoRecordingEnabled ?? false;
1007
+ const isVideoEnabled = webCall?.assistant?.voice?.provider === 'tavus';
1008
+
1009
+ // Stage 1: Create Daily call object
1010
+ this.emit('call-start-progress', {
1011
+ stage: 'daily-call-object-creation',
1012
+ status: 'started',
1013
+ timestamp: new Date().toISOString(),
1014
+ metadata: {
1015
+ audioSource: this.dailyCallObject.audioSource ?? true,
1016
+ videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled,
1017
+ isVideoRecordingEnabled,
1018
+ isVideoEnabled
1019
+ }
1020
+ });
1021
+
1022
+ const dailyCallStartTime = Date.now();
1023
+ this.call = DailyIframe.createCallObject({
1024
+ audioSource: this.dailyCallObject.audioSource ?? true,
1025
+ videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled,
1026
+ dailyConfig: this.dailyCallConfig,
1027
+ });
1028
+
1029
+ const dailyCallDuration = Date.now() - dailyCallStartTime;
1030
+ this.emit('call-start-progress', {
1031
+ stage: 'daily-call-object-creation',
1032
+ status: 'completed',
1033
+ duration: dailyCallDuration,
1034
+ timestamp: new Date().toISOString()
1035
+ });
1036
+
1037
+ this.call.iframe()?.style.setProperty('display', 'none');
1038
+
1039
+ // Set up event listeners
1040
+ this.call.on('left-meeting', () => {
1041
+ this.emit('call-end');
1042
+ if (!this.hasEmittedCallEndedStatus) {
1043
+ this.emit('message', {
1044
+ type: 'status-update',
1045
+ status: 'ended',
1046
+ 'endedReason': 'customer-ended-call',
1047
+ });
1048
+ this.hasEmittedCallEndedStatus = true;
1049
+ }
1050
+ if (isVideoRecordingEnabled) {
1051
+ this.call?.stopRecording();
1052
+ }
1053
+ this.cleanup().catch(console.error);
1054
+ });
1055
+
1056
+ this.call.on('error', (error: any) => {
1057
+ this.emit('error', error);
1058
+ if (isVideoRecordingEnabled) {
1059
+ this.call?.stopRecording();
1060
+ }
1061
+ });
1062
+
1063
+ this.call.on('camera-error', (error: any) => {
1064
+ this.emit('camera-error', error);
1065
+ });
1066
+
1067
+ this.call.on('network-quality-change', (event: any) => {
1068
+ this.emit('network-quality-change', event);
1069
+ });
1070
+
1071
+ this.call.on('network-connection', (event: any) => {
1072
+ this.emit('network-connection', event);
1073
+ });
1074
+
1075
+ this.call.on('track-started', async (e) => {
1076
+ if (!e || !e.participant) {
1077
+ return;
1078
+ }
1079
+ if (e.participant?.local) {
1080
+ return;
1081
+ }
1082
+ if (e.participant?.user_name !== 'Vapi Speaker') {
1083
+ return;
1084
+ }
1085
+ if (e.track.kind === 'video') {
1086
+ this.emit('video', e.track);
1087
+ }
1088
+ if (e.track.kind === 'audio') {
1089
+ await buildAudioPlayer(e.track, e.participant.session_id);
1090
+ }
1091
+ this.call?.sendAppMessage('playable');
1092
+ });
1093
+
1094
+ this.call.on('participant-joined', (e) => {
1095
+ if (!e || !this.call) return;
1096
+ subscribeToTracks(
1097
+ e,
1098
+ this.call,
1099
+ isVideoRecordingEnabled,
1100
+ isVideoEnabled,
1101
+ );
1102
+ });
1103
+
1104
+ this.call.on('participant-updated', (e) => {
1105
+ if (!e) {
1106
+ return;
1107
+ }
1108
+ this.emit('daily-participant-updated', e.participant);
1109
+ });
1110
+
1111
+ this.call.on('participant-left', (e) => {
1112
+ if (!e) {
1113
+ return;
1114
+ }
1115
+ destroyAudioPlayer(e.participant.session_id);
1116
+ });
1117
+
1118
+ this.call.on('remote-participants-audio-level', (e) => {
1119
+ if (e) this.handleRemoteParticipantsAudioLevel(e);
1120
+ });
1121
+
1122
+ this.call.on('app-message', (e) => this.onAppMessage(e));
1123
+
1124
+ this.call.on('nonfatal-error', (e) => {
1125
+ // https://docs.daily.co/reference/daily-js/events/meeting-events#type-audio-processor-error
1126
+ if (e?.type === 'audio-processor-error') {
1127
+ this.call
1128
+ ?.updateInputSettings({
1129
+ audio: {
1130
+ processor: {
1131
+ type: 'none',
1132
+ },
1133
+ },
1134
+ })
1135
+ .then(() => {
1136
+ safeSetLocalAudio(this.call, true);
1137
+ });
1138
+ }
1139
+ });
1140
+
1141
+ // Stage 2: Mobile device handling and permissions
1142
+ const isMobile = this.isMobileDevice();
1143
+ this.emit('call-start-progress', {
1144
+ stage: 'mobile-permissions',
1145
+ status: 'started',
1146
+ timestamp: new Date().toISOString(),
1147
+ metadata: { isMobile }
1148
+ });
1149
+
1150
+ if (isMobile) {
1151
+ const mobileWaitStartTime = Date.now();
1152
+ await this.sleep(1000);
1153
+ const mobileWaitDuration = Date.now() - mobileWaitStartTime;
1154
+ this.emit('call-start-progress', {
1155
+ stage: 'mobile-permissions',
1156
+ status: 'completed',
1157
+ duration: mobileWaitDuration,
1158
+ timestamp: new Date().toISOString(),
1159
+ metadata: { action: 'permissions-wait' }
1160
+ });
1161
+ } else {
1162
+ this.emit('call-start-progress', {
1163
+ stage: 'mobile-permissions',
1164
+ status: 'completed',
1165
+ timestamp: new Date().toISOString(),
1166
+ metadata: { action: 'skipped-not-mobile' }
1167
+ });
1168
+ }
1169
+
1170
+ // Stage 3: Join the call
1171
+ this.emit('call-start-progress', {
1172
+ stage: 'daily-call-join',
1173
+ status: 'started',
1174
+ timestamp: new Date().toISOString()
1175
+ });
1176
+
1177
+ const joinStartTime = Date.now();
1178
+ await this.call.join({
1179
+ url: webCall.webCallUrl,
1180
+ subscribeToTracksAutomatically: false,
1181
+ });
1182
+
1183
+ const joinDuration = Date.now() - joinStartTime;
1184
+ this.emit('call-start-progress', {
1185
+ stage: 'daily-call-join',
1186
+ status: 'completed',
1187
+ duration: joinDuration,
1188
+ timestamp: new Date().toISOString()
1189
+ });
1190
+
1191
+ // Stage 4: Video recording setup (if enabled)
1192
+ if (isVideoRecordingEnabled) {
1193
+ this.emit('call-start-progress', {
1194
+ stage: 'video-recording-setup',
1195
+ status: 'started',
1196
+ timestamp: new Date().toISOString()
1197
+ });
1198
+
1199
+ const recordingStartTime = Date.now();
1200
+ const recordingRequestedTime = new Date().getTime();
1201
+
1202
+ try {
1203
+ this.call.startRecording({
1204
+ width: 1280,
1205
+ height: 720,
1206
+ backgroundColor: '#FF1F2D3D',
1207
+ layout: {
1208
+ preset: 'default',
1209
+ },
1210
+ });
1211
+
1212
+ const recordingSetupDuration = Date.now() - recordingStartTime;
1213
+ this.emit('call-start-progress', {
1214
+ stage: 'video-recording-setup',
1215
+ status: 'completed',
1216
+ duration: recordingSetupDuration,
1217
+ timestamp: new Date().toISOString()
1218
+ });
1219
+
1220
+ this.call.on('recording-started', () => {
1221
+ const totalRecordingDelay = (new Date().getTime() - recordingRequestedTime) / 1000;
1222
+ this.emit('call-start-progress', {
1223
+ stage: 'video-recording-started',
1224
+ status: 'completed',
1225
+ timestamp: new Date().toISOString(),
1226
+ metadata: { delaySeconds: totalRecordingDelay }
1227
+ });
1228
+
1229
+ this.send({
1230
+ type: 'control',
1231
+ control: 'say-first-message',
1232
+ videoRecordingStartDelaySeconds: totalRecordingDelay,
1233
+ });
1234
+ });
1235
+ } catch (error) {
1236
+ const recordingSetupDuration = Date.now() - recordingStartTime;
1237
+ this.emit('call-start-progress', {
1238
+ stage: 'video-recording-setup',
1239
+ status: 'failed',
1240
+ duration: recordingSetupDuration,
1241
+ timestamp: new Date().toISOString(),
1242
+ metadata: { error: error?.toString() }
1243
+ });
1244
+ // Don't throw here, video recording is optional
1245
+ }
1246
+ } else {
1247
+ this.emit('call-start-progress', {
1248
+ stage: 'video-recording-setup',
1249
+ status: 'completed',
1250
+ timestamp: new Date().toISOString(),
1251
+ metadata: { action: 'skipped-not-enabled' }
1252
+ });
1253
+ }
1254
+
1255
+ // Stage 5: Audio level observer setup
1256
+ this.emit('call-start-progress', {
1257
+ stage: 'audio-observer-setup',
1258
+ status: 'started',
1259
+ timestamp: new Date().toISOString()
1260
+ });
1261
+
1262
+ const audioObserverStartTime = Date.now();
1263
+
1264
+ try {
1265
+ this.call.startRemoteParticipantsAudioLevelObserver(100);
1266
+ const audioObserverDuration = Date.now() - audioObserverStartTime;
1267
+ this.emit('call-start-progress', {
1268
+ stage: 'audio-observer-setup',
1269
+ status: 'completed',
1270
+ duration: audioObserverDuration,
1271
+ timestamp: new Date().toISOString()
1272
+ });
1273
+ } catch (error) {
1274
+ const audioObserverDuration = Date.now() - audioObserverStartTime;
1275
+ this.emit('call-start-progress', {
1276
+ stage: 'audio-observer-setup',
1277
+ status: 'failed',
1278
+ duration: audioObserverDuration,
1279
+ timestamp: new Date().toISOString(),
1280
+ metadata: { error: error?.toString() }
1281
+ });
1282
+ // Don't throw here, this is non-critical
1283
+ }
1284
+
1285
+ // Stage 6: Audio processing setup
1286
+ this.emit('call-start-progress', {
1287
+ stage: 'audio-processing-setup',
1288
+ status: 'started',
1289
+ timestamp: new Date().toISOString()
1290
+ });
1291
+
1292
+ const audioProcessingStartTime = Date.now();
1293
+
1294
+ try {
1295
+ this.call.updateInputSettings({
1296
+ audio: {
1297
+ processor: {
1298
+ type: 'noise-cancellation',
1299
+ },
1300
+ },
1301
+ });
1302
+
1303
+ const audioProcessingDuration = Date.now() - audioProcessingStartTime;
1304
+ this.emit('call-start-progress', {
1305
+ stage: 'audio-processing-setup',
1306
+ status: 'completed',
1307
+ duration: audioProcessingDuration,
1308
+ timestamp: new Date().toISOString()
1309
+ });
1310
+ } catch (error) {
1311
+ const audioProcessingDuration = Date.now() - audioProcessingStartTime;
1312
+ this.emit('call-start-progress', {
1313
+ stage: 'audio-processing-setup',
1314
+ status: 'failed',
1315
+ duration: audioProcessingDuration,
1316
+ timestamp: new Date().toISOString(),
1317
+ metadata: { error: error?.toString() }
1318
+ });
1319
+ // Don't throw here, this is non-critical
1320
+ }
1321
+
1322
+ const totalDuration = Date.now() - startTime;
1323
+ this.emit('call-start-success', {
1324
+ totalDuration,
1325
+ callId: webCall?.id || 'unknown',
1326
+ timestamp: new Date().toISOString()
1327
+ });
1328
+
1329
+ this.emit('call-start');
1330
+
1331
+ } catch (e) {
1332
+ const totalDuration = Date.now() - startTime;
1333
+
1334
+ this.emit('call-start-failed', {
1335
+ stage: 'reconnect',
1336
+ totalDuration,
1337
+ error: e?.toString() || 'Unknown error occurred',
1338
+ errorStack: e instanceof Error ? e.stack : 'No stack trace available',
1339
+ timestamp: new Date().toISOString(),
1340
+ context: {
1341
+ isReconnect: true,
1342
+ callId: webCall?.id || 'unknown',
1343
+ hasVideoRecording: !!webCall?.artifactPlan?.videoRecordingEnabled,
1344
+ voiceProvider: webCall?.assistant?.voice?.provider || 'unknown',
1345
+ isMobile: this.isMobileDevice()
1346
+ }
1347
+ });
1348
+
1349
+ // Also emit the generic error event for backward compatibility
1350
+ this.emit('error', {
1351
+ type: 'reconnect-error',
1352
+ error: e,
1353
+ totalDuration,
1354
+ timestamp: new Date().toISOString(),
1355
+ context: {
1356
+ isReconnect: true,
1357
+ callId: webCall?.id || 'unknown',
1358
+ hasVideoRecording: !!webCall?.artifactPlan?.videoRecordingEnabled,
1359
+ voiceProvider: webCall?.assistant?.voice?.provider || 'unknown',
1360
+ isMobile: this.isMobileDevice()
1361
+ }
1362
+ });
1363
+
1364
+ await this.cleanup();
1365
+ throw e;
1366
+ }
1367
+ }
892
1368
  }