diff --git a/README.md b/README.md
index c0170a0..87ce5be 100644
--- a/README.md
+++ b/README.md
@@ -71,42 +71,43 @@ The simplest way to add the widget to your website:
### Optional Props
-| Prop | Type | Default | Description |
-| ------------------------- | --------------------------------------------------------------------------------- | ------------------------ | --------------------------------------------------------------------------------------- |
-| `mode` | `'voice' \| 'chat' \| 'hybrid'` | `'chat'` | Widget interaction mode |
-| `theme` | `'light' \| 'dark'` | `'light'` | Color theme |
-| `position` | `'bottom-right' \| 'bottom-left' \| 'top-right' \| 'top-left' \| 'bottom-center'` | `'bottom-right'` | Screen position |
-| `size` | `'tiny' \| 'compact' \| 'full'` | `'full'` | Widget size |
-| `borderRadius` | `'none' \| 'small' \| 'medium' \| 'large'` | `'medium'` | Corner radius style |
-| `apiUrl` | `string` | - | Custom API endpoint for chat mode |
-| **Colors** | | | |
-| `baseBgColor` | `string` | - | Main background color |
-| `accentColor` | `string` | `'#14B8A6'` | Primary accent color |
-| `ctaButtonColor` | `string` | `'#000000'` | CTA button background color |
-| `ctaButtonTextColor` | `string` | `'#FFFFFF'` | CTA button text/icon color |
-| **Text Labels** | | | |
-| `title` | `string` | `'Talk with AI'` | Main widget title |
-| `startButtonText` | `string` | `'Start'` | Voice call start button text |
-| `endButtonText` | `string` | `'End Call'` | Voice call end button text |
-| `ctaTitle` | `string` | _(uses title)_ | Floating button main text |
-| `ctaSubtitle` | `string` | - | Floating button subtitle text |
-| **Empty States** | | | |
-| `voiceEmptyMessage` | `string` | - | Message when voice mode is empty |
-| `voiceActiveEmptyMessage` | `string` | - | Message during active voice call |
-| `chatEmptyMessage` | `string` | - | Message when chat is empty |
-| `hybridEmptyMessage` | `string` | - | Message for hybrid mode |
-| **Chat Configuration** | | | |
-| `chatFirstMessage` | `string` | - | Initial assistant message in chat |
-| `chatPlaceholder` | `string` | `'Type your message...'` | Chat input placeholder text |
-| **Voice Configuration** | | | |
-| `voiceShowTranscript` | `boolean` | `false` | Show/hide voice transcript |
-| `voiceAutoReconnect` | `boolean` | `false` | Auto-reconnect to an active web call within the same browser tab (uses session storage) |
-| `reconnectStorageKey` | `string` | `'vapi_widget_web_call'` | Key for storing reconnection data (uses session storage) |
-| **Consent Configuration** | | | |
-| `consentRequired` | `boolean` | `false` | Show consent form before first use |
-| `consentTitle` | `string` | `"Terms and conditions"` | Consent form title |
-| `consentContent` | `string` | _(default message)_ | Terms & conditions content |
-| `consentStorageKey` | `string` | `"vapi_widget_consent"` | Key for storing consent |
+| Prop | Type | Default | Description |
+| ------------------------- | --------------------------------------------------------------------------------- | ------------------------ | --------------------------------------------------------------------------------- |
+| `mode` | `'voice' \| 'chat' \| 'hybrid'` | `'chat'` | Widget interaction mode |
+| `theme` | `'light' \| 'dark'` | `'light'` | Color theme |
+| `position` | `'bottom-right' \| 'bottom-left' \| 'top-right' \| 'top-left' \| 'bottom-center'` | `'bottom-right'` | Screen position |
+| `size` | `'tiny' \| 'compact' \| 'full'` | `'full'` | Widget size |
+| `borderRadius` | `'none' \| 'small' \| 'medium' \| 'large'` | `'medium'` | Corner radius style |
+| `apiUrl` | `string` | - | Custom API endpoint for chat mode |
+| **Colors** | | | |
+| `baseBgColor` | `string` | - | Main background color |
+| `accentColor` | `string` | `'#14B8A6'` | Primary accent color |
+| `ctaButtonColor` | `string` | `'#000000'` | CTA button background color |
+| `ctaButtonTextColor` | `string` | `'#FFFFFF'` | CTA button text/icon color |
+| **Text Labels** | | | |
+| `title` | `string` | `'Talk with AI'` | Main widget title |
+| `startButtonText` | `string` | `'Start'` | Voice call start button text |
+| `endButtonText` | `string` | `'End Call'` | Voice call end button text |
+| `ctaTitle` | `string` | _(uses title)_ | Floating button main text |
+| `ctaSubtitle` | `string` | - | Floating button subtitle text |
+| **Empty States** | | | |
+| `voiceEmptyMessage` | `string` | - | Message when voice mode is empty |
+| `voiceActiveEmptyMessage` | `string` | - | Message during active voice call |
+| `chatEmptyMessage` | `string` | - | Message when chat is empty |
+| `hybridEmptyMessage` | `string` | - | Message for hybrid mode |
+| **Chat Configuration** | | | |
+| `chatFirstMessage` | `string` | - | Initial assistant message in chat |
+| `chatPlaceholder` | `string` | `'Type your message...'` | Chat input placeholder text |
+| **Voice Configuration** | | | |
+| `voiceShowTranscript` | `boolean` | `false` | Show/hide voice transcript |
+| `voiceAutoReconnect` | `boolean` | `false` | Auto-reconnect to an active web call (see `voiceReconnectStorage` for scope) |
+| `voiceReconnectStorage` | `'session' \| 'cookies'` | `'session'` | Storage type: 'session' (same tab only) or 'cookies' (same tab across subdomains) |
+| `reconnectStorageKey` | `string` | `'vapi_widget_web_call'` | Key for storing reconnection data |
+| **Consent Configuration** | | | |
+| `consentRequired` | `boolean` | `false` | Show consent form before first use |
+| `consentTitle` | `string` | `"Terms and conditions"` | Consent form title |
+| `consentContent` | `string` | _(default message)_ | Terms & conditions content |
+| `consentStorageKey` | `string` | `"vapi_widget_consent"` | Key for storing consent |
### Event Callbacks
@@ -279,13 +280,26 @@ Use this approach if your environment doesn't support custom elements or for bet
### Voice-Only with Auto-Reconnect
-````tsx
+```tsx
+```
+
+### Voice with Cross-Subdomain Reconnection
+
+```tsx
+
+```
## Development
@@ -301,7 +315,7 @@ npm install
# Build everything
npm run build:all
-````
+```
### Development Commands
diff --git a/package-lock.json b/package-lock.json
index 5318936..57e68b5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,11 +10,13 @@
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"@phosphor-icons/react": "^2.1.10",
+ "js-cookie": "^3.0.5",
"react-colorful": "^5.6.1",
"react-markdown": "^10.1.0"
},
"devDependencies": {
"@playwright/test": "^1.53.1",
+ "@types/js-cookie": "^3.0.6",
"@types/node": "^24.0.6",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
@@ -1916,6 +1918,13 @@
"@types/unist": "*"
}
},
+ "node_modules/@types/js-cookie": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
+ "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -4312,6 +4321,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/js-cookie": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
+ "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
diff --git a/package.json b/package.json
index 95423f1..eaf0cb1 100644
--- a/package.json
+++ b/package.json
@@ -74,6 +74,7 @@
},
"devDependencies": {
"@playwright/test": "^1.53.1",
+ "@types/js-cookie": "^3.0.6",
"@types/node": "^24.0.6",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
@@ -102,6 +103,7 @@
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"@phosphor-icons/react": "^2.1.10",
+ "js-cookie": "^3.0.5",
"react-colorful": "^5.6.1",
"react-markdown": "^10.1.0"
}
diff --git a/src/components/VapiWidget.tsx b/src/components/VapiWidget.tsx
index 06cc9e9..ae9d8ea 100644
--- a/src/components/VapiWidget.tsx
+++ b/src/components/VapiWidget.tsx
@@ -62,6 +62,7 @@ const VapiWidget: React.FC = ({
voiceShowTranscript,
showTranscript = false, // deprecated
voiceAutoReconnect = false,
+ voiceReconnectStorage = 'session',
reconnectStorageKey = 'vapi_widget_web_call',
// Consent configuration
consentRequired,
@@ -150,6 +151,7 @@ const VapiWidget: React.FC = ({
apiUrl,
firstChatMessage: effectiveChatFirstMessage,
voiceAutoReconnect,
+ voiceReconnectStorage,
reconnectStorageKey,
onCallStart: effectiveOnVoiceStart,
onCallEnd: effectiveOnVoiceEnd,
diff --git a/src/components/types.ts b/src/components/types.ts
index 6931d8a..abf964e 100644
--- a/src/components/types.ts
+++ b/src/components/types.ts
@@ -49,6 +49,7 @@ export interface VapiWidgetProps {
// Voice Configuration
voiceShowTranscript?: boolean;
voiceAutoReconnect?: boolean;
+ voiceReconnectStorage?: 'session' | 'cookies';
reconnectStorageKey?: string;
// Consent Configuration
diff --git a/src/hooks/useVapiCall.ts b/src/hooks/useVapiCall.ts
index 90f676f..73573eb 100644
--- a/src/hooks/useVapiCall.ts
+++ b/src/hooks/useVapiCall.ts
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import Vapi from '@vapi-ai/web';
import * as vapiCallStorage from '../utils/vapiCallStorage';
+import type { StorageType } from '../utils/vapiCallStorage';
export interface VapiCallState {
isCallActive: boolean;
@@ -25,6 +26,7 @@ export interface UseVapiCallOptions {
apiUrl?: string;
enabled?: boolean;
voiceAutoReconnect?: boolean;
+ voiceReconnectStorage?: StorageType;
reconnectStorageKey?: string;
onCallStart?: () => void;
onCallEnd?: () => void;
@@ -43,6 +45,7 @@ export const useVapiCall = ({
apiUrl,
enabled = true,
voiceAutoReconnect = false,
+ voiceReconnectStorage = 'session',
reconnectStorageKey = 'vapi_widget_web_call',
onCallStart,
onCallEnd,
@@ -98,7 +101,10 @@ export const useVapiCall = ({
setIsSpeaking(false);
setIsMuted(false);
// Clear stored call data on successful call end
- vapiCallStorage.clearStoredCall(reconnectStorageKey);
+ vapiCallStorage.clearStoredCall(
+ reconnectStorageKey,
+ voiceReconnectStorage
+ );
callbacksRef.current.onCallEnd?.();
};
@@ -153,7 +159,7 @@ export const useVapiCall = ({
vapi.removeListener('message', handleMessage);
vapi.removeListener('error', handleError);
};
- }, [vapi, reconnectStorageKey]);
+ }, [vapi, reconnectStorageKey, voiceReconnectStorage]);
useEffect(() => {
return () => {
@@ -194,14 +200,26 @@ export const useVapiCall = ({
// Store call data for reconnection if call was successful and auto-reconnect is enabled
if (call && voiceAutoReconnect) {
- vapiCallStorage.storeCallData(reconnectStorageKey, call, callOptions);
+ vapiCallStorage.storeCallData(
+ reconnectStorageKey,
+ call,
+ callOptions,
+ voiceReconnectStorage
+ );
}
} catch (error) {
console.error('Error starting call:', error);
setConnectionStatus('disconnected');
callbacksRef.current.onError?.(error as Error);
}
- }, [vapi, callOptions, enabled, voiceAutoReconnect, reconnectStorageKey]);
+ }, [
+ vapi,
+ callOptions,
+ enabled,
+ voiceAutoReconnect,
+ voiceReconnectStorage,
+ reconnectStorageKey,
+ ]);
const endCall = useCallback(
async ({ force = false }: { force?: boolean } = {}) => {
@@ -250,7 +268,10 @@ export const useVapiCall = ({
return;
}
- const storedData = vapiCallStorage.getStoredCallData(reconnectStorageKey);
+ const storedData = vapiCallStorage.getStoredCallData(
+ reconnectStorageKey,
+ voiceReconnectStorage
+ );
if (!storedData) {
console.warn('No stored call data found for reconnection');
@@ -264,7 +285,10 @@ export const useVapiCall = ({
console.warn(
'CallOptions have changed since last call, clearing stored data and skipping reconnection'
);
- vapiCallStorage.clearStoredCall(reconnectStorageKey);
+ vapiCallStorage.clearStoredCall(
+ reconnectStorageKey,
+ voiceReconnectStorage
+ );
return;
}
@@ -281,14 +305,17 @@ export const useVapiCall = ({
} catch (error) {
setConnectionStatus('disconnected');
console.error('Reconnection failed:', error);
- vapiCallStorage.clearStoredCall(reconnectStorageKey);
+ vapiCallStorage.clearStoredCall(
+ reconnectStorageKey,
+ voiceReconnectStorage
+ );
callbacksRef.current.onError?.(error as Error);
}
- }, [vapi, enabled, reconnectStorageKey, callOptions]);
+ }, [vapi, enabled, reconnectStorageKey, voiceReconnectStorage, callOptions]);
const clearStoredCall = useCallback(() => {
- vapiCallStorage.clearStoredCall(reconnectStorageKey);
- }, [reconnectStorageKey]);
+ vapiCallStorage.clearStoredCall(reconnectStorageKey, voiceReconnectStorage);
+ }, [reconnectStorageKey, voiceReconnectStorage]);
useEffect(() => {
if (!vapi || !enabled || !voiceAutoReconnect) {
diff --git a/src/hooks/useVapiWidget.ts b/src/hooks/useVapiWidget.ts
index 46bb4f2..bee5a4c 100644
--- a/src/hooks/useVapiWidget.ts
+++ b/src/hooks/useVapiWidget.ts
@@ -16,6 +16,7 @@ export interface UseVapiWidgetOptions {
apiUrl?: string;
firstChatMessage?: string;
voiceAutoReconnect?: boolean;
+ voiceReconnectStorage?: 'session' | 'cookies';
reconnectStorageKey?: string;
onCallStart?: () => void;
onCallEnd?: () => void;
@@ -32,6 +33,7 @@ export const useVapiWidget = ({
apiUrl,
firstChatMessage,
voiceAutoReconnect = false,
+ voiceReconnectStorage = 'session',
reconnectStorageKey,
onCallStart,
onCallEnd,
@@ -68,6 +70,7 @@ export const useVapiWidget = ({
apiUrl,
enabled: voiceEnabled,
voiceAutoReconnect,
+ voiceReconnectStorage,
reconnectStorageKey,
onCallStart: () => {
// In hybrid mode, clear all conversations when starting voice
diff --git a/src/utils/vapiCallStorage.ts b/src/utils/vapiCallStorage.ts
index aff90e4..36dc3b1 100644
--- a/src/utils/vapiCallStorage.ts
+++ b/src/utils/vapiCallStorage.ts
@@ -1,3 +1,5 @@
+import Cookies from 'js-cookie';
+
export interface StoredCallData {
webCallUrl: string;
id?: string;
@@ -11,50 +13,142 @@ export interface StoredCallData {
};
callOptions?: any;
timestamp: number;
+ tabId?: string;
+}
+
+export type StorageType = 'session' | 'cookies';
+
+// Generate or retrieve tab-specific ID (stored in sessionStorage)
+function getTabId(): string {
+ const TAB_ID_KEY = '_vapi_tab_id';
+ let tabId = sessionStorage.getItem(TAB_ID_KEY);
+
+ if (!tabId) {
+ tabId = `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+ sessionStorage.setItem(TAB_ID_KEY, tabId);
+ }
+
+ return tabId;
+}
+
+// Get root domain for cookie (e.g., ".domain.com")
+function getRootDomain(): string {
+ const hostname = window.location.hostname;
+
+ // Handle localhost/IP
+ if (
+ hostname === 'localhost' ||
+ hostname === '127.0.0.1' ||
+ /^\d+\.\d+\.\d+\.\d+$/.test(hostname)
+ ) {
+ return hostname;
+ }
+
+ const parts = hostname.split('.');
+
+ // Already root domain
+ if (parts.length <= 2) {
+ return hostname;
+ }
+
+ // Return root domain with leading dot (e.g., ".domain.com")
+ return '.' + parts.slice(-2).join('.');
}
export const storeCallData = (
reconnectStorageKey: string,
call: any,
- callOptions?: any
+ callOptions?: any,
+ storageType: StorageType = 'session'
) => {
const webCallUrl =
(call as any).webCallUrl || (call.transport as any)?.callUrl;
- if (webCallUrl) {
- const webCallToStore = {
- webCallUrl,
- id: call.id,
- artifactPlan: call.artifactPlan,
- assistant: call.assistant,
- callOptions,
- timestamp: Date.now(),
- };
- sessionStorage.setItem(reconnectStorageKey, JSON.stringify(webCallToStore));
- console.log('Stored call data for reconnection:', webCallToStore);
- } else {
+ if (!webCallUrl) {
console.warn(
'No webCallUrl found in call object, cannot store for reconnection'
);
+ return;
+ }
+
+ const webCallToStore: StoredCallData = {
+ webCallUrl,
+ id: call.id,
+ artifactPlan: call.artifactPlan,
+ assistant: call.assistant,
+ callOptions,
+ timestamp: Date.now(),
+ };
+
+ if (storageType === 'session') {
+ sessionStorage.setItem(reconnectStorageKey, JSON.stringify(webCallToStore));
+ } else if (storageType === 'cookies') {
+ const tabId = getTabId();
+ const webCallToStoreWithTab = { ...webCallToStore, tabId };
+
+ try {
+ const rootDomain = getRootDomain();
+
+ Cookies.set(reconnectStorageKey, JSON.stringify(webCallToStoreWithTab), {
+ domain: rootDomain,
+ path: '/',
+ secure: true,
+ sameSite: 'lax',
+ expires: 1 / 24, // 1 hour (expires takes days, so 1/24 = 1 hour)
+ });
+ } catch (error) {
+ console.error('Failed to store call data in cookie:', error);
+ }
}
};
export const getStoredCallData = (
- reconnectStorageKey: string
+ reconnectStorageKey: string,
+ storageType: StorageType = 'session'
): StoredCallData | null => {
try {
- const stored = sessionStorage.getItem(reconnectStorageKey);
- if (!stored) return null;
+ if (storageType === 'session') {
+ const sessionData = sessionStorage.getItem(reconnectStorageKey);
+ if (!sessionData) return null;
- return JSON.parse(stored);
- } catch {
- sessionStorage.removeItem(reconnectStorageKey);
+ return JSON.parse(sessionData);
+ } else if (storageType === 'cookies') {
+ const currentTabId = getTabId();
+ const cookieValue = Cookies.get(reconnectStorageKey);
+
+ if (!cookieValue) return null;
+
+ const data = JSON.parse(cookieValue);
+
+ // Verify tab ID matches (prevents multi-tab reconnection)
+ if (data.tabId !== currentTabId) {
+ console.warn('Tab ID mismatch - ignoring call data from different tab');
+ return null;
+ }
+
+ return data;
+ }
+
+ return null;
+ } catch (error) {
+ console.error('Error reading stored call data:', error);
return null;
}
};
-export const clearStoredCall = (reconnectStorageKey: string) => {
- sessionStorage.removeItem(reconnectStorageKey);
+export const clearStoredCall = (
+ reconnectStorageKey: string,
+ storageType: StorageType = 'session'
+) => {
+ if (storageType === 'session') {
+ sessionStorage.removeItem(reconnectStorageKey);
+ } else if (storageType === 'cookies') {
+ const rootDomain = getRootDomain();
+ Cookies.remove(reconnectStorageKey, {
+ domain: rootDomain,
+ path: '/',
+ });
+ }
};
export const areCallOptionsEqual = (options1: any, options2: any): boolean => {
diff --git a/src/widget/index.ts b/src/widget/index.ts
index f6a6a26..a165dbf 100644
--- a/src/widget/index.ts
+++ b/src/widget/index.ts
@@ -169,6 +169,7 @@ function initializeWidgets() {
// Voice Configuration
'voice-show-transcript': 'voiceShowTranscript',
'voice-auto-reconnect': 'voiceAutoReconnect',
+ 'voice-reconnect-storage': 'voiceReconnectStorage',
'reconnect-storage-key': 'reconnectStorageKey',
// Consent Configuration