Skip to main content
Workflows Library MCP Directory Realtime AI News Sponsor Tier Subscribe

Build an IVR-Navigating Outbound Voice Agent Workflow with Twilio & LangGraph in 2026

Dialing is easy and finishing the call is hard: IVR menus, hold queues and voicemail decide completion. Build a durable LangGraph + Twilio outbound voice agent that navigates the middle of the call and returns a transcript plus a structured outcome.

Deepak Bagada

Deepak Bagada

CEO, SaaSNext

Aug 14, 2026 Published
|
Aug 14, 2026 Updated
|
11 Minutes Reading Time
Core Takeaways for Founders & Builders
  • The middle of a call — IVR trees, hold queues, voicemail — decides whether an outbound voice agent actually completes the task.
  • Model the call as a durable LangGraph state machine with budgets for IVR attempts and hold time, plus an escalation path.
  • Retry only what is safe: re-ask speech menu options, but never blind-retry DTMF which can press the wrong key twice.
  • Escalate to a human with transcript context instead of failing silently when retries or hold budgets run out.
  • Measure completion, not conversation: return a structured outcome enum plus a transcript as the audit trail.

By Deepak Bagada, CEO at SaaSNext & Principal AI Architect.

In August 2026 the outbound voice agent finally crossed the line that used to stop every demo. Google shipped consumer agents that dial real stores to check inventory, voice startups raised record rounds, and enterprise AI receptionists became so common that calling a mid-sized company often means talking to an AI. But the industry quietly admits the same thing in every technical talk: dialing a number is easy, and finishing the call is hard. The middle of a call — an IVR tree that wants you to say "billing" and press 4, a fifteen-minute hold queue, a voicemail system that needs a callback number, a representative who asks a question the agent was never briefed on — is where most agent calls die.

This guide builds the workflow that survives that middle: an IVR-navigating outbound voice agent on Twilio and LangGraph that dials a business, reads menu options from speech or keypad tones, selects the right path, monitors hold queues, escalates to a human when the task needs one, and returns a transcript plus a structured outcome. It is the automation of the errand, not the automation of the greeting, and it is built on the same orchestration discipline we catalogue in the AI workflows library, with the telephony primitives exposed the way our MCP directory servers expose every other tool.

Architecture Overview

graph TD
  A[Schedule / Trigger] --> B[Dial via Twilio]
  B --> C{Answer?}
  C -- no --> D[Voicemail Handler]
  C -- yes --> E[Media Stream WebSocket]
  E --> F[ASR Engine]
  F --> G[IVR Navigator]
  G --> H{Menu detected?}
  H -- yes --> I[Select Option: Speech / DTMF]
  I --> J{Task path found?}
  J -- yes --> K[Task Execution + Confirm]
  J -- no --> L[Retry / Escalate]
  H -- no --> M[Queue / Hold Monitor]
  M --> N{Timeout?}
  N -- yes --> O[Human Handoff]
  K --> P[Transcript + Outcome]
  D --> P
  O --> P

The workflow is a state machine over the call. Every leg — dial, answer, IVR navigation, hold, task, handoff — is a node in a LangGraph graph, and the call state (call SID, current menu depth, transcripts, retry count) is the shared state that moves between nodes. That is the key design decision: the agent is not a script that plays once, it is a durable graph that can pause on a hold queue, retry a misrecognized menu option, and resume exactly where it left off.

Part 1 — Configuration

.env

TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_FROM_NUMBER=+15551234567
CALLBACK_BASE_URL=https://voice-agent.example.com
ASR_PROVIDER=deepgram
ASR_API_KEY=xxxxxxxxxxxxxxxx
STT_SAMPLE_RATE=8000
MAX_IVR_ATTEMPTS=3
HOLD_TIMEOUT_SECONDS=600
VOICEMAIL_RETRY_BACKOFF_SECONDS=300
HUMAN_HANDOFF_WEBHOOK=https://ops.example.com/handoffs

Twilio's Media Streams push the raw audio of a live call to a WebSocket you control; a streaming speech-to-text engine turns that audio into a live transcript; and the graph decides the next Twilio action — say a phrase, play DTMF tones, or hang up — based on what was heard. The CALLBACK_BASE_URL is where Twilio reaches back for TwiML instructions at each step of the call.

Part 2 — State model

schemas.py

from typing import TypedDict, List, Optional
from enum import Enum

class CallOutcome(str, Enum):
    COMPLETED = "completed"        # task done, confirmation received
    VOICEMAIL = "voicemail"        # left a callback message
    HANGUP = "hangup"              # called party hung up
    HUMAN_HANDOFF = "handoff"      # escalated to a human agent
    FAILED = "failed"              # retries exhausted

class CallState(TypedDict, total=False):
    call_sid: str
    task: str                      # e.g. "check part availability"
    outcome: CallOutcome
    ivr_attempts: int
    current_menu_depth: int
    transcript: List[dict]         # [{role, text, ts}]
    hold_started_at: Optional[str]
    human_handed_off: bool
    summary: str

Every field the graph needs travels in one CallState object. Because LangGraph checkpoints the state between nodes, a crash mid-hold or a redeploy does not lose the call — the graph resumes from the last checkpoint, which matters when a call has already spent four minutes in a queue.

Part 3 — Telephony tools

tools.py

import os, asyncio
from twilio.rest import Client
from twilio.twiml.voice_response import VoiceResponse, Say, Gather, Play

client = Client(os.environ["TWILIO_ACCOUNT_SID"], os.environ["TWILIO_AUTH_TOKEN"])

def initiate_call(to: str, task: str) -> str:
    # Start the outbound call; Twilio dials and streams audio to our WebSocket.
    twiml = VoiceResponse()
    twiml.connect().stream(url=f"{os.environ['CALLBACK_BASE_URL']}/stream")
    call = client.calls.create(
        url=f"{os.environ['CALLBACK_BASE_URL']}/twiml/start",
        to=to,
        from_=os.environ["TWILIO_FROM_NUMBER"],
    )
    return call.sid

def send_dtmf(call_sid: str, digits: str) -> None:
    # Press keypad tones, e.g. '1' or '4#'. Works when IVR accepts DTMF.
    call = client.calls(call_sid)
    call.update(twiml=VoiceResponse().play(digits=f"w{','.join(digits)}").to_xml())

def say(call_sid: str, text: str) -> None:
    # Speak a phrase on the live call (gather = speech or DTMF input).
    g = Gather(input="speech dtmf", timeout=5, speech_timeout="auto")
    g.say(text)
    call = client.calls(call_sid)
    call.update(twiml=VoiceResponse().append(g).to_xml())

def hang_up(call_sid: str) -> None:
    client.calls(call_sid).update(status="completed")

def notify_human(call_sid: str, transcript: list, reason: str) -> None:
    import httpx
    httpx.post(os.environ["HUMAN_HANDOFF_WEBHOOK"],
               json={"call_sid": call_sid, "reason": reason,
                     "transcript": transcript[-40:]}, timeout=10)

The tools are deliberately small and idempotent. initiate_call creates the call and attaches the media stream; say and send_dtmf change what the caller hears or sends; hang_up ends it. notify_human is the escape hatch — when the graph cannot finish the task, a human gets the transcript and the context in a webhook instead of the call dying silently. Idempotency matters here: a retried send_dtmf after a network blip can press the wrong key, so the graph only retries DTMF when the previous press produced no state change.

Part 4 — The LangGraph state machine

graph.py

from langgraph.graph import StateGraph, END
from schemas import CallState, CallOutcome
from tools import initiate_call, say, send_dtmf, hang_up, notify_human

def dial(state: CallState) -> CallState:
    state["call_sid"] = initiate_call(state["task_site"], state["task"])
    return state

def navigate_ivr(state: CallState) -> CallState:
    # ASR emits menu options; select the best match for the task.
    options = state.get("menu_options", [])
    pick = match_option(options, state["task"])          # returns digits or None
    if pick:
        send_dtmf(state["call_sid"], pick)
        state["ivr_attempts"] = 0
    else:
        state["ivr_attempts"] += 1
    return state

def decide_ivr(state: CallState) -> str:
    if state["ivr_attempts"] >= int(os.environ["MAX_IVR_ATTEMPTS"]):
        return "escalate"
    if state.get("task_path_found"):
        return "execute_task"
    return "reask"                                       # loop back to navigate_ivr

def monitor_hold(state: CallState) -> CallState:
    # Silence + hold music detection; enforce HOLD_TIMEOUT_SECONDS budget.
    if state.get("hold_started_at") and elapsed(state["hold_started_at"]) >             int(os.environ["HOLD_TIMEOUT_SECONDS"]):
        state["outcome"] = CallOutcome.HUMAN_HANDOFF
        notify_human(state["call_sid"], state["transcript"], "hold timeout")
    return state

def execute_task(state: CallState) -> CallState:
    say(state["call_sid"], state["confirmation_prompt"])
    state["outcome"] = CallOutcome.COMPLETED if state.get("confirmed")         else CallOutcome.FAILED
    return state

g = StateGraph(CallState)
g.add_node("dial", dial); g.add_node("navigate_ivr", navigate_ivr)
g.add_node("monitor_hold", monitor_hold); g.add_node("execute_task", execute_task)
g.add_node("escalate", lambda s: s)
g.set_entry_point("dial")
g.add_edge("dial", "navigate_ivr")
g.add_conditional_edges("navigate_ivr", decide_ivr,
    {"reask": "navigate_ivr", "execute_task": "execute_task",
     "escalate": "escalate"})
g.add_edge("monitor_hold", "navigate_ivr")
g.add_edge("escalate", END); g.add_edge("execute_task", END)
app = g.compile(checkpointer=SqliteSaver.from_conn_string("checkpoints.db"))

The conditional edges are where the retry policy lives. A misrecognized menu option loops back into navigate_ivr with a fresh prompt — but only up to MAX_IVR_ATTEMPTS (default 3), after which the graph escalates rather than burning the call. Hold monitoring is a parallel concern: the state machine re-enters navigate_ivr after every hold check so a call that comes off hold mid-loop continues navigating instead of hanging in limbo. Checkpointing makes the whole graph durable, which is the difference between a demo and a production errand-runner.

Part 5 — Running the workflow

main.py

import asyncio, json
from graph import app
from schemas import CallState

async def run_errand(task: str, site_number: str):
    initial: CallState = {"task": task, "task_site": site_number,
                          "ivr_attempts": 0, "transcript": [],
                          "outcome": None, "current_menu_depth": 0}
    final = app.invoke(initial, config={"recursion_limit": 60})
    with open(f"outcome_{final['call_sid']}.json", "w") as f:
        json.dump({"task": task, "outcome": final["outcome"].value,
                   "summary": summarize(final["transcript"]),
                   "transcript": final["transcript"]}, f, indent=2)
    return final["outcome"].value

if __name__ == "__main__":
    asyncio.run(run_errand("check part availability for a sink faucet",
                           "+15550987654"))

The workflow runs a single errand to completion, persists the transcript and a structured outcome, and hands the summary to whatever system requested the call — a CRM, a support queue, or the user's phone. Completion — not conversation — is the metric, which is the standard the whole agent industry adopted in 2026.

Production checklist

  1. Budget the middle of the call. IVR navigation, hold queues, and voicemail handling are state machine nodes with budgets, not afterthoughts.
  2. Retry only what is safe. Re-ask a menu option (speech) liberally; never blind-retry DTMF, which can press the wrong key twice.
  3. Escalate, don't fail silently. After retries or a hold timeout, hand the transcript and context to a human via webhook.
  4. Checkpoint everything. A four-minute hold is a lot of state; LangGraph checkpoints let the graph resume after any crash.
  5. Return a structured outcome. The transcript is the audit trail; the outcome enum is what downstream systems act on.

Frequently Asked Questions

Q: Why is IVR navigation the hard part of outbound voice agents?

A: Because the start of a call is scriptable but the middle is not: menus vary per business, speech recognition mishears options, hold queues are unbounded, and voicemail systems need callback numbers. The workflow treats each of those as a state with a budget and an escalation path instead of assuming the call will go smoothly.

Q: How does the agent choose which DTMF key to press?

A: A streaming speech-to-text engine transcribes the menu options, and a matching step maps the task (e.g., "check part availability") to the best-matching option (e.g., "press 1 for parts"). If no option matches, the graph re-asks or escalates after the retry budget is spent.

Q: What happens on a long hold queue?

A: The hold monitor tracks hold duration against a budget. Inside the budget the graph stays on the call and resumes navigation when the hold ends; past the budget it escalates to a human with the transcript and reason.

Q: Is this Twilio-specific?

A: No. The graph is transport-agnostic — swap the tools.py layer for any telephony provider (or an SIP trunk) and the state machine, retry rules, and escalation logic stay the same.

Q: How does this differ from a single-shot scripted call?

A: A scripted call plays a fixed sequence and breaks on anything unexpected. This is a durable state machine that reacts to what it hears, retries safely, monitors holds, escalates to humans, and returns a structured completion outcome plus a transcript.

Executive Briefing

Enjoyed this breakdown? Get our morning dispatch in your inbox.

Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.

Frequently Asked Questions
Because the start of a call is scriptable but the middle is not: menus vary per business, speech recognition mishears options, hold queues are unbounded, and voicemail systems need callback numbers. The workflow treats each as a state with a budget and an escalation path.
A streaming speech-to-text engine transcribes the menu options and a matching step maps the task to the best-matching option (e.g., press 1 for parts). If nothing matches, the graph re-asks or escalates after the retry budget is spent.
The hold monitor tracks duration against a budget. Inside the budget the graph stays on the call and resumes navigation when the hold ends; past the budget it escalates to a human with the transcript and reason.
No. The graph is transport-agnostic — swap the tools.py layer for any telephony provider or SIP trunk and the state machine, retry rules, and escalation logic stay the same.
A scripted call plays a fixed sequence and breaks on anything unexpected. This is a durable state machine that reacts to what it hears, retries safely, monitors holds, escalates to humans, and returns a structured completion outcome plus a transcript.
Deepak Bagada
Author Profile

Deepak Bagada

CEO, SaaSNext

Deepak Bagada is the CEO of SaaSNext and founder of Daily AI World. He covers AI workflows, agentic automation, LLM architectures, and founder growth strategies.

Related Intelligence Analysis

Research Breakdown AI Workflows

The Step-by-Step Guide to Automating Meeting Tasks with Whisper

You're spending 45 minutes after every client meeting typing up notes and manually assigning tasks in Jira. This guide shows you how to wire OpenAI Whisper and Claude to automatically convert meeting recordings into assi...

Deepak Bagada Deepak Bagada
9m read
Research Breakdown AI Workflows

Lovable AI UI-to-Code Pipeline: 2026 Tutorial

Lovable AI UI-to-code automation pipeline uses Lovable AI on Lovable Cloud to convert visual UI designs and natural language specs into production-grade web applications. UI/UX designers and frontend developers bridging...

Deepak Bagada Deepak Bagada
8m read
Breaking AI Workflows

Claude Code's New Browser: 5 Workflows That Save Hours Daily

Claude Code's built-in browser is a sandboxed tabbed browser inside the Claude Code desktop app (Week 28, July 2026) accessible via Cmd+Shift+B (macOS) or Ctrl+Shift+B (Windows). It lets Claude open websites, read docume...

Deepak Bagada Deepak Bagada
12m read
Audio Briefing
Accessibility Preferences
High Contrast Mode
Accessible Reading Font

Keyboard Shortcuts

Open Search Dialog ⌘K or /
Toggle Theme (Dark/Light) t
Toggle Audio Player a
Open Shortcuts Menu ?
Close Active Dialog Esc