Skip to main content
Founder Notes · Build Logs

How I Built an AI Voice Agent That Makes Sales Calls Autonomously

May 8, 2026
12 min read
Akhil Paswan
How I Built an AI Voice Agent That Makes Sales Calls Autonomously — Quick Comet

The short version: Most AI demos stop at "look, it responds to text." I needed an AI that picks up the phone, has a real conversation, qualifies leads, and updates my CRM — without me touching anything. Here is how I built it with ElevenLabs, Twilio, and Node.js, and the specific gotchas that cost me hours so they don't cost you any.

What we're building

An AI voice agent that:

  • Makes outbound sales calls to business prospects
  • Handles natural conversation with objection handling
  • Qualifies leads based on conversation signals
  • Updates a CRM database after each call
  • Sends a summary to Telegram for monitoring

This is the same architecture behind Maya, the voice agent that powers our AI receptionist service. Built it for our own outreach first, then productized.

The architecture

Twilio (phone)  →  ElevenLabs (voice AI)  →  Webhook (Node.js)  →  CRM (SQLite)  →  Telegram (notification)

Twilio handles telephony. ElevenLabs handles the actual voice conversation — speech-to-text, LLM reasoning, and text-to-speech in real time. Our Node.js server processes the post-call webhook and updates the database.

Definition — Conversational AI: An LLM-powered system that handles full duplex voice conversations: real-time speech recognition, contextual reasoning, and natural-sounding speech synthesis, all with sub-second latency. Different from a voice assistant (Siri-like one-turn Q&A) and from a chatbot (text-only).

Step 1: Set up the voice agent on ElevenLabs

ElevenLabs provides a Conversational AI API that handles the entire voice pipeline. You configure an agent with a system prompt, knowledge base, and voice settings.

// update-agent.js
import 'dotenv/config';

const AGENT_ID = process.env.ELEVENLABS_AGENT_ID;
const API_KEY = process.env.ELEVENLABS_API_KEY;

const agentConfig = {
  conversation_config: {
    agent: {
      prompt: {
        prompt: `You are Maya, a professional business development representative for QuickComet,
an AI solutions agency. You're calling to introduce our services.

RULES:
- Be warm, professional, and concise
- Ask about their current website and digital presence
- Listen for pain points (outdated website, no online ordering, etc.)
- If interested, offer to schedule a follow-up call
- If not interested, thank them and end graciously
- Never be pushy or aggressive
- Disclose that you're an AI assistant when asked`,
      },
      first_message: "Hi, this is Maya from QuickComet. Do you have a quick moment?",
      language: "en",
    },
    tts: {
      voice_id: "your-voice-id-here",
      model_id: "eleven_turbo_v2_5",
      output_format: "ulaw_8000", // Required for Twilio
    },
    stt: {
      model_id: "nova-2",
      input_format: "ulaw_8000", // Must match TTS format for Twilio
    },
    turn: {
      turn_timeout: 7,
      silence_end_call_timeout: 15,
    },
  },
  platform_settings: {
    max_duration_seconds: 300,
  },
};

async function updateAgent() {
  const response = await fetch(
    `https://api.elevenlabs.io/v1/convai/agents/${AGENT_ID}`,
    {
      method: 'PATCH',
      headers: {
        'xi-api-key': API_KEY,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(agentConfig),
    }
  );

  const result = await response.json();
  console.log('Agent updated:', result.agent_id);
}

updateAgent();

Critical detail: Both TTS and STT must use ulaw_8000 format for Twilio compatibility. If these don't match, you'll get garbled audio or silence. I lost hours debugging this before catching it — save yourself the same.

Step 2: Connect Twilio to ElevenLabs

When Twilio receives or makes a call, it needs to connect the audio stream to ElevenLabs. I use a Twilio Function (serverless) for this:

// twilio-function/call-webhook.js
exports.handler = async function(context, event, callback) {
  const AGENT_ID = context.ELEVENLABS_AGENT_ID;

  // After the call ends, ElevenLabs sends conversation data here
  if (event.conversation_id) {
    const summary = {
      conversationId: event.conversation_id,
      duration: event.duration,
      transcript: event.transcript,
      outcome: event.metadata?.outcome || 'unknown',
    };

    // Send to Telegram for monitoring
    await fetch(`https://api.telegram.org/bot${context.TELEGRAM_TOKEN}/sendMessage`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        chat_id: context.OWNER_CHAT_ID,
        text: `Call completed:\n${summary.transcript?.substring(0, 500)}`,
      }),
    });

    return callback(null, { success: true });
  }

  // Initial call setup — return TwiML to connect to ElevenLabs
  const twiml = new Twilio.twiml.VoiceResponse();
  const connect = twiml.connect();
  connect.conversationRelay({
    url: `wss://api.elevenlabs.io/v1/convai/conversation?agent_id=${AGENT_ID}`,
  });

  return callback(null, twiml);
};

Step 3: Build the call manager

This is the orchestrator — it picks a prospect from the database, initiates the call through Twilio, and handles the result:

// call-manager.js
import twilio from 'twilio';
import Database from 'better-sqlite3';

const client = twilio(process.env.TWILIO_SID, process.env.TWILIO_AUTH);
const db = new Database('quickcomet-crm.db');

export async function makeCall(prospectId) {
  const prospect = db.prepare(
    'SELECT * FROM prospects WHERE id = ?'
  ).get(prospectId);

  if (!prospect?.phone) {
    throw new Error(`Prospect ${prospectId} has no phone number`);
  }

  db.prepare(
    'UPDATE prospects SET status = ? WHERE id = ?'
  ).run('calling', prospectId);

  const call = await client.calls.create({
    to: prospect.phone,
    from: process.env.TWILIO_PHONE,
    url: process.env.TWILIO_WEBHOOK_URL,
    statusCallback: process.env.TWILIO_STATUS_URL,
    statusCallbackEvent: ['completed', 'no-answer', 'busy', 'failed'],
    machineDetection: 'Enable',
    timeout: 30,
  });

  db.prepare(`
    INSERT INTO call_logs (prospect_id, call_sid, direction, status)
    VALUES (?, ?, 'outbound', 'initiated')
  `).run(prospectId, call.sid);

  return call.sid;
}

Step 4: Automated call queue

The call queue runs daily, picks the top prospects by quality score, and calls them during business hours:

// call-queue.js (simplified)
const CONFIG = {
  maxCallsPerDay: 5,
  minQualityScore: 9,
  callDelayMs: 60_000, // 1 minute between calls
  preferredHours: { start: 9, end: 17 },
};

function getCallableProspects(limit) {
  return db.prepare(`
    SELECT id, business_name, phone, quality_score
    FROM prospects
    WHERE quality_score >= ?
      AND phone IS NOT NULL
      AND status NOT IN ('declined', 'replied')
    ORDER BY quality_score DESC
    LIMIT ?
  `).all(CONFIG.minQualityScore, limit);
}

async function runQueue() {
  const hour = new Date().getHours();
  if (hour < CONFIG.preferredHours.start || hour >= CONFIG.preferredHours.end) {
    console.log('Outside business hours. Skipping.');
    return;
  }

  const prospects = getCallableProspects(CONFIG.maxCallsPerDay);

  for (const prospect of prospects) {
    console.log(`Calling: ${prospect.business_name} (${prospect.phone})`);
    await makeCall(prospect.id);
    await new Promise(r => setTimeout(r, CONFIG.callDelayMs));
  }
}

Results and lessons learned

After 270+ simulated calls and ~30 real calls across 78 industries:

  • Voice quality matters more than prompt quality. A natural-sounding voice with proper pacing converts better than a perfect script with a robotic voice. Spend the budget on a good voice clone before optimizing the prompt.
  • ulaw_8000 for BOTH TTS and STT is non-negotiable with Twilio. I lost hours debugging garbled audio before catching this.
  • Business type determines best call time. Restaurants: 2–4 PM (between rushes). Lawyers: 9–11 AM. Default: 10 AM – 5 PM. Hardcoding this into the call queue improved pickup rates ~30%.
  • Silence detection kills calls. Set silence_end_call_timeout to at least 15 seconds — some people take time to think before responding. Anything shorter and you cut off thinkers.
  • Always disclose AI. It is the right thing to do, many jurisdictions require it, and it does not hurt conversion. It actually disarms callers.
The real failure mode: The first version of Maya talked at people instead of with them. She delivered the opener, then didn't respond. Turned out to be a misconfigured dynamic_variable_placeholders field — the Gemini backend received literal "{{business_name}}" in the prompt and froze. Lesson learned: read the docs first.

What I'd do differently

  1. Start with inbound calls first. It is easier to handle people calling you (they are already interested) than cold outbound. We did outbound first because that was the use case driving us, but in retrospect inbound would have caught half the bugs at lower stakes.
  2. Build the CRM integration from day one. Don't bolt it on later — every call should log to a database automatically. Otherwise you have great conversations and no idea what happened in any of them.
  3. Limit prompt length to under 10,000 characters. Longer prompts add latency, and in voice conversations, latency kills the illusion of natural conversation. Move detail into a knowledge base (RAG) where the model retrieves only what is relevant per turn.

Frequently Asked Questions

For a single-purpose voice agent (one prompt, one industry), 2–3 days of focused build time gets you a working prototype. Adding CRM integration, post-call processing, and a call queue takes another 3–5 days. Production-hardening (error handling, voicemail detection, compliance disclosures) is another week. Plan on ~2–3 weeks for a system you would put in front of real prospects.

Twilio streams audio as ulaw_8000 in both directions. If your TTS outputs a different sample rate or codec (e.g. PCM 16kHz), Twilio downsamples on the way in but cannot re-encode the agent's response cleanly on the way back out. The result is garbled audio, silence, or dropped calls. Set both TTS and STT to ulaw_8000 in the ElevenLabs agent config and it just works.

At May 2026 pricing: ElevenLabs conversational AI runs roughly $0.10–0.15 per minute of conversation. Twilio adds ~$0.013/min for the phone connection. A 3-minute discovery call lands around $0.40 in pure variable cost. You can expect $200–600/month in LLM + telephony costs for a small agent handling 50–150 calls/day.

Use ElevenLabs unless you have a specific reason not to. Building your own pipeline (Deepgram + GPT/Claude + ElevenLabs TTS or similar) gives you more control but adds weeks of work managing turn-taking, interruption handling, and latency. ElevenLabs handles all of that natively. Roll your own only if you need a custom LLM provider or sub-200ms latency on specific paths.

Twilio's machineDetection feature flags answering machines on the inbound webhook. Pair that with ElevenLabs' voicemail_detection system tool which leaves a pre-recorded message and ends the call cleanly. Without machine detection, the agent talks to a beep and burns LLM tokens recording its own monologue.

In most U.S. states, yes — and even where it is not strictly required, it is the right thing to do. California, Maryland, and Florida have explicit disclosure laws. The clean pattern is to bake the disclosure into the agent's prompt: "I should mention I'm an AI assistant" if the caller asks who they are speaking to, or once at the start of the call for outbound. It does not hurt conversion and it eliminates legal risk.

Using a 12,000-character system prompt. Long prompts add latency on every turn — and in voice conversations, latency is the difference between feeling natural and feeling robotic. Keep prompts under 8,000 characters. Move detail into the agent's knowledge base (RAG) where the model only retrieves what is relevant per turn.

Bottom line

The hard parts of an AI voice agent aren't the LLM or the voice — those are commodity now. The hard parts are: matching audio formats end-to-end, handling silence, building the CRM glue, and disclosing AI properly. Get those right and the rest is a weekend.

Building a voice agent for your business and stuck on one of these gotchas? Email hello@quickcomet.com — happy to compare notes.

Akhil Paswan

Akhil Paswan

Founder, Quick Comet

Akhil ships every Quick Comet project personally from Stockton, CA. He has built voice agents, RAG systems, multi-tenant SaaS platforms, and a self-evolving CRM — all with Claude Code, Next.js, and Supabase.