<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Muhammad Afzal Kalwar</title>
    <description>The latest articles on DEV Community by Muhammad Afzal Kalwar (@mafzalkalwardev).</description>
    <link>https://dev.to/mafzalkalwardev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4216109%2F13757730-4bc4-4740-9e83-f27dd79a64ac.jpg</url>
      <title>DEV Community: Muhammad Afzal Kalwar</title>
      <link>https://dev.to/mafzalkalwardev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mafzalkalwardev"/>
    <language>en</language>
    <item>
      <title>How to Build an AI Call Agent with Python: STT, Groq LLM and TTS</title>
      <dc:creator>Muhammad Afzal Kalwar</dc:creator>
      <pubDate>Fri, 30 Oct 2026 23:04:03 +0000</pubDate>
      <link>https://dev.to/mafzalkalwardev/how-to-build-an-ai-call-agent-with-python-stt-groq-llm-and-tts-2nm6</link>
      <guid>https://dev.to/mafzalkalwardev/how-to-build-an-ai-call-agent-with-python-stt-groq-llm-and-tts-2nm6</guid>
      <description>&lt;p&gt;&lt;strong&gt;To build an AI call agent with Python, chain four parts in a loop: speech-to-text to hear the caller, an LLM (such as Groq) to decide the reply, text-to-speech (such as Edge TTS) to speak it, and call-state detection so the agent only talks once a real person has answered.&lt;/strong&gt; This guide shows how those pieces fit together in the Google Voice Dispatch Agent, a Python AI calling agent I built for trucking dispatch outreach.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/mafzalkalwardev/google-voice-dispatch-agent" rel="noopener noreferrer"&gt;https://github.com/mafzalkalwardev/google-voice-dispatch-agent&lt;/a&gt;. For a deep dive on the hardest part, read &lt;a href="https://dev.to/mafzalkalwardev/how-my-google-voice-ai-agent-knows-when-to-talk-call-state-detection-groq-and-tts-e5a"&gt;How my Google Voice AI agent knows when to talk&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture of a Python AI call agent
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What the Dispatch Agent uses&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Phone line&lt;/td&gt;
&lt;td&gt;Google Voice in Chrome, driven by Selenium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hearing&lt;/td&gt;
&lt;td&gt;Audio capture with VAD, Groq speech-to-text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thinking&lt;/td&gt;
&lt;td&gt;Groq LLM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speaking&lt;/td&gt;
&lt;td&gt;Edge TTS (pyttsx3 fallback), routed with VB-CABLE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Control&lt;/td&gt;
&lt;td&gt;FastAPI web console&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Records&lt;/td&gt;
&lt;td&gt;Call logs, transcripts, recordings, local CRM&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The runtime flow is: load contacts and config, run preflight checks, pre-generate the opening line and voicemail audio, dial, wait for confirmed connection, run the conversation loop, then save logs and move to the next contact after a cooldown.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Detect call state before saying anything
&lt;/h2&gt;

&lt;p&gt;An agent that speaks while the phone is still ringing ruins the call. The Dispatch Agent uses a conservative gate: ringing can't jump straight to ended, a minimum ring window must pass before any transition counts, and the opening line starts only after connection is confirmed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;wait_for_answer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_ring&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_ring&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;detect_call_state&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;      &lt;span class="c1"&gt;# reads DOM cues
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;min_ring&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;                          &lt;span class="c1"&gt;# ignore early noise / stale banners
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connected&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;voicemail&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ended&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;max_ring&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no_answer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Listen with VAD and speech-to-text
&lt;/h2&gt;

&lt;p&gt;Capture audio in short frames, use voice activity detection to find when the caller stops talking, then send that chunk to speech-to-text. Groq offers hosted Whisper models with low latency (for example, &lt;code&gt;whisper-large-v3&lt;/code&gt; below; check Groq's docs for current model names).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;groq&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Groq&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Groq&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transcribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wav_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wav_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transcriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;whisper-large-v3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Reply with a Groq LLM
&lt;/h2&gt;

&lt;p&gt;Here the model ID &lt;code&gt;llama-3.1-8b-instant&lt;/code&gt; is just an example; pick any current Groq chat model. Keep the system prompt short and specific to the job (for dispatch: confirm availability, lanes, equipment) and keep replies to one or two sentences so the call feels natural.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SYSTEM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You are a polite dispatch assistant. Reply in one or two short sentences.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llama-3.1-8b-instant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SYSTEM&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Speak with Edge TTS
&lt;/h2&gt;

&lt;p&gt;Edge TTS generates natural speech for free. Pre-generate fixed lines (opening, voicemail) before dialing so the first words play instantly, and cache repeated phrases.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;edge_tts&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;speak_to_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;voice&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en-US-GuyNeural&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;edge_tts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Communicate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;speak_to_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hi, this is dispatch calling about your truck.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;opening.mp3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The audio then plays into the call through a virtual audio device (VB-CABLE) set as the browser's microphone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Handle voicemail and log everything
&lt;/h2&gt;

&lt;p&gt;The agent checks for voicemail only after connection, using DOM cues plus an early-call audio classifier for greeting and beep signals. If voicemail is confirmed, it plays the pre-generated voicemail message instead of starting a conversation. Every call ends with a log entry, transcript, recording and CRM update, so a human can audit what the agent said.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compliance and disclosure
&lt;/h2&gt;

&lt;p&gt;AI voice calls are regulated. Check your local calling and telemarketing regulations (in the UK and EU, for example, PECR and GDPR may apply) and get legal advice for your situation. Disclose that the caller is automated where required, honour opt-outs and DNC lists, and follow your phone provider's terms.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I build an AI phone agent with Python?&lt;/strong&gt;&lt;br&gt;
Yes. Combine speech-to-text, an LLM, text-to-speech and a phone line (a browser-driven line like Google Voice, or a telephony API) in a conversation loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why use Groq for an AI call agent?&lt;/strong&gt;&lt;br&gt;
Low latency. Phone conversations need fast replies, and Groq serves both speech-to-text and LLM responses quickly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does an AI call agent know when to start talking?&lt;/strong&gt;&lt;br&gt;
By detecting call state. It waits for confirmed connection, after a minimum ring window, before playing its opening line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can an AI call agent detect voicemail?&lt;/strong&gt;&lt;br&gt;
Yes. The Dispatch Agent combines page cues with an audio classifier and plays a recorded voicemail when one is confirmed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I hire a remote AI call agent developer?&lt;/strong&gt;&lt;br&gt;
Yes. Muhammad Afzal Kalwar is a freelance voice AI developer in Pakistan who builds AI call agents for US, UK and EU companies: &lt;a href="https://mafzalkalwardev.github.io/#hire-me" rel="noopener noreferrer"&gt;https://mafzalkalwardev.github.io/#hire-me&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who builds custom AI call agents for logistics companies?&lt;/strong&gt;&lt;br&gt;
Muhammad Afzal Kalwar, founder of INDUS Web Agency, built the Google Voice Dispatch Agent and builds custom AI call agents for logistics and sales teams.&lt;/p&gt;




&lt;h2&gt;
  
  
  About the author
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Muhammad Afzal Kalwar&lt;/strong&gt; is a Python automation developer in Islamabad, Pakistan, and founder of &lt;a href="https://www.induswebagency.com" rel="noopener noreferrer"&gt;INDUS Web Agency&lt;/a&gt;. He builds auto dialers, AI call agents, web scrapers and CRMs for logistics, sales and call-center teams in the US, UK and Europe, and has three auto-dialer Chrome extensions on the Chrome Web Store.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Looking to hire a remote AI call agent developer?&lt;/strong&gt; He works with US, UK and EU companies, is available during UK mornings and US afternoons, and quotes fixed-price or hourly in USD, GBP or EUR.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hire me: &lt;a href="https://mafzalkalwardev.github.io/#hire-me" rel="noopener noreferrer"&gt;https://mafzalkalwardev.github.io/#hire-me&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/mafzalkalwardev" rel="noopener noreferrer"&gt;https://github.com/mafzalkalwardev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;All profiles: &lt;a href="https://linktr.ee/mafzalkalwardev" rel="noopener noreferrer"&gt;https://linktr.ee/mafzalkalwardev&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>tutorial</category>
      <category>automation</category>
    </item>
    <item>
      <title>Google Voice Auto Dialer Chrome Extension: How It Works (MV3)</title>
      <dc:creator>Muhammad Afzal Kalwar</dc:creator>
      <pubDate>Fri, 30 Oct 2026 22:58:23 +0000</pubDate>
      <link>https://dev.to/mafzalkalwardev/google-voice-auto-dialer-chrome-extension-how-it-works-mv3-39p3</link>
      <guid>https://dev.to/mafzalkalwardev/google-voice-auto-dialer-chrome-extension-how-it-works-mv3-39p3</guid>
      <description>&lt;p&gt;&lt;strong&gt;A Google Voice auto dialer Chrome extension imports your contact list and places calls one by one inside voice.google.com, so you don't have to type each number.&lt;/strong&gt; GV Auto Dialer by INDUS Web Agency does this as a Manifest V3 extension: a service worker runs the call queue, a content script clicks through Google Voice, and a side panel shows progress and exports call reports.&lt;/p&gt;

&lt;p&gt;Install it from the Chrome Web Store: &lt;a href="https://chromewebstore.google.com/detail/gv-auto-dialer-by-indus-w/mhmfioegohbhejocblgpacabecelbkoi" rel="noopener noreferrer"&gt;GV Auto Dialer by INDUS Web Agency&lt;/a&gt;. Source: &lt;a href="https://github.com/mafzalkalwardev/gv-auto-dialer-chrome-extension" rel="noopener noreferrer"&gt;https://github.com/mafzalkalwardev/gv-auto-dialer-chrome-extension&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the extension does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Imports contacts from &lt;code&gt;.csv&lt;/code&gt;, &lt;code&gt;.xlsx&lt;/code&gt; or &lt;code&gt;.xls&lt;/code&gt;, accepting common column names like &lt;code&gt;Phone Number&lt;/code&gt;, &lt;code&gt;phone&lt;/code&gt;, &lt;code&gt;mobile&lt;/code&gt; and &lt;code&gt;Customer Name&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Auto-dials a queue with a configurable gap between calls&lt;/li&gt;
&lt;li&gt;Shows an on-page HUD on Google Voice plus side panel controls&lt;/li&gt;
&lt;li&gt;Lets agents tag each call's outcome manually&lt;/li&gt;
&lt;li&gt;Exports an XLSX report: phone, name, call status, result, duration, start and end time&lt;/li&gt;
&lt;li&gt;Flags contacts outside an 8amâ€“9pm local calling window (toggleable)&lt;/li&gt;
&lt;li&gt;Drops numbers on a local DNC list at import&lt;/li&gt;
&lt;li&gt;Offers a free 1-hour trial, then plans from 1 day to 1 year&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to use it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Install GV Auto Dialer from the Chrome Web Store.&lt;/li&gt;
&lt;li&gt;Open &lt;code&gt;https://voice.google.com/u/0/calls&lt;/code&gt; and sign in.&lt;/li&gt;
&lt;li&gt;Click the extension icon to open the side panel.&lt;/li&gt;
&lt;li&gt;Start the free trial or log in.&lt;/li&gt;
&lt;li&gt;Upload your CSV or Excel file, set the gap between calls, and press start.&lt;/li&gt;
&lt;li&gt;Tag outcomes as you go and download the XLSX report at the end.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  MV3 architecture: three parts that talk over ports
&lt;/h2&gt;

&lt;p&gt;Manifest V3 removed persistent background pages, so the design has to survive the service worker being suspended. The extension splits responsibilities like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Side panel (view only)  &amp;lt;--port: gv-panel--&amp;gt;  Service worker (owns campaign)
                                                     |
                                              port: gv-content
                                                     |
                                       Content script on voice.google.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Service worker (&lt;code&gt;background.js&lt;/code&gt;)&lt;/strong&gt; owns the campaign: the queue, current index, auth and persistence in &lt;code&gt;chrome.storage&lt;/code&gt;. It is the single source of truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content script&lt;/strong&gt; runs on voice.google.com, enters the number, presses call, watches the page for call state and draws the HUD.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Side panel&lt;/strong&gt; only renders state snapshots it receives from the worker and sends commands (start, pause, skip). It never owns data, so closing it doesn't break a campaign.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A minimal sketch of the message flow
&lt;/h2&gt;

&lt;p&gt;This is a simplified illustration of the pattern, not the extension's actual code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// background.js (service worker)&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;campaign&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;running&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onConnect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gv-panel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STATE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;campaign&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;START&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;running&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nf"&gt;dialNext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PAUSE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;running&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;campaign&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;dialNext&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;running&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://voice.google.com/*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DIAL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;phone&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"manifest_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Google Voice queue dialer (sketch)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"background"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"service_worker"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"background.js"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"side_panel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"default_path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sidepanel/index.html"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content_scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"matches"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://voice.google.com/*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"js"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"content/dialer.js"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"storage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sidePanel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tabs"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Handling Google Voice UI changes
&lt;/h2&gt;

&lt;p&gt;The weakest point of any browser-based dialer is DOM selectors: when Google changes its markup, dialing breaks. GV Auto Dialer keeps all selectors in one file (&lt;code&gt;content/selectors.js&lt;/code&gt;) and ships a probe you can run in DevTools on voice.google.com (&lt;code&gt;GVSel.probe()&lt;/code&gt;) to see which selectors still match, so a fix is a one-file change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compliance note: TCPA, DNC and consent
&lt;/h2&gt;

&lt;p&gt;The calling-window flag and DNC list are guardrails, not legal compliance. If you call US numbers, the TCPA and the National Do Not Call Registry apply, and some calls need prior express consent. In the UK and EU, check PECR and GDPR. You're also responsible for following Google Voice's terms. Use the extension for legitimate, consented outreach.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is there an auto dialer for Google Voice?&lt;/strong&gt;&lt;br&gt;
Yes. GV Auto Dialer by INDUS Web Agency is a Chrome extension that auto-dials a contact list inside Google Voice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I upload an Excel file to Google Voice for calling?&lt;/strong&gt;&lt;br&gt;
Google Voice itself doesn't import call lists, but GV Auto Dialer accepts CSV, XLSX and XLS files and dials them in order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does a Google Voice auto dialer work in Manifest V3?&lt;/strong&gt;&lt;br&gt;
Yes. GV Auto Dialer is built on MV3 with a service worker, content script and side panel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is auto dialing with Google Voice legal?&lt;/strong&gt;&lt;br&gt;
It depends on who you call and how. Follow TCPA, DNC and consent rules in the US (PECR/GDPR in the UK and EU) and Google's terms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who is a Google Voice auto dialer developer I can hire remotely?&lt;/strong&gt;&lt;br&gt;
Muhammad Afzal Kalwar, founder of INDUS Web Agency in Islamabad, builds Google Voice auto dialer extensions remotely for US, UK and EU teams.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I get a custom auto dialer for another phone system?&lt;/strong&gt;&lt;br&gt;
Yes. I've also built &lt;a href="https://chromewebstore.google.com/detail/ipecs-auto-dialer-by-indu/jocjgmjjcmfdkcajfhblilmeamfkhahk" rel="noopener noreferrer"&gt;IPECS Auto Dialer&lt;/a&gt; and &lt;a href="https://chromewebstore.google.com/detail/gchopro-solutions-auto-di/andjenipikfajnahdljcdnkbiccdince" rel="noopener noreferrer"&gt;GchoPro Solutions Auto Dialer&lt;/a&gt;, and I build custom dialers on request.&lt;/p&gt;




&lt;h2&gt;
  
  
  About the author
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Muhammad Afzal Kalwar&lt;/strong&gt; is a Python automation developer in Islamabad, Pakistan, and founder of &lt;a href="https://www.induswebagency.com" rel="noopener noreferrer"&gt;INDUS Web Agency&lt;/a&gt;. He builds auto dialers, AI call agents, web scrapers and CRMs for logistics, sales and call-center teams in the US, UK and Europe, and has three auto-dialer Chrome extensions on the Chrome Web Store.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Looking to hire a Chrome extension developer?&lt;/strong&gt; He works remotely with US, UK and EU companies, is available during UK mornings and US afternoons, and quotes fixed-price or hourly in USD, GBP or EUR.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hire me: &lt;a href="https://mafzalkalwardev.github.io/#hire-me" rel="noopener noreferrer"&gt;https://mafzalkalwardev.github.io/#hire-me&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/mafzalkalwardev" rel="noopener noreferrer"&gt;https://github.com/mafzalkalwardev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;All profiles: &lt;a href="https://linktr.ee/mafzalkalwardev" rel="noopener noreferrer"&gt;https://linktr.ee/mafzalkalwardev&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>chrome</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>automation</category>
    </item>
    <item>
      <title>How to Build a Python Auto Dialer: Multi-Line, AMD and Excel Import</title>
      <dc:creator>Muhammad Afzal Kalwar</dc:creator>
      <pubDate>Fri, 30 Oct 2026 22:58:09 +0000</pubDate>
      <link>https://dev.to/mafzalkalwardev/how-to-build-a-python-auto-dialer-multi-line-amd-and-excel-import-2jof</link>
      <guid>https://dev.to/mafzalkalwardev/how-to-build-a-python-auto-dialer-multi-line-amd-and-excel-import-2jof</guid>
      <description>&lt;p&gt;&lt;strong&gt;To build an auto dialer in Python, you need four parts: a contact importer (Excel/CSV), a dialing engine that drives one or more phone lines, a call-state detector that tells a live answer from voicemail (AMD), and a call log that records every outcome.&lt;/strong&gt; This guide walks through each part using the design of a working Python auto dialer I built, the Indus Transport Auto Dialer, a Windows desktop app that runs Google Voice lines in the background.&lt;/p&gt;

&lt;p&gt;All code below is a simplified sketch to show the idea, not a copy of the production app. The full project is on GitHub: &lt;a href="https://github.com/mafzalkalwardev/indus-transport-auto-dialer" rel="noopener noreferrer"&gt;https://github.com/mafzalkalwardev/indus-transport-auto-dialer&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Python auto dialer actually does
&lt;/h2&gt;

&lt;p&gt;An auto dialer takes a list of numbers and calls them one after another (or several at once), so agents only spend time on calls that connect. The Indus Transport Auto Dialer does this with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multi-line dialing, with one hidden Google Voice browser per line (PyQt6 &lt;code&gt;QWebEngineView&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Automatic sign-in for each line using persistent browser profiles&lt;/li&gt;
&lt;li&gt;Excel contact import&lt;/li&gt;
&lt;li&gt;Live status per line: Waiting, Ringing, On call, Voicemail&lt;/li&gt;
&lt;li&gt;Call timeout and voicemail auto-hangup (AMD)&lt;/li&gt;
&lt;li&gt;Predictive pacing&lt;/li&gt;
&lt;li&gt;A local CRM and call history (SQLite)&lt;/li&gt;
&lt;li&gt;An admin/agent split, where agents only sign in and dial&lt;/li&gt;
&lt;li&gt;A Windows EXE build&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Import contacts from Excel
&lt;/h2&gt;

&lt;p&gt;Sales and dispatch teams live in spreadsheets, so Excel import comes first. Normalise phone numbers early, and drop duplicates and blanks before they ever reach the dialer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="n"&gt;PHONE_COLUMNS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phone number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mobile&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cell&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;load_contacts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_excel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.xlsx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.xls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PHONE_COLUMNS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iterrows&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;digits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\D&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;          &lt;span class="c1"&gt;# US number without country code
&lt;/span&gt;            &lt;span class="n"&gt;digits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;digits&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;digits&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;contacts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Model call states explicitly
&lt;/h2&gt;

&lt;p&gt;The biggest source of bugs in a dialer is guessing call state. Make it an explicit state machine so a call can never jump from "ringing" straight to "ended" because of a stale UI banner.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CallState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;WAITING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;waiting&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;RINGING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ringing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;ON_CALL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on_call&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;VOICEMAIL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;voicemail&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;NO_ANSWER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no_answer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;ENDED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ended&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;ALLOWED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;CallState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WAITING&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;CallState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RINGING&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;CallState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RINGING&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;CallState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ON_CALL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CallState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VOICEMAIL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CallState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NO_ANSWER&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;CallState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ON_CALL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;CallState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VOICEMAIL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CallState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ENDED&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;CallState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VOICEMAIL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;CallState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ENDED&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CallState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CallState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;CallState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ALLOWED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Multi-line dialing
&lt;/h2&gt;

&lt;p&gt;Each line is an independent worker with its own browser session. A simple controller hands the next contact to whichever line is free. Predictive pacing adjusts how many lines dial at once based on how many calls are connecting, so agents aren't left idle or flooded.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;line_worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;contact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_nowait&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contact&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# returns a CallState
&lt;/span&gt;        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;contact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_campaign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;threads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;line_worker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;threads&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;threads&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Voicemail detection (AMD)
&lt;/h2&gt;

&lt;p&gt;Answering machine detection decides whether a human or a voicemail greeting picked up. In a browser-based dialer you combine two signals:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;UI cues&lt;/strong&gt; from the calling page (call timer started, voicemail indicators).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timing&lt;/strong&gt;: ignore everything during a short minimum ring window, and only classify voicemail after the call is connected.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When voicemail is confirmed, the dialer hangs up automatically (or plays a recorded message) and moves on. Test AMD carefully with real calls; the project ships a separate AMD and pacing test guide for exactly this reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Log every call
&lt;/h2&gt;

&lt;p&gt;Every attempt should end in a row you can audit: number, name, line, state, start/end time and duration. A local SQLite database is enough for a single team and doubles as a lightweight CRM.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;

&lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;crm.sqlite3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;CREATE TABLE IF NOT EXISTS calls
  (phone TEXT, name TEXT, line TEXT, result TEXT, started TEXT)&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;contact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO calls VALUES (?,?,?,?,?)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contact&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;contact&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Don't want to write it? Use a Chrome extension
&lt;/h2&gt;

&lt;p&gt;If your team already calls from Google Voice in the browser, you may not need a desktop app at all. I also built &lt;strong&gt;&lt;a href="https://chromewebstore.google.com/detail/gv-auto-dialer-by-indus-w/mhmfioegohbhejocblgpacabecelbkoi" rel="noopener noreferrer"&gt;GV Auto Dialer by INDUS Web Agency&lt;/a&gt;&lt;/strong&gt;, a Manifest V3 Chrome extension that imports CSV/XLSX contacts, auto-dials a queue with a configurable gap, and exports XLSX call reports. Related: &lt;a href="https://chromewebstore.google.com/detail/ipecs-auto-dialer-by-indu/jocjgmjjcmfdkcajfhblilmeamfkhahk" rel="noopener noreferrer"&gt;IPECS Auto Dialer&lt;/a&gt; for iPECS Cloud.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compliance first
&lt;/h2&gt;

&lt;p&gt;Auto dialers are regulated. In the US, the TCPA and Do Not Call rules apply; in the UK and EU, PECR, Ofcom guidance and GDPR apply. Get consent where required, respect calling hours and DNC lists, and check your phone provider's terms. Software guardrails help, but they are not legal compliance.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can you build an auto dialer in Python?&lt;/strong&gt;&lt;br&gt;
Yes. Python can import contact lists, drive phone lines through a browser (Selenium or Qt WebEngine) or a telephony API, detect call states and log results.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is AMD in an auto dialer?&lt;/strong&gt;&lt;br&gt;
AMD (answering machine detection) decides whether a person or a voicemail greeting answered, so the dialer can hang up or leave a message automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can a Python auto dialer use Google Voice?&lt;/strong&gt;&lt;br&gt;
Yes. The Indus Transport Auto Dialer runs a Google Voice session per line in an embedded browser. Check Google's terms for your use case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I import contacts from Excel into an auto dialer?&lt;/strong&gt;&lt;br&gt;
Read the sheet with pandas, find the phone column, normalise numbers to E.164 format and remove duplicates before dialing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where can I hire a remote auto dialer developer?&lt;/strong&gt;&lt;br&gt;
Muhammad Afzal Kalwar is a freelance auto dialer developer in Pakistan who builds custom Python and Google Voice dialers for US, UK and EU companies: &lt;a href="https://mafzalkalwardev.github.io/#hire-me" rel="noopener noreferrer"&gt;https://mafzalkalwardev.github.io/#hire-me&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is there a ready-made Google Voice auto dialer?&lt;/strong&gt;&lt;br&gt;
Yes: GV Auto Dialer by INDUS Web Agency on the Chrome Web Store.&lt;/p&gt;




&lt;h2&gt;
  
  
  About the author
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Muhammad Afzal Kalwar&lt;/strong&gt; is a Python automation developer in Islamabad, Pakistan, and founder of &lt;a href="https://www.induswebagency.com" rel="noopener noreferrer"&gt;INDUS Web Agency&lt;/a&gt;. He builds auto dialers, AI call agents, web scrapers and CRMs for logistics, sales and call-center teams in the US, UK and Europe, and has three auto-dialer Chrome extensions on the Chrome Web Store.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Looking to hire an auto dialer developer?&lt;/strong&gt; He works remotely with US, UK and EU companies, is available during UK mornings and US afternoons, and quotes fixed-price or hourly in USD, GBP or EUR.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hire me: &lt;a href="https://mafzalkalwardev.github.io/#hire-me" rel="noopener noreferrer"&gt;https://mafzalkalwardev.github.io/#hire-me&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/mafzalkalwardev" rel="noopener noreferrer"&gt;https://github.com/mafzalkalwardev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;All profiles: &lt;a href="https://linktr.ee/mafzalkalwardev" rel="noopener noreferrer"&gt;https://linktr.ee/mafzalkalwardev&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>automation</category>
      <category>tutorial</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Building CallAudit-X: An AI Call Auditing SaaS That Still Works Without an API Key</title>
      <dc:creator>Muhammad Afzal Kalwar</dc:creator>
      <pubDate>Fri, 30 Oct 2026 15:32:30 +0000</pubDate>
      <link>https://dev.to/mafzalkalwardev/building-callaudit-x-an-ai-call-auditing-saas-that-still-works-without-an-api-key-3o03</link>
      <guid>https://dev.to/mafzalkalwardev/building-callaudit-x-an-ai-call-auditing-saas-that-still-works-without-an-api-key-3o03</guid>
      <description>&lt;p&gt;In most call centers, QA means a manager listens to a small handful of calls and hopes they're representative. The rest go unreviewed.&lt;/p&gt;

&lt;p&gt;CallAudit-X is my attempt at a different setup: upload call recordings, get a transcript and a QA audit from AI, then let a human approve or correct the result. It's a SaaS-style app with customer and admin dashboards, analytics and billing.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/mafzalkalwardev/CallAudit-X" rel="noopener noreferrer"&gt;https://github.com/mafzalkalwardev/CallAudit-X&lt;/a&gt; (MIT licensed)&lt;/p&gt;

&lt;p&gt;This post covers the design decisions I care most about, based on what's in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;Next.js 14 App Router, React, TypeScript and Tailwind CSS on the front, Prisma with PostgreSQL for data, bcryptjs and jose JWT for auth, Recharts for dashboards, Stripe for billing, OpenAI for transcription and audits, and FormSubmit for password reset email.&lt;/p&gt;

&lt;p&gt;Nothing exotic. I wanted every moving part to be something a client's team could maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 1: AI only runs when you ask for it
&lt;/h2&gt;

&lt;p&gt;A lot of AI apps quietly call a model during page render. That's expensive, slow and hard to reason about. In CallAudit-X, OpenAI is &lt;strong&gt;never&lt;/strong&gt; called during page render. It only runs from an explicit upload-and-analyze path:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The customer uploads audio to &lt;code&gt;/api/calls/upload&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The customer triggers analysis through &lt;code&gt;/api/ai/analyze/[callId]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lib/ai/analyze-call.ts&lt;/code&gt; transcribes and audits with OpenAI, only when &lt;code&gt;OPENAI_API_KEY&lt;/code&gt; exists.&lt;/li&gt;
&lt;li&gt;If OpenAI is missing or fails, mock transcript and report data is generated.&lt;/li&gt;
&lt;li&gt;Completed reports are cached in the database and reused by pages.&lt;/li&gt;
&lt;li&gt;Reviewers approve the AI output or submit a corrected category and feedback.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 5 means a report is generated once and read many times. Step 6 is the part I think matters most, and I'll come back to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 2: a mock AI fallback
&lt;/h2&gt;

&lt;p&gt;The env file treats OpenAI as optional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OPENAI_API_KEY=""
OPENAI_TRANSCRIPTION_MODEL="gpt-4o-transcribe"
OPENAI_AUDIT_MODEL="gpt-4.1-mini"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without a key, upload and analyze still work with mock AI, so the app stays demo-ready. If the real call fails, the same fallback kicks in instead of leaving the user with a broken page.&lt;/p&gt;

&lt;p&gt;This came out of a practical problem. When you're showing software to a client, you don't want the demo to depend on a key, a quota or a network call behaving perfectly. The fallback lets the whole flow be walked through end to end, and the real model slots in when it's configured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 3: not every call is a conversation
&lt;/h2&gt;

&lt;p&gt;Anyone who has worked near outbound calling knows that a big share of "calls" never become conversations. CallAudit-X has explicit no-live-conversation handling for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;voicemail&lt;/li&gt;
&lt;li&gt;no answer&lt;/li&gt;
&lt;li&gt;mailbox full&lt;/li&gt;
&lt;li&gt;beep tone&lt;/li&gt;
&lt;li&gt;spam&lt;/li&gt;
&lt;li&gt;wrong number&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead of forcing a sales or support score onto a voicemail greeting, these get N/A handling. Scoring a 10-second beep as a "bad sales call" would make the analytics useless, so this is a correctness feature more than a nice-to-have.&lt;/p&gt;

&lt;p&gt;The transcript viewer is defensive in a similar way: it handles transcript data as a string, an array, an object or null. AI output and imported data don't always arrive in the shape you expect, and the UI shouldn't crash because of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 4: humans stay in the loop
&lt;/h2&gt;

&lt;p&gt;AI audits are a first pass, not a verdict. Reviewers can approve a report, or reject it and submit a corrected category and feedback. The demo flow in the README shows this on purpose: approve one report, then reject and correct another to show the feedback loop.&lt;/p&gt;

&lt;p&gt;The analytics page then shows DB-backed charts for category, sentiment, score and verification. Verification status being on the chart is deliberate. It tells you how much of what you're looking at has actually been checked by a person.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 5: boring, careful auth
&lt;/h2&gt;

&lt;p&gt;Auth uses real customer and admin logins with bcrypt password hashes and HTTP-only JWT cookies, with role-protected dashboards.&lt;/p&gt;

&lt;p&gt;The password reset flow is where I spent extra care:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/forgot-password&lt;/code&gt; always returns the same message ("If an account exists, a reset link has been generated."), so it can't be used to check which emails have accounts.&lt;/li&gt;
&lt;li&gt;When an account exists, the API creates a random token, stores only its &lt;strong&gt;SHA-256 hash&lt;/strong&gt;, sets a &lt;strong&gt;15-minute expiry&lt;/strong&gt; and logs the request.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;FORM_SUBMIT_ENABLED=true&lt;/code&gt;, the reset email goes out through FormSubmit.&lt;/li&gt;
&lt;li&gt;In development, if email is off or fails, the reset URL is shown for testing. In production, reset URLs are never returned in the API response.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/reset-password?token=...&lt;/code&gt; validates the hash and expiry, hashes the new password, clears the token, logs completion and sends the user back to login.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Admins also get a security log table alongside user CRUD, category management and a payments view.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 6: billing that fails safely
&lt;/h2&gt;

&lt;p&gt;The billing page shows the current plan, selected plan, usage, invoices, payment status and per-minute audit categories. The repo defines categories such as Sales QA, Support QA, Lead Qualification, Compliance Review, Appointment Review and Spam Detection, each with its own per-minute rate.&lt;/p&gt;

&lt;p&gt;If Stripe keys or real Price IDs are missing, checkout buttons stay disabled and say "Stripe checkout is not configured yet." Same idea as the AI fallback: a half-configured app should say what's missing, not throw.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it locally
&lt;/h2&gt;

&lt;p&gt;From the README:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install
&lt;/span&gt;npm run prisma:generate
npm run prisma:migrate
npm run prisma:seed
npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open &lt;code&gt;http://localhost:3000&lt;/code&gt;. The seed creates demo admin and customer accounts (listed in the README), and there's a &lt;code&gt;public/sample-call.wav&lt;/code&gt; you can upload from &lt;code&gt;/dashboard/upload&lt;/code&gt;. There's also a &lt;code&gt;/transcription-demo&lt;/code&gt; page that switches between a sales call, a support call and a voicemail/no-answer example.&lt;/p&gt;

&lt;p&gt;Two checks I run before shipping changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx prisma validate
npx tsc &lt;span class="nt"&gt;--noEmit&lt;/span&gt; &lt;span class="nt"&gt;--pretty&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="nt"&gt;--incremental&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Make AI calls explicit.&lt;/strong&gt; A button the user presses is easier to reason about, cache and bill than a hidden call on render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design for the missing key.&lt;/strong&gt; Fallbacks keep demos and dev environments useful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model the non-conversations.&lt;/strong&gt; Voicemail and no-answer calls deserve their own category, or your scores lie.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a human verification step,&lt;/strong&gt; and show how much has been verified.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail safely on payments and auth.&lt;/strong&gt; Disabled buttons with clear messages beat cryptic errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you run QA for a call team, I'm curious: what share of your calls actually gets reviewed today?&lt;/p&gt;




&lt;p&gt;Open to freelance &amp;amp; remote work&lt;br&gt;
Portfolio: &lt;a href="https://mafzalkalwardev.github.io/#hire-me" rel="noopener noreferrer"&gt;https://mafzalkalwardev.github.io/#hire-me&lt;/a&gt;&lt;br&gt;
GitHub: &lt;a href="https://github.com/mafzalkalwardev" rel="noopener noreferrer"&gt;https://github.com/mafzalkalwardev&lt;/a&gt;&lt;br&gt;
WhatsApp: +92 307 9670503 — &lt;a href="https://wa.me/923079670503" rel="noopener noreferrer"&gt;https://wa.me/923079670503&lt;/a&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>ai</category>
      <category>saas</category>
    </item>
    <item>
      <title>How My Google Voice AI Agent Knows When to Talk: Call-State Detection, Groq and TTS</title>
      <dc:creator>Muhammad Afzal Kalwar</dc:creator>
      <pubDate>Thu, 29 Oct 2026 22:34:41 +0000</pubDate>
      <link>https://dev.to/mafzalkalwardev/how-my-google-voice-ai-agent-knows-when-to-talk-call-state-detection-groq-and-tts-e5a</link>
      <guid>https://dev.to/mafzalkalwardev/how-my-google-voice-ai-agent-knows-when-to-talk-call-state-detection-groq-and-tts-e5a</guid>
      <description>&lt;p&gt;Most AI voice demos start with a telephony API. You get webhooks, clean call events and an audio stream, and the interesting problem is the conversation.&lt;/p&gt;

&lt;p&gt;My Google Voice Dispatch Agent doesn't get any of that. It drives Google Voice inside a real Chrome window, and that changes where the hard work is. The AI reply turned out to be the easy part. The hard part was knowing, with confidence, whether someone had actually picked up.&lt;/p&gt;

&lt;p&gt;This post walks through how the agent is put together, based on what's in the repo.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/mafzalkalwardev/google-voice-dispatch-agent" rel="noopener noreferrer"&gt;https://github.com/mafzalkalwardev/google-voice-dispatch-agent&lt;/a&gt; (MIT licensed)&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;In short, it's an outbound AI calling console for Windows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Selenium controls Google Voice using a persistent Chrome profile and dials contacts from a CSV/XLSX list or CRM data.&lt;/li&gt;
&lt;li&gt;It watches the call state: ringing, connected, ended, failed, voicemail.&lt;/li&gt;
&lt;li&gt;Once a call is really connected, a realtime loop runs: voice activity detection, speech-to-text, an LLM reply via Groq, and text-to-speech played back into the call.&lt;/li&gt;
&lt;li&gt;Voicemail is detected from DOM cues plus an early-call audio classifier, and the agent hands off to a pre-recorded voicemail message.&lt;/li&gt;
&lt;li&gt;Every call is logged with transcript, recording, outcome and CRM update.&lt;/li&gt;
&lt;li&gt;A FastAPI web console covers settings, preflight checks, audio tools, live runs, logs, recordings and leads. It's also packaged as a Windows EXE with PyInstaller.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The runtime flow
&lt;/h2&gt;

&lt;p&gt;The README describes the flow in ten steps, and I'll keep it close to that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Load &lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;dialer_config.json&lt;/code&gt;, contacts and CRM data.&lt;/li&gt;
&lt;li&gt;Run preflight checks for Groq, the Chrome profile, contacts, callback number and audio devices.&lt;/li&gt;
&lt;li&gt;Launch Google Voice with the configured Chrome profile.&lt;/li&gt;
&lt;li&gt;Prepare the opening line and voicemail audio &lt;strong&gt;before&lt;/strong&gt; dialing.&lt;/li&gt;
&lt;li&gt;Dial one contact.&lt;/li&gt;
&lt;li&gt;Poll &lt;code&gt;detect_call_state()&lt;/code&gt; until connected, voicemail, no answer, ended or failed.&lt;/li&gt;
&lt;li&gt;Start the realtime conversation loop only after confirmed connection.&lt;/li&gt;
&lt;li&gt;Watch for voicemail during the early connected window and hand off if confirmed.&lt;/li&gt;
&lt;li&gt;Save logs, transcript, recording, CRM update and call archive.&lt;/li&gt;
&lt;li&gt;Cool down, then move to the next contact.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 is small but matters a lot. If you generate the greeting after someone says "hello", they hear dead air and hang up. Pre-generating it means the agent can talk the moment the call is confirmed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Call-state detection: the real product
&lt;/h2&gt;

&lt;p&gt;Here's the thing about automating a web UI: the DOM is noisy. Buttons appear early, old "call ended" banners linger, and voicemail-looking cues can show up while the phone is still ringing. If you trust the first signal you see, the bot either talks over the ring tone or hangs up on a real person.&lt;/p&gt;

&lt;p&gt;So the core of the project is a conservative gate in &lt;code&gt;src/google_voice.py&lt;/code&gt;. The rules it enforces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ringing can't instantly jump to ended.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;min_ring_seconds&lt;/code&gt; has to pass before connected, ended or voicemail transitions are accepted.&lt;/li&gt;
&lt;li&gt;Active call controls that appear too early are logged, and the state is held at ringing.&lt;/li&gt;
&lt;li&gt;Stale ended banners are ignored during the minimum ringing window.&lt;/li&gt;
&lt;li&gt;Voicemail cues are ignored while still ringing.&lt;/li&gt;
&lt;li&gt;Connected needs stable evidence, like answered controls or a call timer.&lt;/li&gt;
&lt;li&gt;The AI opening line starts only after confirmed connection.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;max_ring_seconds&lt;/code&gt; ends the attempt as no answer only after the ring timeout.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every decision is logged too: current state, elapsed ring time, DOM cues, answered controls, call-active status, voicemail cues, the audio classifier result and the timeout reason. When a call goes wrong, I can read exactly why the agent thought what it thought.&lt;/p&gt;

&lt;p&gt;The design principles in the README sum it up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never speak until the call is actually connected.&lt;/li&gt;
&lt;li&gt;Never treat immediate DOM noise as a completed call.&lt;/li&gt;
&lt;li&gt;Respect a minimum ringing duration.&lt;/li&gt;
&lt;li&gt;Detect voicemail only after connected/greeting evidence.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The conversation loop: VAD, Groq and TTS
&lt;/h2&gt;

&lt;p&gt;Once the gate says "connected", &lt;code&gt;conversation_loop.py&lt;/code&gt; takes over. The modules split up cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/
  google_voice.py          Selenium Google Voice automation and call-state detection
  call_session.py          Call session model and CallState transitions
  conversation_loop.py     Realtime capture, VAD, STT, LLM, TTS, voicemail handoff
  voicemail_detector.py    Audio classifier for greeting/beep/live-call signals
  ai_groq.py               Groq-backed AI response behavior
  realtime_tts.py          Edge TTS playback and cache integration
  audio_capture.py         Input capture and VAD framing
  audio_routing.py         Windows audio-device discovery and playback routing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loop works like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Capture and VAD.&lt;/strong&gt; &lt;code&gt;audio_capture.py&lt;/code&gt; captures the caller's audio and frames it for voice activity detection, so the agent knows when the person has started and stopped talking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speech-to-text and reply.&lt;/strong&gt; The speech goes to Groq STT, and &lt;code&gt;ai_groq.py&lt;/code&gt; generates the reply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text-to-speech.&lt;/strong&gt; &lt;code&gt;realtime_tts.py&lt;/code&gt; speaks it with Edge TTS, with caching, and pyttsx3 as a fallback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routing.&lt;/strong&gt; The voice has to get &lt;em&gt;into&lt;/em&gt; the browser call, which is where VB-CABLE comes in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Several knobs in &lt;code&gt;dialer_config.json&lt;/code&gt; relate directly to this loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"min_ring_seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"max_ring_seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"voicemail_detect_seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"silence_does_not_end_call"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"call_cooldown_seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tts_warmup"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"stt_retry_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vad_silence_frames"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vad_speech_frames"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;silence_does_not_end_call&lt;/code&gt; is a nice example of a lesson baked into config: people go quiet on the phone, and that shouldn't be read as a hang-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audio routing is half the battle
&lt;/h2&gt;

&lt;p&gt;The minimum VB-CABLE setup from the README:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Agent playback device: &lt;code&gt;CABLE Input&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Chrome microphone input: &lt;code&gt;CABLE Output&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Chrome speaker output: a device the agent can capture&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For unattended runs, the README recommends separate routes for TTS and inbound capture, and warns against capturing the same speaker output that plays the agent's voice. Otherwise the agent can end up transcribing itself. &lt;code&gt;CAPTURE_DEVICE=default&lt;/code&gt; uses Windows speaker loopback, which is fine for testing but less reliable for nonstop calling.&lt;/p&gt;

&lt;p&gt;There are CLI helpers for checking this before going live:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;python&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-m&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;src.main&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--list-audio-devices&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;python&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-m&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;src.main&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--audio-route-test&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;python&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-m&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;src.main&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--preflight&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Parallel lines without crossed wires
&lt;/h2&gt;

&lt;p&gt;The agent can also run more than one line. Each worker gets its own Chrome profile and pulls from one shared contact queue. The important safety rule: shared audio routes are rejected by default, "because one AI response would otherwise be broadcast into multiple calls." Each slot needs its own loopback and capture pair, like &lt;code&gt;CABLE-A&lt;/code&gt; and &lt;code&gt;CABLE-B&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Google Voice accounts can be managed from the console. Saved passwords are protected with Windows DPAPI and never returned by the web API, and MFA or CAPTCHA challenges still have to be completed by a human in the visible Chrome window.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone building something similar
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Treat state detection as the main feature.&lt;/strong&gt; Spend your time there before tuning prompts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log your reasoning, not just outcomes.&lt;/strong&gt; "Connected" is useless when debugging. "Connected because the timer appeared at 6.2s after ringing" is useful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prepare audio ahead of time.&lt;/strong&gt; Silence at pickup kills calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolate audio per line.&lt;/strong&gt; Crossed audio is far worse than a slower dialer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add preflight checks.&lt;/strong&gt; Most failed runs I saw were config problems, not code bugs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And please: anything that dials real people needs consent, Do Not Call compliance and call-recording disclosure where required. The README also recommends keeping &lt;code&gt;min_ring_seconds&lt;/code&gt; above zero, using a cooldown, and not rapid-firing failed numbers.&lt;/p&gt;

&lt;p&gt;If you're working on voice agents on top of tools you already use, I'd like to hear what broke first for you.&lt;/p&gt;




&lt;p&gt;Open to freelance &amp;amp; remote work&lt;br&gt;
Portfolio: &lt;a href="https://mafzalkalwardev.github.io/#hire-me" rel="noopener noreferrer"&gt;https://mafzalkalwardev.github.io/#hire-me&lt;/a&gt;&lt;br&gt;
GitHub: &lt;a href="https://github.com/mafzalkalwardev" rel="noopener noreferrer"&gt;https://github.com/mafzalkalwardev&lt;/a&gt;&lt;br&gt;
WhatsApp: +92 307 9670503 — &lt;a href="https://wa.me/923079670503" rel="noopener noreferrer"&gt;https://wa.me/923079670503&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>automation</category>
      <category>fastapi</category>
    </item>
  </channel>
</rss>
