Skip to main content
Three self-contained pages, each driving an invisible widget with completely custom UI. Replace YOUR_WIDGET_ID and your-domain.com with the values from your campaign’s Web Widget tab → Embed Code panel, serve over https:// (or http://localhost), and they run as-is.
These pages use no framework - plain HTML, CSS, and JavaScript - so the patterns translate directly to React, Vue, Svelte, or Webflow custom code.

Voice call

A single toggle button with full state cycling (Loading → Talk → Connecting → End), call duration from durationMs, mic-denied and error banners, and a consent listener.
voice-call.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Headless Mode - Voice Call Example</title>
    <!--
  Headless Mode (Bring Your Own UI) - VOICE CALL example

  The widget is embedded with `hide-launcher`, so it renders NOTHING.
  Your own button is the entire call experience, driven by:

    Methods:  widget.startCall()  widget.stopCall()
    Events:   widget.ready · voice.connecting · voice.started ·
              voice.ended · voice.mic-permission-denied · voice.error

  Serve this file over http://localhost or https:// (NOT file://) -
  browsers don't persist mic permission on file:// pages.
-->
    <style>
      body {
        font-family: system-ui, sans-serif;
        max-width: 480px;
        margin: 60px auto;
        padding: 0 20px;
      }
      #call-btn {
        padding: 14px 28px;
        font-size: 16px;
        font-weight: 600;
        border: none;
        border-radius: 12px;
        cursor: pointer;
        background: #6d28d9;
        color: #fff;
        transition: background 0.15s;
      }
      #call-btn:disabled {
        opacity: 0.5;
        cursor: not-allowed;
      }
      #call-btn.in-call {
        background: #dc2626;
      }
      #call-status {
        min-height: 24px;
        font-size: 14px;
        color: #555;
      }
      #call-status.error {
        color: #b91c1c;
      }
    </style>
  </head>
  <body>
    <h1>Talk to our AI agent</h1>
    <p>
      Press the button and start speaking - our assistant answers in real time.
    </p>

    <button id="call-btn" disabled>Loading…</button>
    <p id="call-status"></p>

    <!-- The invisible widget: hide-launcher = no UI is ever rendered -->
    <voiceai-widget
      id="YOUR_WIDGET_ID"
      host="your-domain.com"
      hide-launcher
    ></voiceai-widget>
    <script
      defer
      src="https://your-domain.com/widget/voiceai-widget.umd.js"
    ></script>

    <script>
      const widget = document.querySelector('voiceai-widget');
      const btn = document.getElementById('call-btn');
      const status = document.getElementById('call-status');
      let inCall = false;

      const setStatus = (text, isError = false) => {
        status.textContent = text;
        status.className = isError ? 'error' : '';
      };

      // One button toggles the call. Calls made before the widget is ready
      // are buffered automatically and fire right after `widget.ready`.
      btn.onclick = () => (inCall ? widget.stopCall() : widget.startCall());

      // ---- Widget lifecycle -------------------------------------------------

      widget.addEventListener('widget.ready', (e) => {
        // e.detail = { provider, modes } - modes tells you what's enabled
        btn.disabled = false;
        btn.textContent = '📞 Talk to our AI';
      });

      widget.addEventListener('widget.error', (e) => {
        setStatus('Widget failed to load: ' + e.detail.message, true);
      });

      // ---- Call lifecycle ---------------------------------------------------

      widget.addEventListener('voice.connecting', () => {
        btn.textContent = 'Connecting…';
        setStatus('');
      });

      widget.addEventListener('voice.started', () => {
        inCall = true;
        btn.textContent = '🔴 End call';
        btn.classList.add('in-call');
        setStatus('You are live - start speaking!');
      });

      // voice.ended is GUARANTEED to fire once per call attempt,
      // even when the connection fails (durationMs is 0 in that case).
      widget.addEventListener('voice.ended', (e) => {
        inCall = false;
        btn.textContent = '📞 Talk to our AI';
        btn.classList.remove('in-call');
        const seconds = Math.round(e.detail.durationMs / 1000);
        if (seconds > 0)
          setStatus(
            `Call ended after ${seconds}s. Thanks for talking with us!`,
          );
      });

      // ---- Errors (your UI is the only UI - surface them!) ------------------

      widget.addEventListener('voice.mic-permission-denied', () => {
        setStatus(
          'Microphone is blocked. Click the 🔒 icon in your address bar, allow the mic, and try again.',
          true,
        );
      });

      widget.addEventListener('voice.error', (e) => {
        setStatus('Something went wrong: ' + e.detail.message, true);
      });

      // ---- Consent (only if "Require consent" is enabled on the widget) -----
      // In Headless Mode the built-in consent popup can't show, so render your
      // own consent UI and call acceptConsent() to resume the pending call.
      widget.addEventListener('widget.consent-required', () => {
        // Replace this confirm() with your own modal.
        if (
          window.confirm(
            'This call is handled by an AI assistant and may be recorded. Continue?',
          )
        ) {
          widget.acceptConsent();
        }
      });
    </script>
  </body>
</html>

Chat

A complete custom chat panel: bubbles rendered from the single chat.message stream, balanced typing dots, an online/offline chip, an End-chat button, and human-readable handling of all four session-ended reasons.
chat.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Headless Mode - Chat Example</title>
    <!--
  Headless Mode (Bring Your Own UI) - CHAT example

  The widget is embedded with `hide-launcher`, so it renders NOTHING.
  The entire chat interface below is your own HTML/CSS, driven by:

    Methods:  widget.sendChatMessage(text)  widget.endChatSession()
    Events:   widget.ready · chat.session-started · chat.message ·
              chat.agent-typing-started · chat.agent-typing-stopped ·
              chat.session-ended · chat.error

  Notes:
  - The first sendChatMessage() auto-starts a session - no separate
    "connect" step is needed.
  - chat.message fires for YOUR messages too (role: 'user'), so you
    can render the whole conversation from a single event stream.
  - Sessions auto-end after inactivity or when the tab is hidden -
    chat.session-ended tells you why via detail.reason.
-->
    <style>
      body {
        font-family: system-ui, sans-serif;
        max-width: 480px;
        margin: 40px auto;
        padding: 0 20px;
      }
      #chat {
        border: 1px solid #e5e7eb;
        border-radius: 16px;
        overflow: hidden;
        box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
      }
      #chat-header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding: 12px 16px;
        background: #111827;
        color: #fff;
        font-size: 14px;
      }
      #chat-state {
        font-size: 11px;
        padding: 3px 10px;
        border-radius: 999px;
        background: rgba(255, 255, 255, 0.15);
      }
      #chat-state.online {
        background: #065f46;
      }
      #end-btn {
        border: none;
        background: none;
        color: #f87171;
        font-size: 12px;
        cursor: pointer;
      }
      #messages {
        height: 320px;
        overflow-y: auto;
        padding: 14px;
        display: flex;
        flex-direction: column;
        gap: 8px;
        background: #f9fafb;
      }
      .msg {
        max-width: 80%;
        padding: 9px 13px;
        border-radius: 14px;
        font-size: 14px;
        line-height: 1.4;
      }
      .msg.user {
        align-self: flex-end;
        background: #6d28d9;
        color: #fff;
        border-bottom-right-radius: 4px;
      }
      .msg.assistant {
        align-self: flex-start;
        background: #fff;
        border: 1px solid #e5e7eb;
        border-bottom-left-radius: 4px;
      }
      .msg.system {
        align-self: center;
        font-size: 12px;
        color: #92400e;
        background: #fef3c7;
        border-radius: 999px;
        padding: 4px 12px;
      }
      #typing {
        display: none;
        align-self: flex-start;
        padding: 12px 16px;
        background: #fff;
        border: 1px solid #e5e7eb;
        border-radius: 14px;
      }
      #typing.show {
        display: flex;
        gap: 4px;
      }
      #typing span {
        width: 6px;
        height: 6px;
        border-radius: 50%;
        background: #9ca3af;
        animation: blink 1.2s infinite;
      }
      #typing span:nth-child(2) {
        animation-delay: 0.2s;
      }
      #typing span:nth-child(3) {
        animation-delay: 0.4s;
      }
      @keyframes blink {
        50% {
          opacity: 0.25;
        }
      }
      #composer {
        display: flex;
        gap: 8px;
        padding: 12px;
        border-top: 1px solid #e5e7eb;
        background: #fff;
      }
      #composer input {
        flex: 1;
        padding: 10px 14px;
        border: 1px solid #d1d5db;
        border-radius: 10px;
        font-size: 14px;
        outline: none;
      }
      #composer input:focus {
        border-color: #6d28d9;
      }
      #composer button {
        padding: 10px 18px;
        border: none;
        border-radius: 10px;
        background: #6d28d9;
        color: #fff;
        font-weight: 600;
        cursor: pointer;
      }
      #composer button:disabled {
        opacity: 0.5;
        cursor: not-allowed;
      }
    </style>
  </head>
  <body>
    <h1>Chat with us</h1>

    <div id="chat">
      <div id="chat-header">
        <span>💬 Support Assistant</span>
        <span id="chat-state">offline</span>
        <button id="end-btn" title="End this conversation">End chat</button>
      </div>
      <div id="messages">
        <div class="msg system">Send a message to start chatting</div>
        <div id="typing"><span></span><span></span><span></span></div>
      </div>
      <div id="composer">
        <input type="text" id="input" placeholder="Loading…" disabled />
        <button id="send-btn" disabled>Send</button>
      </div>
    </div>

    <!-- The invisible widget: hide-launcher = no UI is ever rendered -->
    <voiceai-widget
      id="YOUR_WIDGET_ID"
      host="your-domain.com"
      hide-launcher
    ></voiceai-widget>
    <script
      defer
      src="https://your-domain.com/widget/voiceai-widget.umd.js"
    ></script>

    <script>
      const widget = document.querySelector('voiceai-widget');
      const messages = document.getElementById('messages');
      const typing = document.getElementById('typing');
      const input = document.getElementById('input');
      const sendBtn = document.getElementById('send-btn');
      const chatState = document.getElementById('chat-state');

      const addBubble = (cls, text) => {
        const bubble = document.createElement('div');
        bubble.className = 'msg ' + cls;
        bubble.textContent = text;
        messages.insertBefore(bubble, typing); // keep typing dots at the bottom
        messages.scrollTop = messages.scrollHeight;
      };

      const send = () => {
        const text = input.value.trim();
        if (!text) return;
        widget.sendChatMessage(text); // auto-starts the session on first message
        input.value = '';
      };

      sendBtn.onclick = send;
      input.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') send();
      });
      document.getElementById('end-btn').onclick = () =>
        widget.endChatSession();

      // ---- Widget lifecycle -------------------------------------------------

      widget.addEventListener('widget.ready', () => {
        input.disabled = false;
        sendBtn.disabled = false;
        input.placeholder = 'Type a message…';
      });

      widget.addEventListener('widget.error', (e) => {
        addBubble('system', 'Chat failed to load: ' + e.detail.message);
      });

      // ---- Chat lifecycle ---------------------------------------------------

      widget.addEventListener('chat.session-started', () => {
        chatState.textContent = 'online';
        chatState.classList.add('online');
      });

      // One event stream renders the whole conversation - your messages
      // arrive with role 'user', the agent's with role 'assistant'.
      widget.addEventListener('chat.message', (e) => {
        addBubble(e.detail.role, e.detail.content);
      });

      // The typing pair is always balanced - dots can never get stuck.
      widget.addEventListener('chat.agent-typing-started', () => {
        typing.classList.add('show');
        messages.scrollTop = messages.scrollHeight;
      });
      widget.addEventListener('chat.agent-typing-stopped', () => {
        typing.classList.remove('show');
      });

      // detail.reason: 'user' | 'inactivity' | 'hidden-tab' | 'error'
      widget.addEventListener('chat.session-ended', (e) => {
        chatState.textContent = 'offline';
        chatState.classList.remove('online');
        const why =
          {
            user: 'Conversation ended.',
            inactivity: 'Session expired due to inactivity.',
            'hidden-tab': 'Session ended when you switched tabs.',
            error: 'Session ended unexpectedly.',
          }[e.detail.reason] || 'Session ended.';
        addBubble('system', why + ' Send a message to start a new one.');
      });

      widget.addEventListener('chat.error', (e) => {
        addBubble('system', 'Message failed: ' + e.detail.message);
      });

      // ---- Consent (only if "Require consent" is enabled on the widget) -----
      widget.addEventListener('widget.consent-required', () => {
        // Replace this confirm() with your own modal.
        if (
          window.confirm('This chat is handled by an AI assistant. Continue?')
        ) {
          widget.acceptConsent(); // the buffered message sends automatically
        }
      });
    </script>
  </body>
</html>

Voice + chat together

One invisible widget powering both surfaces side by side. widget.readye.detail.modes gates each surface, and a single consent modal serves both flows.
voice-and-chat.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Headless Mode - Voice + Chat Example</title>
    <!--
  Headless Mode (Bring Your Own UI) - VOICE + CHAT example

  One invisible widget powers BOTH surfaces at the same time:
  a voice-call button and a fully custom chat panel, side by side.

    Voice:  startCall() / stopCall()  +  voice.* events
    Chat:   sendChatMessage() / endChatSession()  +  chat.* events
    Shared: widget.ready · widget.consent-required · widget.error

  Voice and chat are independent - a visitor can be on a call and
  chat at the same time. Events and payloads are identical across
  Vapi, Retell and ElevenLabs, so this page works unchanged for any
  provider your campaign uses.

  The widget must have BOTH modes enabled (widget.ready → e.detail.modes).
  Serve over http://localhost or https:// (not file://).
-->
    <style>
      body {
        font-family: system-ui, sans-serif;
        max-width: 880px;
        margin: 40px auto;
        padding: 0 20px;
      }
      #surfaces {
        display: grid;
        grid-template-columns: 1fr 1.2fr;
        gap: 24px;
        align-items: start;
      }
      @media (max-width: 720px) {
        #surfaces {
          grid-template-columns: 1fr;
        }
      }

      /* --- voice card --- */
      #voice-card {
        border: 1px solid #e5e7eb;
        border-radius: 16px;
        padding: 24px;
        text-align: center;
      }
      #call-btn {
        padding: 14px 28px;
        font-size: 16px;
        font-weight: 600;
        border: none;
        border-radius: 12px;
        cursor: pointer;
        background: #6d28d9;
        color: #fff;
      }
      #call-btn:disabled {
        opacity: 0.5;
        cursor: not-allowed;
      }
      #call-btn.in-call {
        background: #dc2626;
      }
      #call-status {
        min-height: 22px;
        font-size: 13px;
        color: #555;
        margin-top: 12px;
      }
      #call-status.error {
        color: #b91c1c;
      }

      /* --- chat card --- */
      #chat {
        border: 1px solid #e5e7eb;
        border-radius: 16px;
        overflow: hidden;
      }
      #chat-header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding: 10px 14px;
        background: #111827;
        color: #fff;
        font-size: 13px;
      }
      #chat-state {
        font-size: 11px;
        padding: 2px 9px;
        border-radius: 999px;
        background: rgba(255, 255, 255, 0.15);
      }
      #chat-state.online {
        background: #065f46;
      }
      #end-btn {
        border: none;
        background: none;
        color: #f87171;
        font-size: 12px;
        cursor: pointer;
      }
      #messages {
        height: 260px;
        overflow-y: auto;
        padding: 12px;
        display: flex;
        flex-direction: column;
        gap: 7px;
        background: #f9fafb;
      }
      .msg {
        max-width: 80%;
        padding: 8px 12px;
        border-radius: 12px;
        font-size: 13px;
      }
      .msg.user {
        align-self: flex-end;
        background: #6d28d9;
        color: #fff;
      }
      .msg.assistant {
        align-self: flex-start;
        background: #fff;
        border: 1px solid #e5e7eb;
      }
      .msg.system {
        align-self: center;
        font-size: 11px;
        color: #92400e;
        background: #fef3c7;
        border-radius: 999px;
        padding: 3px 10px;
      }
      #typing {
        display: none;
        align-self: flex-start;
        padding: 10px 14px;
        background: #fff;
        border: 1px solid #e5e7eb;
        border-radius: 12px;
      }
      #typing.show {
        display: flex;
        gap: 4px;
      }
      #typing span {
        width: 5px;
        height: 5px;
        border-radius: 50%;
        background: #9ca3af;
        animation: blink 1.2s infinite;
      }
      #typing span:nth-child(2) {
        animation-delay: 0.2s;
      }
      #typing span:nth-child(3) {
        animation-delay: 0.4s;
      }
      @keyframes blink {
        50% {
          opacity: 0.25;
        }
      }
      #composer {
        display: flex;
        gap: 8px;
        padding: 10px;
        border-top: 1px solid #e5e7eb;
      }
      #composer input {
        flex: 1;
        padding: 9px 12px;
        border: 1px solid #d1d5db;
        border-radius: 9px;
        font-size: 13px;
        outline: none;
      }
      #composer button {
        padding: 9px 16px;
        border: none;
        border-radius: 9px;
        background: #6d28d9;
        color: #fff;
        font-weight: 600;
        cursor: pointer;
      }
      #composer input:disabled,
      #composer button:disabled {
        opacity: 0.5;
      }

      /* --- consent modal (your own UI - the hidden widget can't show one) --- */
      #consent {
        display: none;
        position: fixed;
        inset: 0;
        background: rgba(0, 0, 0, 0.45);
        align-items: center;
        justify-content: center;
      }
      #consent.show {
        display: flex;
      }
      #consent > div {
        background: #fff;
        border-radius: 14px;
        padding: 24px;
        max-width: 360px;
      }
      #consent button {
        margin-top: 12px;
        margin-right: 8px;
        padding: 9px 16px;
        border-radius: 9px;
        border: 1px solid #d1d5db;
        cursor: pointer;
        background: #fff;
      }
      #consent button.primary {
        background: #6d28d9;
        border-color: #6d28d9;
        color: #fff;
      }
    </style>
  </head>
  <body>
    <h1>Talk or chat - your choice</h1>

    <div id="surfaces">
      <div id="voice-card">
        <h3>🎙 Voice</h3>
        <button id="call-btn" disabled>Loading…</button>
        <p id="call-status"></p>
      </div>

      <div id="chat">
        <div id="chat-header">
          <span>💬 Chat</span>
          <span id="chat-state">offline</span>
          <button id="end-btn">End chat</button>
        </div>
        <div id="messages">
          <div class="msg system">Send a message to start chatting</div>
          <div id="typing"><span></span><span></span><span></span></div>
        </div>
        <div id="composer">
          <input type="text" id="input" placeholder="Loading…" disabled />
          <button id="send-btn" disabled>Send</button>
        </div>
      </div>
    </div>

    <!-- Your own consent UI, shown when the widget requires consent -->
    <div id="consent">
      <div>
        <h3>Before we start…</h3>
        <p style="font-size: 14px">
          Conversations are handled by an AI assistant and may be recorded for
          quality purposes.
        </p>
        <button class="primary" id="consent-accept">I agree</button>
        <button id="consent-decline">Not now</button>
      </div>
    </div>

    <!-- ONE invisible widget powers both surfaces -->
    <voiceai-widget
      id="YOUR_WIDGET_ID"
      host="your-domain.com"
      hide-launcher
    ></voiceai-widget>
    <script
      defer
      src="https://your-domain.com/widget/voiceai-widget.umd.js"
    ></script>

    <script>
      const widget = document.querySelector('voiceai-widget');

      // ================= Shared lifecycle =================

      widget.addEventListener('widget.ready', (e) => {
        // e.detail.modes tells you what this widget supports: ['voice', 'chat']
        if (e.detail.modes.includes('voice')) {
          callBtn.disabled = false;
          callBtn.textContent = '📞 Start a call';
        }
        if (e.detail.modes.includes('chat')) {
          input.disabled = false;
          sendBtn.disabled = false;
          input.placeholder = 'Type a message…';
        }
      });

      widget.addEventListener('widget.error', (e) => {
        setCallStatus('Error: ' + e.detail.message, true);
      });

      // One consent modal serves both surfaces - whichever action triggered
      // it (call or chat message) resumes automatically after acceptConsent().
      const consent = document.getElementById('consent');
      widget.addEventListener('widget.consent-required', () =>
        consent.classList.add('show'),
      );
      document.getElementById('consent-accept').onclick = () => {
        consent.classList.remove('show');
        widget.acceptConsent();
      };
      document.getElementById('consent-decline').onclick = () =>
        consent.classList.remove('show');

      // ================= Voice surface =================

      const callBtn = document.getElementById('call-btn');
      const callStatus = document.getElementById('call-status');
      let inCall = false;

      const setCallStatus = (text, isError = false) => {
        callStatus.textContent = text;
        callStatus.className = isError ? 'error' : '';
      };

      callBtn.onclick = () => (inCall ? widget.stopCall() : widget.startCall());

      widget.addEventListener('voice.connecting', () => {
        callBtn.textContent = 'Connecting…';
        setCallStatus('');
      });
      widget.addEventListener('voice.started', () => {
        inCall = true;
        callBtn.textContent = '🔴 End call';
        callBtn.classList.add('in-call');
        setCallStatus('Live - start speaking!');
      });
      widget.addEventListener('voice.ended', (e) => {
        inCall = false;
        callBtn.textContent = '📞 Start a call';
        callBtn.classList.remove('in-call');
        const seconds = Math.round(e.detail.durationMs / 1000);
        if (seconds > 0) setCallStatus(`Call ended after ${seconds}s.`);
      });
      widget.addEventListener('voice.mic-permission-denied', () => {
        setCallStatus('Microphone blocked - allow mic access and retry.', true);
      });
      widget.addEventListener('voice.error', (e) =>
        setCallStatus('Call failed: ' + e.detail.message, true),
      );

      // ================= Chat surface =================

      const messages = document.getElementById('messages');
      const typing = document.getElementById('typing');
      const input = document.getElementById('input');
      const sendBtn = document.getElementById('send-btn');
      const chatState = document.getElementById('chat-state');

      const addBubble = (cls, text) => {
        const bubble = document.createElement('div');
        bubble.className = 'msg ' + cls;
        bubble.textContent = text;
        messages.insertBefore(bubble, typing);
        messages.scrollTop = messages.scrollHeight;
      };

      const send = () => {
        const text = input.value.trim();
        if (!text) return;
        widget.sendChatMessage(text); // auto-starts the session
        input.value = '';
      };
      sendBtn.onclick = send;
      input.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') send();
      });
      document.getElementById('end-btn').onclick = () =>
        widget.endChatSession();

      widget.addEventListener('chat.session-started', () => {
        chatState.textContent = 'online';
        chatState.classList.add('online');
      });
      widget.addEventListener('chat.message', (e) =>
        addBubble(e.detail.role, e.detail.content),
      );
      widget.addEventListener('chat.agent-typing-started', () =>
        typing.classList.add('show'),
      );
      widget.addEventListener('chat.agent-typing-stopped', () =>
        typing.classList.remove('show'),
      );
      widget.addEventListener('chat.session-ended', (e) => {
        chatState.textContent = 'offline';
        chatState.classList.remove('online');
        addBubble(
          'system',
          `Session ended (${e.detail.reason}). Send a message to start a new one.`,
        );
      });
      widget.addEventListener('chat.error', (e) =>
        addBubble('system', 'Message failed: ' + e.detail.message),
      );
    </script>
  </body>
</html>