Voice AI

Eliminate Dead Air in AI Voice Assistants with Filler Messages — Telnyx AI Assistant Webhook Demo

When an AI voice assistant calls a sync webhook tool — to look up an order, query a database, check inventory — the caller hears silence. Five seconds of dead air on a phone call feels like thirty. Hang up. Call again. Complaint filed.

Filler messages solve this. Configured per-tool in Telnyx Mission Control, they let the AI Assistant speak scripted phrases while waiting for the webhook to respond: "Let me look that up for you." at 0 seconds, "Still working on this, one moment please." at 5 seconds, "Almost there, thanks for your patience." at 15 seconds. The caller stays engaged. The webhook takes the time it needs. No dead air.

This walkthrough builds a complete demo: a Flask webhook server with a live split-screen dashboard that shows the webhook call, the filler messages firing, and the countdown in real time. 124 lines of Python, one AI Assistant, one phone number, one dashboard — and zero silence.

What You'll Build

A webhook server that an AI Assistant calls when a caller asks about an order:

  • Caller dials the AI Assistant — a voice AI agent configured in Mission Control
  • Caller asks a question — "What's the status of my order 12345?"
  • AI Assistant calls the webhook — a sync tool call to /webhook/order-status
  • Webhook delays — intentionally sleeps for 12 seconds (configurable) to simulate a slow backend
  • Filler messages fire — the AI Assistant speaks scripted phrases at 0s, 5s, and 15s while the webhook is processing
  • Webhook responds — returns mock order status as JSON
  • AI Assistant reads the result — "Your order 12345 has shipped via FedEx, tracking number FX-98765, estimated delivery July 20th."
  • Dashboard shows everything — a split-screen browser UI with the call timeline on the left and server logs on the right, updated in real time via Server-Sent Events

The whole flow is visible: the tool call arrives, the filler messages fire, the countdown ticks, the response goes back.

Why Filler Messages Matter

Every voice AI developer hits the same problem: sync webhook tool calls take time. A database query takes 2 seconds. A third-party API takes 5 seconds. A complex lookup takes 10 seconds. On a phone call, that's dead air — and dead air is the enemy of conversation.

Without filler messages, developers build workarounds:

  • Background music — doesn't tell the caller what's happening
  • "Please hold" TTS — fires once, then silence resumes
  • Async tools — more complex to build, don't work for all use cases
  • Faster webhooks — not always possible when you depend on external systems

Filler messages are the carrier-grade solution: the AI Assistant speaks configurable phrases at configurable intervals while the webhook processes. The caller knows the system is working. The developer doesn't have to build a workaround.

Prerequisites

The Architecture

  Caller dials AI Assistant
        │
        ▼
  ┌──────────────────────┐
  │ Telnyx AI Assistant   │
  │ (Mission Control)     │
  └────────┬─────────────┘
           │ sync tool call
           ▼
  ┌──────────────────────┐    SSE     ┌──────────────────┐
  │ Flask Webhook Server  │──────────►│ Browser Dashboard  │
  │ POST /webhook/        │           │ Split-screen UI    │
  │   order-status        │           │ (timeline + logs)  │
  └──────────────────────┘           └──────────────────┘
           │
    delays N seconds,
    then returns mock
    order status

The AI Assistant calls the webhook as a sync tool. The webhook sleeps for WEBHOOK_DELAY_SECONDS (default 12). During that delay, the AI Assistant speaks the filler messages. The browser dashboard receives real-time events via SSE.

Step 1: Clone and Configure

git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/ai-assistant-filler-messages-demo-python
cp .env.example .env
pip install -r requirements.txt

Edit .env with your credentials:

TELNYX_API_KEY=your_telnyx_api_key_here
WEBHOOK_DELAY_SECONDS=12
PORT=5000

The WEBHOOK_DELAY_SECONDS controls how long the webhook waits before responding. Set it to 12-15 seconds so multiple filler messages trigger during the delay.

Step 2: Set Up the AI Assistant

The repo includes a setup.py script that creates the AI Assistant, adds the webhook tool with filler messages, and assigns your phone number — all via the Telnyx API.

Start ngrok first (the setup script needs your public URL):

ngrok http 5000

Copy the HTTPS URL, then run setup:

python setup.py https://abc123.ngrok.io

The script will:

  1. List your active phone numbers — pick one
  2. Create an AI Assistant named "Filler Messages Demo" with Claude Haiku 4.5
  3. Add a sync webhook tool (check_order_status) pointing to your ngrok URL
  4. Configure the three filler messages on the tool
  5. Assign your phone number to the assistant's backing TeXML app

Important: The API sets the filler message values, but playback on calls requires configuring them through the Mission Control UI. After running setup.py, open the tool's Filler Messages tab in Mission Control and verify the three messages are present.

Manual Setup (Alternative)

If you prefer to configure everything in Mission Control manually:

  1. Go to AI → Assistants in the Telnyx Portal
  2. Create a new assistant with telephony enabled
  3. Add a sync webhook tool:

- Name: check_order_status - Description: "Check the status of a customer order by order ID" - Parameters: order_id (string, required) - Webhook URL: https://<id>.ngrok.io/webhook/order-status - Timeout: 30000ms (default 5000ms is too short)

  1. Under the Filler Messages tab, add:
TypeContentTiming
request_start"Let me look that up for you."immediate
request_response_delayed"Still working on this, one moment please."5000ms
request_response_delayed"Almost there, thanks for your patience."15000ms
  1. Assign a phone number to the assistant

Step 3: Understand the Code

Everything lives in app.py (124 lines). Here's what each piece does.

The Filler Message Configuration

The filler messages are defined as a config array:

FILLER_CONFIG = [
    {"type": "request_start", "content": "Let me look that up for you.", "timing_ms": 0},
    {"type": "request_response_delayed", "content": "Still working on this, one moment please.", "timing_ms": 5000},
    {"type": "request_response_delayed", "content": "Almost there, thanks for your patience.", "timing_ms": 15000},
]

Two types:

  • request_start — fires immediately when the webhook tool is called
  • request_response_delayed — fires after timing_ms milliseconds if the webhook hasn't responded yet

The Webhook Endpoint

The webhook receives the sync tool call, broadcasts events to the dashboard, sleeps for the configured delay, then returns mock order data:

@app.route("/webhook/order-status", methods=["POST"])
def webhook_order_status():
    body = request.get_json(silent=True) or {}
    order_id = body.get("order_id", "unknown")
    
    broadcast("tool_call_received", {"order_id": order_id, "request_body": body, "delay_seconds": WEBHOOK_DELAY_SECONDS})
    
    for filler in FILLER_CONFIG:
        if filler.get("timing_ms", 0) / 1000 < WEBHOOK_DELAY_SECONDS:
            broadcast("filler_message", {"content": filler["content"], "filler_type": filler["type"], "timing_ms": filler.get("timing_ms", 0)})
    
    for elapsed in range(1, WEBHOOK_DELAY_SECONDS + 1):
        time.sleep(1)
        broadcast("countdown", {"elapsed": elapsed, "total": WEBHOOK_DELAY_SECONDS, "remaining": WEBHOOK_DELAY_SECONDS - elapsed})
    
    order = MOCK_ORDERS.get(order_id, {"order_id": order_id, "status": "not_found", "error": "Order not found"})
    return jsonify({"result": order}), 200

The webhook broadcasts four event types to the dashboard: tool_call_received when the call arrives, filler_message for each configured filler, countdown every second, and response_sent when the webhook returns.

The SSE Dashboard

The dashboard uses Server-Sent Events for real-time updates. Each connected browser gets a queue, and the broadcast() function pushes events to all clients:

def broadcast(event_type, data):
    payload = json.dumps({"type": event_type, "timestamp": time.time(), **data})
    msg = f"event: {event_type}\ndata: {payload}\n\n"
    dead = []
    with clients_lock:
        for q in clients:
            try:
                q.put_nowait(msg)
            except queue.Full:
                dead.append(q)
        for q in dead:
            clients.remove(q)

The SSE endpoint streams events to the browser with a 30-second keepalive:

@app.route("/events")
def events():
    q = queue.Queue(maxsize=100)
    with clients_lock:
        clients.append(q)
    def stream():
        yield f"data: {json.dumps({'type': 'connected', 'timestamp': time.time(), 'delay_seconds': WEBHOOK_DELAY_SECONDS})}\n\n"
        while True:
            try:
                msg = q.get(timeout=30)
                yield msg
            except queue.Empty:
                yield ": keepalive\n\n"
    return Response(stream(), mimetype="text/event-stream",
                    headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})

The Mock Orders

Three orders for testing:

MOCK_ORDERS = {
    "12345": {"order_id": "12345", "status": "shipped", "carrier": "FedEx", "tracking": "FX-98765", "eta": "2026-07-20", "items": ["Wireless Headset", "USB-C Cable"]},
    "67890": {"order_id": "67890", "status": "processing", "carrier": "pending", "tracking": "pending", "eta": "2026-07-25", "items": ["Desk Lamp"]},
    "11111": {"order_id": "11111", "status": "delivered", "carrier": "UPS", "tracking": "1Z999AA10123456784", "eta": "2026-07-14", "items": ["Keyboard", "Mouse", "Monitor Stand"]},
}

Call and ask about order 12345, 67890, or 11111 to see different statuses.

Step 4: Run the Demo

Start the Flask server:

python app.py

Open http://localhost:5000 in a browser — you'll see the split-screen dashboard with:

  • Left panel: Call timeline showing tool calls, filler message indicators, and responses
  • Right panel: Server logs with request/response JSON and delay countdown

Make sure ngrok is running and the webhook URL is configured in your AI Assistant's tool:

ngrok http 5000

Step 5: Call and Watch

Call your AI Assistant's phone number. Ask:

"What's the status of my order 12345?"

Watch the dashboard and listen to the call:

  1. 0 seconds — the AI Assistant says "Let me look that up for you." and calls the webhook
  2. 5 seconds — the AI Assistant says "Still working on this, one moment please."
  3. 12 seconds — the webhook responds with the order status
  4. After response — the AI Assistant reads back the order details: "Your order 12345 has shipped via FedEx, tracking number FX-98765, estimated delivery July 20th."

The dashboard shows the tool call arriving, the filler messages firing at their configured times, the countdown ticking, and the response going back — all in real time.

Test Without a Phone Call

You can test the webhook endpoint directly:

curl -X POST http://localhost:5000/webhook/order-status \
  -H "Content-Type: application/json" \
  -d '{"order_id": "12345"}'

Open the dashboard in a browser first to see the events stream in real time.

Customizing the Filler Messages

The filler message configuration is the control surface. Different timings and content produce very different caller experiences:

Add a 10-second filler:

FILLER_CONFIG = [
    {"type": "request_start", "content": "Let me look that up for you.", "timing_ms": 0},
    {"type": "request_response_delayed", "content": "Still working on this, one moment please.", "timing_ms": 5000},
    {"type": "request_response_delayed", "content": "I'm checking with the shipping carrier now.", "timing_ms": 10000},
    {"type": "request_response_delayed", "content": "Almost there, thanks for your patience.", "timing_ms": 15000},
]

Customize the messages for your brand:

{"type": "request_start", "content": "Let me check on that for you right away.", "timing_ms": 0}
{"type": "request_response_delayed", "content": "I'm pulling up your account details now.", "timing_ms": 5000}

Use context-aware fillers for different tools:

For a payment processing tool:

{"type": "request_start", "content": "Let me process that payment for you.", "timing_ms": 0}
{"type": "request_response_delayed", "content": "I'm confirming the transaction with your bank.", "timing_ms": 5000}
{"type": "request_response_delayed", "content": "Almost done, just waiting for final confirmation.", "timing_ms": 15000}

For a booking tool:

{"type": "request_start", "content": "Let me check availability for those dates.", "timing_ms": 0}
{"type": "request_response_delayed", "content": "I'm searching across all our properties now.", "timing_ms": 5000}

Going to Production

This example uses mock data and intentional delays. For production:

  • Real data source — replace MOCK_ORDERS with actual database or API queries
  • Remove artificial delay — the delay exists only for demo purposes; real webhooks should respond as fast as possible
  • Webhook verification — validate Telnyx webhook signatures
  • Error handling — add timeout handling and retry logic for upstream API failures
  • Monitoring — add structured logging and alerting
  • Filler message tuning — adjust timing and content based on real webhook response times
  • Timeout alignment — set the tool timeout_ms (default 5000ms) to at least 30000ms so the webhook has time to respond before the assistant gives up

Frequently Asked Questions

What are AI Assistant filler messages? Filler messages are scripted phrases the AI Assistant speaks while waiting for a sync webhook tool to respond. They eliminate dead air on the call and keep the caller informed. They're configured per-tool in Mission Control.

How are filler messages different from TTS hold music? Filler messages are spoken by the AI Assistant in its own voice, as part of the conversation. Hold music is audio played while the call is on hold. Filler messages feel like the assistant is still there and working on your request.

Can I use filler messages with async webhook tools? Filler messages are designed for sync webhook tools — where the assistant waits for the webhook response before continuing. Async tools return immediately and the assistant continues without waiting, so filler messages don't apply.

What happens if the webhook responds before the first filler message fires? If the webhook responds before the first request_response_delayed message's timing_ms, that message doesn't fire. The request_start message always fires immediately when the tool is called.

How do I configure filler messages? Filler messages are configured per-tool in Mission Control. Open the AI Assistant, edit the webhook tool, and go to the Filler Messages tab. You can also set them via the API (setup.py does this), but playback on calls requires the Mission Control UI configuration.

What is the tool timeout and why does it matter? The tool timeout (timeout_ms) is how long the AI Assistant waits for the webhook to respond before giving up. The default is 5000ms (5 seconds). If your webhook takes longer than 5 seconds, you must increase this to at least 30000ms, or the assistant will time out before the webhook responds.

Can I use a different AI model? Yes. The setup.py script uses anthropic/claude-haiku-4-5 as the model. You can change this when creating or updating the assistant via the API or Mission Control.

How much does a call cost? Telnyx AI Assistants are priced per minute. A typical order status call with a 12-second webhook delay uses about 30-45 seconds of call time — a few cents per call.

Ready to build with low-latency voice AI?

Join developers building the future of real-time conversations