VoiceTel

Support / VoiceML examples / Build a PBX with VoiceML

Build a full-featured PBX with VoiceML.

Widgets.com is a fictional company with four extensions, a main menu, a ring-all line for the labor force, an AI agent on 0, and voicemail with transcripts. The whole phone system runs on two documents published on voiceml.org and the VoiceML Dashboard, with no application server.

Free trial VoiceML Dashboard

What callers and operators get

  • IVR with direct dial

    Press 1 for sales, 2 for the labor force, or 0 for the AI agent, or dial extensions 100–103 at any time during the greeting.

  • Ring-all

    Pressing 2 rings all four extensions at once. The first phone answered takes the call.

  • AI agent

    Pressing 0 bridges the caller to your voice agent over <ConversationRelay>. The platform runs speech recognition and synthesis; your agent receives text and replies with text.

  • Call recording

    Every call answered at an extension is recorded with the caller and the extension on separate channels, so a transcript reads as a conversation.

  • Voicemail with transcripts

    Unanswered calls go to a personal voicemail. The audio and its transcript are emailed to the extension’s owner as a secure link.

  • Real-time dashboard

    A live board of calls in progress with running timers, and which extensions are registered, ringing, or on a call.

  • Drag-and-drop transfer

    Drag a live call from the board onto an extension to transfer the caller there.

  • Live call control

    From any live call: hang up, transfer, send to voicemail, or start and stop recording.

  • Real-time transcription

    Start a live transcript on any call and watch it stream into the call view. Post-call summaries are optional and use your own Anthropic or OpenAI key.

  • Call analytics

    Today’s calls, answered calls and answer rate, missed calls, and the average answered call, plus an analytics page with date filters and the busiest numbers.

How the pieces fit

Components of the widgets.com PBX
Piece Where it lives What it does
Main documentvoiceml.org, /r/ URLThe number’s voice URL. Plays the menu, branches on the keys pressed, rings extensions, and takes voicemail.
Goodbye documentvoiceml.org, /s/ URLThe action of every <Record>. Tells a joke and hangs up.
Extensions 100–103Your VoiceML SIP domain (here widgets.vml.voice.tel)Desk phones or VoiceML Phone registered as 100, 101, 102, and 103.
VoiceML Dashboarddash.voiceml.comReceives the status, recording, voicemail, and transcript callbacks. Runs the live board, call control, and analytics.
AI agentYour own wss:// endpointAnswers callers who press 0.

Before you start

  1. A VoiceML account with a phone number. See VoiceML or start a free trial.
  2. Four extensions registered on your SIP domain, numbered 100 to 103.
  3. The VoiceML Dashboard connected to the account. An administrator signs in at the VoiceML Dashboard, verifies the account with its Account SID and API key, and invites each address that receives messages from the Users page. The dashboard refuses callbacks for an address that isn’t a member of the account.
  4. For the AI agent, your agent on a wss:// WebSocket. The relay needs a speech recognizer and a synthesis engine on the account, or the platform defaults. See ConversationRelay.

widgets.com is used here as a fictional company. It is a real, registered domain with no connection to VoiceTel. Replace every email address and the SIP domain with your own before you publish.

Step 1: Publish the goodbye document

Every <Record> needs an action. Without one, the runtime requests the current document again when the recording ends, which replays the voicemail greeting and records again. This small document is that action: it tells a knock-knock joke and hangs up.

goodbye.xml

<Response>
  <Say>Knock knock.</Say>
  <Pause length="1"/>
  <Say>Who's there?</Say>
  <Pause length="1"/>
  <Say>The interrupting cow.</Say>
  <Pause length="1"/>
  <Say>The interrupting cow who?</Say>
  <Pause length="1"/>
  <Say>Moooooo!</Say>
  <Pause length="1"/>
  <Say>We will get back to you as soon as possible. Thank you. Goodbye.</Say>
  <Hangup/>
</Response>

Paste it at voiceml.org, validate it, and publish. It contains no Liquid, so it is served from an /s/ URL. Keep the URL exactly as returned, key included. The key is a shared secret, so don’t post the URL publicly.

Step 2: Publish the main document

In the document below, replace GOODBYE_URL (five places) with the URL from step 1, and wss://YOUR_AI_WEBSOCKET_URL with your agent’s endpoint. Replace the email addresses, names, and SIP addresses with your own.

main.xml

<Response>
  {% if Digits == blank %}
    <!-- Initial IVR Greeting -->
    <Gather input="dtmf" timeout="3" method="POST">
      <Pause length="2"/>
      <Say>Thank you for calling widgets.com. Let us begin with the basic fundamentals of business. You have a company that manufactures widgets. You have raw materials, you have a labor force, you have plant and equipment. A widget is a fictitious product.</Say>
      <Pause length="2"/>
      <Say>For sales inquiries, press 1.</Say>
      <Pause length="2"/>
      <Say>To speak with the labor force, press 2.</Say>
      <Pause length="2"/>
      <Say>To speak with an AI agent, press 0.</Say>
      <Pause length="2"/>
      <Say>If you know your party’s extension, you may dial it at any time.</Say>
    </Gather>
    <Say>We did not receive a selection. Goodbye.</Say>
    <Hangup/>
  {% else %}
    {% assign email = "" %}
    {% assign name = "" %}

    <!-- Press 0 (AI Agent) -->
    {% if Digits == "0" %}
      <Say>Connecting you to our AI agent now.</Say>
      <Connect>
        <ConversationRelay url="wss://YOUR_AI_WEBSOCKET_URL" welcomeGreeting="Hello! I am the AI agent for widgets.com. How can I help you today?"/>
      </Connect>
      <Hangup/>

    <!-- Extension 100 / Press 1 -->
    {% elsif Digits == "1" or Digits == "100" %}
      {% assign email = "michael@widgets.com" %}
      {% assign name = "Michael" %}
      <Say>Connecting you now.</Say>
      <Pause length="1"/>
      <Say>This call is being recorded.</Say>
      <Pause length="1"/>
      <Dial timeout="20" ringTone="us" record="record-from-answer-dual"
            recordingStatusCallback="https://dash.voiceml.com/recording/{{ email | url_encode }}?From={{ From | url_encode }}&amp;To={{ To | url_encode }}&amp;CallerName={{ CallerName | url_encode }}&amp;Digits={{ Digits | url_encode }}"
            recordingStatusCallbackEvent="completed">
        <Sip statusCallback="https://dash.voiceml.com/status/{{ email | url_encode }}?From={{ From | url_encode }}&amp;To={{ To | url_encode }}&amp;CallerName={{ CallerName | url_encode }}&amp;Digits={{ Digits | url_encode }}"
             statusCallbackMethod="POST" statusCallbackEvent="ringing answered completed">sip:100@widgets.vml.voice.tel</Sip>
      </Dial>
      <Pause length="1"/>
      <Say>{{ name }} is unavailable. Please leave a message after the tone. When you are finished, you may hang up.</Say>
      <Record action="GOODBYE_URL" maxLength="120" timeout="5" playBeep="true" trim="trim-silence"
              transcribe="true"
              transcribeCallback="https://dash.voiceml.com/transcript/{{ email | url_encode }}?From={{ From | url_encode }}&amp;To={{ To | url_encode }}&amp;CallerName={{ CallerName | url_encode }}&amp;Digits={{ Digits | url_encode }}"
              recordingStatusCallback="https://dash.voiceml.com/voicemail/{{ email | url_encode }}?From={{ From | url_encode }}&amp;To={{ To | url_encode }}&amp;CallerName={{ CallerName | url_encode }}&amp;Digits={{ Digits | url_encode }}"
              recordingStatusCallbackEvent="completed"/>
      <Say>Thank you. Goodbye.</Say>
      <Hangup/>

    <!-- Press 2 (Rings the plant) -->
    {% elsif Digits == "2" %}
      {% assign email = "michael@widgets.com" %}
      {% assign name = "A representative" %}
      <Say>Connecting you now.</Say>
      <Pause length="1"/>
      <Say>This call is being recorded.</Say>
      <Pause length="1"/>
      <Dial timeout="20" ringTone="us" record="record-from-answer-dual"
            recordingStatusCallback="https://dash.voiceml.com/recording/{{ email | url_encode }}?From={{ From | url_encode }}&amp;To={{ To | url_encode }}&amp;CallerName={{ CallerName | url_encode }}&amp;Digits={{ Digits | url_encode }}"
            recordingStatusCallbackEvent="completed">
        <Sip>sip:100@widgets.vml.voice.tel</Sip>
        <Sip>sip:101@widgets.vml.voice.tel</Sip>
        <Sip>sip:102@widgets.vml.voice.tel</Sip>
        <Sip>sip:103@widgets.vml.voice.tel</Sip>
      </Dial>
      <Pause length="1"/>
      <Say>{{ name }} is unavailable. Please leave a message after the tone. When you are finished, you may hang up.</Say>
      <Record action="GOODBYE_URL" maxLength="120" timeout="5" playBeep="true" trim="trim-silence"
              transcribe="true"
              transcribeCallback="https://dash.voiceml.com/transcript/{{ email | url_encode }}?From={{ From | url_encode }}&amp;To={{ To | url_encode }}&amp;CallerName={{ CallerName | url_encode }}&amp;Digits={{ Digits | url_encode }}"
              recordingStatusCallback="https://dash.voiceml.com/voicemail/{{ email | url_encode }}?From={{ From | url_encode }}&amp;To={{ To | url_encode }}&amp;CallerName={{ CallerName | url_encode }}&amp;Digits={{ Digits | url_encode }}"
              recordingStatusCallbackEvent="completed"/>
      <Say>Thank you. Goodbye.</Say>
      <Hangup/>

    <!-- Extension 101 -->
    {% elsif Digits == "101" %}
      {% assign email = "ali@widgets.com" %}
      {% assign name = "Ali" %}
      <Say>Connecting you now.</Say>
      <Pause length="1"/>
      <Say>This call is being recorded.</Say>
      <Pause length="1"/>
      <Dial timeout="20" ringTone="us" record="record-from-answer-dual"
            recordingStatusCallback="https://dash.voiceml.com/recording/{{ email | url_encode }}?From={{ From | url_encode }}&amp;To={{ To | url_encode }}&amp;CallerName={{ CallerName | url_encode }}&amp;Digits={{ Digits | url_encode }}"
            recordingStatusCallbackEvent="completed">
        <Sip statusCallback="https://dash.voiceml.com/status/{{ email | url_encode }}?From={{ From | url_encode }}&amp;To={{ To | url_encode }}&amp;CallerName={{ CallerName | url_encode }}&amp;Digits={{ Digits | url_encode }}"
             statusCallbackMethod="POST" statusCallbackEvent="ringing answered completed">sip:101@widgets.vml.voice.tel</Sip>
      </Dial>
      <Pause length="1"/>
      <Say>{{ name }} is unavailable. Please leave a message after the tone. When you are finished, you may hang up.</Say>
      <Record action="GOODBYE_URL" maxLength="120" timeout="5" playBeep="true" trim="trim-silence"
              transcribe="true"
              transcribeCallback="https://dash.voiceml.com/transcript/{{ email | url_encode }}?From={{ From | url_encode }}&amp;To={{ To | url_encode }}&amp;CallerName={{ CallerName | url_encode }}&amp;Digits={{ Digits | url_encode }}"
              recordingStatusCallback="https://dash.voiceml.com/voicemail/{{ email | url_encode }}?From={{ From | url_encode }}&amp;To={{ To | url_encode }}&amp;CallerName={{ CallerName | url_encode }}&amp;Digits={{ Digits | url_encode }}"
              recordingStatusCallbackEvent="completed"/>
      <Say>Thank you. Goodbye.</Say>
      <Hangup/>

    <!-- Extension 102 / Press 3 -->
    {% elsif Digits == "3" or Digits == "102" %}
      {% assign email = "lily@widgets.com" %}
      {% assign name = "Lily" %}
      <Say>Connecting you now.</Say>
      <Pause length="1"/>
      <Say>This call is being recorded.</Say>
      <Pause length="1"/>
      <Dial timeout="20" ringTone="us" record="record-from-answer-dual"
            recordingStatusCallback="https://dash.voiceml.com/recording/{{ email | url_encode }}?From={{ From | url_encode }}&amp;To={{ To | url_encode }}&amp;CallerName={{ CallerName | url_encode }}&amp;Digits={{ Digits | url_encode }}"
            recordingStatusCallbackEvent="completed">
        <Sip statusCallback="https://dash.voiceml.com/status/{{ email | url_encode }}?From={{ From | url_encode }}&amp;To={{ To | url_encode }}&amp;CallerName={{ CallerName | url_encode }}&amp;Digits={{ Digits | url_encode }}"
             statusCallbackMethod="POST" statusCallbackEvent="ringing answered completed">sip:102@widgets.vml.voice.tel</Sip>
      </Dial>
      <Pause length="1"/>
      <Say>{{ name }} is unavailable. Please leave a message after the tone. When you are finished, you may hang up.</Say>
      <Record action="GOODBYE_URL" maxLength="120" timeout="5" playBeep="true" trim="trim-silence"
              transcribe="true"
              transcribeCallback="https://dash.voiceml.com/transcript/{{ email | url_encode }}?From={{ From | url_encode }}&amp;To={{ To | url_encode }}&amp;CallerName={{ CallerName | url_encode }}&amp;Digits={{ Digits | url_encode }}"
              recordingStatusCallback="https://dash.voiceml.com/voicemail/{{ email | url_encode }}?From={{ From | url_encode }}&amp;To={{ To | url_encode }}&amp;CallerName={{ CallerName | url_encode }}&amp;Digits={{ Digits | url_encode }}"
              recordingStatusCallbackEvent="completed"/>
      <Say>Thank you. Goodbye.</Say>
      <Hangup/>

    <!-- Extension 103 / Press 4 -->
    {% elsif Digits == "4" or Digits == "103" %}
      {% assign email = "chloe@widgets.com" %}
      {% assign name = "Chloe" %}
      <Say>Connecting you now.</Say>
      <Pause length="1"/>
      <Say>This call is being recorded.</Say>
      <Pause length="1"/>
      <Dial timeout="20" ringTone="us" record="record-from-answer-dual"
            recordingStatusCallback="https://dash.voiceml.com/recording/{{ email | url_encode }}?From={{ From | url_encode }}&amp;To={{ To | url_encode }}&amp;CallerName={{ CallerName | url_encode }}&amp;Digits={{ Digits | url_encode }}"
            recordingStatusCallbackEvent="completed">
        <Sip statusCallback="https://dash.voiceml.com/status/{{ email | url_encode }}?From={{ From | url_encode }}&amp;To={{ To | url_encode }}&amp;CallerName={{ CallerName | url_encode }}&amp;Digits={{ Digits | url_encode }}"
             statusCallbackMethod="POST" statusCallbackEvent="ringing answered completed">sip:103@widgets.vml.voice.tel</Sip>
      </Dial>
      <Pause length="1"/>
      <Say>{{ name }} is unavailable. Please leave a message after the tone. When you are finished, you may hang up.</Say>
      <Record action="GOODBYE_URL" maxLength="120" timeout="5" playBeep="true" trim="trim-silence"
              transcribe="true"
              transcribeCallback="https://dash.voiceml.com/transcript/{{ email | url_encode }}?From={{ From | url_encode }}&amp;To={{ To | url_encode }}&amp;CallerName={{ CallerName | url_encode }}&amp;Digits={{ Digits | url_encode }}"
              recordingStatusCallback="https://dash.voiceml.com/voicemail/{{ email | url_encode }}?From={{ From | url_encode }}&amp;To={{ To | url_encode }}&amp;CallerName={{ CallerName | url_encode }}&amp;Digits={{ Digits | url_encode }}"
              recordingStatusCallbackEvent="completed"/>
      <Say>Thank you. Goodbye.</Say>
      <Hangup/>

    {% else %}
      <Say>That is not a valid option. Goodbye.</Say>
      <Hangup/>
    {% endif %}
  {% endif %}
</Response>

Publish it at voiceml.org. Because it contains Liquid tags, it is published to an /r/ URL and rendered on every call with that call’s parameters.

Step 3: Point the number at it

Set the /r/ URL as the phone number’s voice URL. GET and POST both work.

To log calls that never reach a <Dial>, such as calls to the AI agent or callers who hang up during the menu, also point the number’s status callback at the dashboard’s /status/ route for a recipient. A number’s status callback reports only the completed event, so the dashboard records those calls when they hang up.

Then test it: call the number, press 1, and watch the call appear on the dashboard’s live board while extension 100 rings. Answer it, drag the call onto another extension, and start a live transcript from the call view. Call again and let it ring out to check that the voicemail and its transcript arrive by email.

How the routing works

The first request for the main document carries no Digits, so {% if Digits == blank %} plays the greeting inside a <Gather>. That <Gather> has no action, so when the caller presses keys the runtime requests the same document again with Digits set, and the {% else %} branch routes the call. timeout="3" ends input three seconds after the last key, which gives callers time to dial a three-digit extension; pressing # ends it sooner. A caller who presses nothing hears “We did not receive a selection” and the call ends.

What each entry does
Caller enters Rings Recordings and voicemail go to
1 or 100Michael, extension 100michael@widgets.com
2Extensions 100, 101, 102, and 103 togethermichael@widgets.com
101Ali, extension 101ali@widgets.com
3 or 102Lily, extension 102lily@widgets.com
4 or 103Chloe, extension 103chloe@widgets.com
0The AI agentNot recorded by this document
Anything elseNothing; the caller hears “That is not a valid option”—

Every branch that rings a phone follows the same pattern:

  1. It announces that the call is being recorded.
  2. <Dial record="record-from-answer-dual"> rings the extension for 20 seconds and sends the two-channel recording to the dashboard’s /recording/ route.
  3. The statusCallback on <Sip> sends ringing, answered, and completed events to /status/, which puts the call on the live board.
  4. If nobody answers, <Record transcribe="true"> takes a voicemail and sends the audio to /voicemail/ and the transcript to /transcript/. Both use the same address, so the dashboard joins them into one message.

Each callback URL also carries From, To, and Digits, which the dashboard shows with the message as call context. Every value placed inside a URL ends in | url_encode, so a caller’s + or & can’t break the query string.

Running the switchboard

Operators and administrators land on the dashboard’s operator panel. The summary strip shows calls active now, today’s calls, answered calls with the answer rate, missed calls, and the average answered call. The live board lists calls in progress, and the Extensions card shows each extension as registered, ringing, on a call, or offline. Clicking a call opens its call view, with details, its live transcript, and these actions:

Call control from the VoiceML Dashboard
Action What happens
Hang upEnds the call.
TransferBlind-transfers the caller to a phone number or SIP address. Dragging the call onto an extension does the same.
Send to voicemailPlays a prompt and records a voicemail for the call’s recipient.
Start or stop recordingRecords both sides on separate channels and delivers the recording to Messages.
Start or stop live transcriptTranscribes both sides; the text streams into the call view as the call goes on.

Every action, including refused ones, is written to the audit log. Voicemail, recordings, and transcripts collect in Messages, with filters by number, a transcript search, and a filter for messages that carry what the caller pressed.

Adding an extension

  1. Register the new phone on your SIP domain, for example as extension 104.
  2. Copy one of the single-extension branches and change its elsif condition, email, name, and SIP address.
  3. Invite the new email address in the dashboard.
  4. Publish the updated document and point the number at its new /r/ URL. Published documents can’t be edited, so every change is a new URL.

Limits of this design

  • The voicemail prompt can follow an answered call. The <Dial> has no action, so the document continues to the voicemail prompt whatever the outcome. If the extension hangs up first, the caller hears the prompt. Choosing what happens by dial result needs a server.
  • Ring-all calls don’t show while they ring. The four <Sip> nouns behind option 2 have no statusCallback. Add the same statusCallback attributes used in the other branches to show those legs on the live board.
  • CallerName arrives empty. The VoiceML runtime doesn’t send it. From and To identify the caller.
  • Long voicemails may not get a transcript. Recordings are transcribed when they last between 2 and 120 seconds. With maxLength="120", a message that runs the full length is at the limit; lower it to 115 if every voicemail needs a transcript.
  • No lookups or memory. A voiceml.org document can branch on the call itself but can’t look anything up or remember anything between calls. A CRM lookup or caller authentication needs a server.

Credits

  • The greeting is adapted from Dr. Phillip Barbay’s widget lecture in Back to School (1986), directed by Alan Metter and distributed by Orion Pictures. Rodney Dangerfield stars as Thornton Melon, the self-made businessman who enrolls in college alongside his son; Paxton Whitehead plays Dr. Barbay.
  • The sign-off is the “interrupting cow,” a traditional knock-knock joke whose author is unknown. Knock-knock jokes took hold in the United States in the 1930s.
  • widgets.com, its staff, and its extensions are fictional in this example. VoiceTel is not affiliated with widgets.com or with the film or its rights holders.
  • References: the VoiceML language reference, the voiceml.org publishing and integration guide, and the VoiceML app-builder prompt.

Trusted by