From 5d627f49740d63dc41e84acc5ff03a407113f234 Mon Sep 17 00:00:00 2001 From: zachm-vapi <276893101+zachm-vapi@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:25:14 -0700 Subject: [PATCH 1/2] Use meeting token to join room when available The meeting token will auto-start the recording, so we don't need to start it manually. --- README.md | 2 +- vapi.ts | 143 +++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 104 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 564bae095..35939b083 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ vapi.say("Our time's up, goodbye!", true) ## Recording -When video recording is enabled on the call's artifact plan, Vapi starts a recording automatically when the call begins. Recording start is asynchronous: success is signaled by the `recording-started` event and failure by the `recording-error` event. +When video recording is enabled on the call's artifact plan, Vapi starts a recording automatically when the call begins. When the server provides a meeting token for the call (`call.transport.callToken`), the SDK joins with it and Daily auto-starts the cloud recording, which is more reliable on weak networks. Otherwise the SDK starts the recording from the client after joining. Recording start is asynchronous: success is signaled by the `recording-started` event and failure by the `recording-error` event. A failed recording start is not retried automatically. Use `startRecording()` and `stopRecording()` to control the recording yourself. For example, to retry after a failure: diff --git a/vapi.ts b/vapi.ts index d92fca060..97cfc6999 100644 --- a/vapi.ts +++ b/vapi.ts @@ -233,10 +233,18 @@ type WebCall = { artifactPlan?: { videoRecordingEnabled?: boolean }; /** * The Vapi WebCall assistant. This is the assistant of the call. - * + * * call.assistant */ assistant?: { voice?: { provider?: string } }; + /** + * The Vapi WebCall transport. When video recording is enabled, the server + * sets `callToken` to a Daily meeting token that auto-starts the cloud + * recording on join. + * + * call.transport + */ + transport?: { callToken?: string }; } async function startAudioPlayer( @@ -465,6 +473,14 @@ export default class Vapi extends VapiEventEmitter { const isVideoEnabled = webCall?.assistant?.voice?.provider === 'tavus'; + // When video recording is enabled, the server sends a Daily meeting + // token that auto-starts the cloud recording on join — more reliable + // than calling startRecording() from a weak client network. Older + // servers don't send one; then the client starts the recording itself. + const callToken = ( + webCall?.transport as { callToken?: string } | undefined + )?.callToken; + // Stage 2: Create Daily call object this.emit('call-start-progress', { stage: 'daily-call-object-creation', @@ -526,6 +542,8 @@ export default class Vapi extends VapiEventEmitter { this.hasEmittedCallEndedStatus = true; } if (isVideoRecordingEnabled) { + // When using a token, the recording doesn't stop automatically on + // leave, so still stop it manually to preserve existing behavior. this.call?.stopRecording(); } this.cleanup().catch(console.error); @@ -621,6 +639,28 @@ export default class Vapi extends VapiEventEmitter { destroyAudioPlayer(e.participant.session_id); }); + // Registered before join(): a token-auto-started recording can begin + // while join() is still in flight, so a listener added after join + // could miss the event. + let recordingRequestedTime = 0; + if (isVideoRecordingEnabled) { + this.call.once('recording-started', () => { + const totalRecordingDelay = (new Date().getTime() - recordingRequestedTime) / 1000; + this.emit('call-start-progress', { + stage: 'video-recording-started', + status: 'completed', + timestamp: new Date().toISOString(), + metadata: { delaySeconds: totalRecordingDelay } + }); + + this.send({ + type: 'control', + control: 'say-first-message', + videoRecordingStartDelaySeconds: totalRecordingDelay, + }); + }); + } + // Stage 3: Mobile device handling and permissions const isMobile = this.isMobileDevice(); this.emit('call-start-progress', { @@ -658,11 +698,15 @@ export default class Vapi extends VapiEventEmitter { }); const joinStartTime = Date.now(); - + recordingRequestedTime = joinStartTime; + try { await this.call.join({ // @ts-expect-error This exists url: webCall.webCallUrl, + // daily-js rejects a `token` key that is present but undefined, so + // only include it when the server actually sent one. + ...(callToken ? { token: callToken } : {}), subscribeToTracksAutomatically: false, }); @@ -694,17 +738,26 @@ export default class Vapi extends VapiEventEmitter { } // Stage 5: Video recording setup (if enabled) - if (isVideoRecordingEnabled) { + if (isVideoRecordingEnabled && callToken) { + // The meeting token auto-started the recording at join, so there is + // nothing to request from the client. + this.emit('call-start-progress', { + stage: 'video-recording-setup', + status: 'completed', + timestamp: new Date().toISOString(), + metadata: { action: 'auto-started-via-meeting-token' } + }); + } else if (isVideoRecordingEnabled) { this.emit('call-start-progress', { stage: 'video-recording-setup', status: 'started', timestamp: new Date().toISOString() }); - - const recordingRequestedTime = new Date().getTime(); + const recordingStartTime = Date.now(); try { + recordingRequestedTime = new Date().getTime(); this.startRecording(); const recordingSetupDuration = Date.now() - recordingStartTime; @@ -714,22 +767,6 @@ export default class Vapi extends VapiEventEmitter { duration: recordingSetupDuration, timestamp: new Date().toISOString() }); - - this.call.once('recording-started', () => { - const totalRecordingDelay = (new Date().getTime() - recordingRequestedTime) / 1000; - this.emit('call-start-progress', { - stage: 'video-recording-started', - status: 'completed', - timestamp: new Date().toISOString(), - metadata: { delaySeconds: totalRecordingDelay } - }); - - this.send({ - type: 'control', - control: 'say-first-message', - videoRecordingStartDelaySeconds: totalRecordingDelay, - }); - }); } catch (error) { const recordingSetupDuration = Date.now() - recordingStartTime; const serializedError = serializeError(error); @@ -1213,6 +1250,11 @@ export default class Vapi extends VapiEventEmitter { const isVideoRecordingEnabled = webCall?.artifactPlan?.videoRecordingEnabled ?? false; const isVideoEnabled = webCall?.assistant?.voice?.provider === 'tavus'; + // Same auto-start token as in start(). Rejoining with it while the + // recording is still running is safe; if the recording stopped when the + // user left, the token starts a new one. + const callToken = webCall?.transport?.callToken; + // Stage 1: Create Daily call object this.emit('call-start-progress', { stage: 'daily-call-object-creation', @@ -1255,6 +1297,8 @@ export default class Vapi extends VapiEventEmitter { this.hasEmittedCallEndedStatus = true; } if (isVideoRecordingEnabled) { + // When using a token, the recording doesn't stop automatically on + // leave, so still stop it manually to preserve existing behavior. this.call?.stopRecording(); } this.cleanup().catch(console.error); @@ -1380,6 +1424,28 @@ export default class Vapi extends VapiEventEmitter { } }); + // Registered before join(): a token-auto-started recording can begin + // while join() is still in flight, so a listener added after join + // could miss the event. + let recordingRequestedTime = 0; + if (isVideoRecordingEnabled) { + this.call.once('recording-started', () => { + const totalRecordingDelay = (new Date().getTime() - recordingRequestedTime) / 1000; + this.emit('call-start-progress', { + stage: 'video-recording-started', + status: 'completed', + timestamp: new Date().toISOString(), + metadata: { delaySeconds: totalRecordingDelay } + }); + + this.send({ + type: 'control', + control: 'say-first-message', + videoRecordingStartDelaySeconds: totalRecordingDelay, + }); + }); + } + // Stage 2: Mobile device handling and permissions const isMobile = this.isMobileDevice(); this.emit('call-start-progress', { @@ -1417,8 +1483,12 @@ export default class Vapi extends VapiEventEmitter { }); const joinStartTime = Date.now(); + recordingRequestedTime = joinStartTime; await this.call.join({ url: webCall.webCallUrl, + // daily-js rejects a `token` key that is present but undefined, so + // only include it when the server actually sent one. + ...(callToken ? { token: callToken } : {}), subscribeToTracksAutomatically: false, }); @@ -1431,17 +1501,26 @@ export default class Vapi extends VapiEventEmitter { }); // Stage 4: Video recording setup (if enabled) - if (isVideoRecordingEnabled) { + if (isVideoRecordingEnabled && callToken) { + // The meeting token auto-started the recording at join, so there is + // nothing to request from the client. + this.emit('call-start-progress', { + stage: 'video-recording-setup', + status: 'completed', + timestamp: new Date().toISOString(), + metadata: { action: 'auto-started-via-meeting-token' } + }); + } else if (isVideoRecordingEnabled) { this.emit('call-start-progress', { stage: 'video-recording-setup', status: 'started', timestamp: new Date().toISOString() }); - + const recordingStartTime = Date.now(); - const recordingRequestedTime = new Date().getTime(); try { + recordingRequestedTime = new Date().getTime(); this.startRecording(); const recordingSetupDuration = Date.now() - recordingStartTime; @@ -1451,22 +1530,6 @@ export default class Vapi extends VapiEventEmitter { duration: recordingSetupDuration, timestamp: new Date().toISOString() }); - - this.call.once('recording-started', () => { - const totalRecordingDelay = (new Date().getTime() - recordingRequestedTime) / 1000; - this.emit('call-start-progress', { - stage: 'video-recording-started', - status: 'completed', - timestamp: new Date().toISOString(), - metadata: { delaySeconds: totalRecordingDelay } - }); - - this.send({ - type: 'control', - control: 'say-first-message', - videoRecordingStartDelaySeconds: totalRecordingDelay, - }); - }); } catch (error) { const recordingSetupDuration = Date.now() - recordingStartTime; const serializedError = serializeError(error); From 6ecaadfdee3b3b8908966826d8350d9774931c1e Mon Sep 17 00:00:00 2001 From: zachm-vapi <276893101+zachm-vapi@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:26:01 -0700 Subject: [PATCH 2/2] Update example app to support video recording and reconnects - Added a video-recording toggle and a webcam view. - Fixed the Stop Call button so it actually stops the call instead of ending it, so that we can test reconnecting. Also added a separate End Call button. --- example/src/App.tsx | 122 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 9 deletions(-) diff --git a/example/src/App.tsx b/example/src/App.tsx index a1179d258..eaa2a51b6 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import Vapi from '@vapi-ai/web'; const VAPI_PUBLIC_KEY = import.meta.env.VITE_VAPI_PUBLIC_KEY; @@ -30,6 +30,9 @@ function App() { const [networkTestRunning, setNetworkTestRunning] = useState(false); const [networkTestResults, setNetworkTestResults] = useState(null); const [simulateFailure, setSimulateFailure] = useState('none'); + const [videoRecordingEnabled, setVideoRecordingEnabled] = useState(false); + const [localVideoTrack, setLocalVideoTrack] = useState(null); + const localVideoRef = useRef(null); useEffect(() => { // Check for stored webCall on component mount @@ -61,7 +64,17 @@ function App() { setConnected(false); setAssistantIsSpeaking(false); setVolumeLevel(0); - addMessage('system', 'Call ended - webCall data preserved for reconnection'); + setLocalVideoTrack(null); + addMessage('system', 'Call ended'); + }); + + // Tracks the local camera so the UI can show what's being recorded when + // video recording is enabled on the call. + vapi.on('daily-participant-updated', (participant) => { + if (!participant.local) return; + const video = participant.tracks?.video; + const track = video?.persistentTrack ?? video?.track ?? null; + setLocalVideoTrack(video?.state === 'playable' ? track : null); }); vapi.on('speech-start', () => { @@ -147,6 +160,15 @@ function App() { }; }, [vapi]); + // Attach the local camera track to the preview element whenever it changes. + useEffect(() => { + if (localVideoRef.current) { + localVideoRef.current.srcObject = localVideoTrack + ? new MediaStream([localVideoTrack]) + : null; + } + }, [localVideoTrack]); + const addMessage = (type: 'user' | 'assistant' | 'system', content: string) => { setMessages(prev => [...prev, { time: new Date().toLocaleTimeString(), @@ -203,6 +225,9 @@ function App() { firstMessage: "Hello! I'm your AI assistant. How can I help you today?", endCallMessage: "Thank you for the conversation. Goodbye!", endCallPhrases: ["goodbye", "bye", "end call", "hang up"], + artifactPlan: { + videoRecordingEnabled, + }, // Max call duration (in seconds) - 10 minutes maxDurationSeconds: 600 @@ -216,7 +241,8 @@ function App() { webCallUrl: (webCall as any).webCallUrl, id: webCall.id, artifactPlan: webCall.artifactPlan, - assistant: webCall.assistant + assistant: webCall.assistant, + transport: webCall.transport, }; localStorage.setItem('vapi-webcall', JSON.stringify(webCallToStore)); setStoredWebCall(webCallToStore); @@ -230,7 +256,19 @@ function App() { }; const stopCall = () => { + // Leaves the call without ending it server-side. With + // roomDeleteOnUserLeaveEnabled: false the call stays alive, so you can + // rejoin it with the Reconnect button. + vapi.stop(); + addMessage('system', 'Left the call - it stays alive for reconnection'); + }; + + const endCall = () => { + // Ends the Vapi call for everyone; reconnection is not possible after this. vapi.end(); + localStorage.removeItem('vapi-webcall'); + setStoredWebCall(null); + addMessage('system', 'Ended the call - stored call data cleared'); }; const reconnectCall = async () => { @@ -445,6 +483,56 @@ function App() { )} + {/* Local Camera Preview */} + {localVideoTrack && ( +
+
+ )} + + {/* Call Settings */} +
+

⚙️ Call Settings

+ +

+ When enabled, your camera turns on and the call is recorded to video. Applies to the next + call you start. +

+
+ {/* Failure Simulation Controls */}
)} - + + - +