ChanlChanl
Voice & Conversation

How to Build a Voice BDR That Qualifies (BANT as a State Machine)

Most AI SDRs ask 'tell me about your business' and dump benefits. Here's how to encode BANT as tool calls, score live, write to Salesforce, and exit politely.

DGDean GroverCo-founderFollow
April 30, 2026
11 min read read
Solo operator at a dusk co-working desk, headset on, four monitors showing a calm waveform, a CRM record, and a qualification rubric filling in field by field, terra cotta palette.

There is a moment in a bad outbound voice call that every operator has heard. The agent says "tell me about your business," the prospect gives a sentence, and the agent answers with thirty seconds of features. The prospect says "I'm not really sure that's what we need" and the agent says "I understand. Tell me about your business." The call dies on the next breath.

This is the AI SDR pattern that took $111M of venture money in the last twelve months. Artisan raised $25M Series A in April 2025 to scale Ava, an outbound BDR. 11x raised $76M across Benchmark and a16z and shipped a phone agent called Julian in May 2025. Aircall launched its outbound AI virtual agent in April 2026 with native Salesforce and HubSpot logging. The category is hot. The calls are not good.

The fix is not a better model. The fix is to stop treating discovery as a monologue and start treating it as a state machine. BANT and MEDDIC are not vibes. They are four to six structured fields that decide whether the deal moves forward. Encode them as tool calls, score them deterministically, write them to the CRM, and exit politely when the lead is wrong-fit. That is what a real BDR does on every call. Below is how to build the same thing in code.

Three Ways to Encode the Script

You have three options for getting the LLM to actually run the script. Two of them lose.

The first is prompt-only. You drop "ask about budget, then authority, then need, then timeline" into the system prompt and pray. This works on the first ten calls, drifts on the next ten, and falls apart entirely the moment the prospect goes off-script. There is no mechanism to know whether budget was actually captured, only whether the LLM thinks it was.

The second is a graph framework like LangGraph, where each axis is a node and the model returns a Command object that routes to the next node. LangGraph models agent workflows as state machines with conditional edges, which gets you explicit transitions and persistent state. The downside for voice is that hop-by-hop graph traversal adds an LLM call to every transition, and on a live call your latency budget is sub-1s end-to-end. You spend it before the prospect hears the question.

The third is Pipecat's function-call processor. You declare one function per BANT axis. The LLM is free to call them in any order as the conversation unfolds, and Pipecat's context aggregator stores the results in conversation context automatically. Pipecat Flows extends this with FlowsFunctionSchema for structured handler-and-transition logic if you want explicit branching. The win is that each axis becomes structured data the moment the LLM hears it, and a deterministic scoring function can run over that data without another LLM round trip. (We compared Pipecat against the alternatives in Pipecat vs LiveKit if you're still picking a voice runtime.)

Option three is what real BDR tools converge on. Aircall's outbound agent qualifies on BANT or MEDDIC and auto-logs every call with structured tags into Salesforce and HubSpot. The qualification axes are first-class data, not summaries.

The Bare-Minimum Pipeline

Start with the smallest possible voice loop. Pipecat 0.0.106 with Daily transport is the production default for sub-1s round trips. Deepgram for STT, GPT-5 mini for the LLM, ElevenLabs for TTS. The full pipeline is under thirty lines.

bdr_pipeline.py·python
import os
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineTask
from pipecat.services.deepgram import DeepgramSTTService
from pipecat.services.openai import OpenAILLMService
from pipecat.services.elevenlabs import ElevenLabsTTSService
from pipecat.transports.services.daily import DailyTransport, DailyParams
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
 
transport = DailyTransport(
    room_url, token, "BDR Agent",
    DailyParams(audio_in_enabled=True, audio_out_enabled=True),
)
stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY"))
tts = ElevenLabsTTSService(api_key=os.getenv("ELEVENLABS_API_KEY"), voice_id="...")
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-5-mini")
 
llm.register_function("record_budget", record_budget)
llm.register_function("record_authority", record_authority)
llm.register_function("record_need", record_need)
llm.register_function("record_timeline", record_timeline)
llm.register_function("evaluate_qualification", evaluate_qualification)
 
context = OpenAILLMContext(messages=[SYSTEM_PROMPT], tools=BANT_TOOLS)
pipeline = Pipeline([transport.input(), stt, llm, tts, transport.output()])
runner = PipelineRunner()
await runner.run(PipelineTask(pipeline))

This is the skeleton. The interesting work is in the four record_* handlers and the scoring function they write into. The handlers below are written in TypeScript so the same shapes can move into a Node post-call worker and a CRM SDK without re-typing them. The Python pipeline registers the handler names; the schemas live in the layer that writes back to Salesforce. If the call drops mid-discovery, Pipecat lets you re-attach the LLM context object on reconnect rather than re-running the whole greeting and re-asking for budget.

Four record_* Tools, One Per Axis

Each BANT axis becomes a tool with a tight Zod schema. The agent does not get to write prose into the qualification state. It writes typed values, which means you can score them and you can show them to humans without an LLM in the loop.

bant-tools.ts·typescript
import { z } from "zod";
 
export const recordBudget = {
  name: "record_budget",
  description: "Capture the prospect's budget signal. Call this when budget is disclosed, mentioned, or refused.",
  inputSchema: z.object({
    callId: z.string(),
    status: z.enum(["disclosed", "range_only", "no_budget", "refused", "unknown"]),
    annualValueUsd: z.number().nullable(),
    confidence: z.enum(["high", "medium", "low"]),
    quote: z.string().describe("The prospect's exact words"),
  }),
};
 
export const recordAuthority = {
  name: "record_authority",
  description: "Capture the prospect's role and decision power.",
  inputSchema: z.object({
    callId: z.string(),
    role: z.string(),
    isDecisionMaker: z.boolean(),
    requiresChampion: z.boolean(),
    economicBuyer: z.string().nullable(),
  }),
};
 
export const recordNeed = {
  name: "record_need",
  description: "Capture the pain or use case the prospect described.",
  inputSchema: z.object({
    callId: z.string(),
    painPoint: z.string(),
    currentSolution: z.string().nullable(),
    quantifiedImpact: z.string().nullable(),
  }),
};
 
export const recordTimeline = {
  name: "record_timeline",
  description: "Capture purchase timeline.",
  inputSchema: z.object({
    callId: z.string(),
    horizon: z.enum(["this_quarter", "next_quarter", "this_year", "exploratory", "no_timeline"]),
    triggerEvent: z.string().nullable(),
  }),
};

Notice what is not here. There is no record_summary and no record_objection field that lets the LLM write paragraphs. Every shape is closed. When the model wants to capture something it cannot fit into a schema, it has to decide which axis the signal belongs to. That is the same discipline a human BDR has when filling out a Salesforce field.

Live Scoring as a Deterministic Tool

Once an axis is captured, score it. Not with another LLM call. With a function. The LLM's only job is to look at the result and pick the next branch.

evaluate-qualification.ts·typescript
type QualState = {
  budget?: { status: string; annualValueUsd: number | null };
  authority?: { isDecisionMaker: boolean; requiresChampion: boolean };
  need?: { painPoint: string };
  timeline?: { horizon: string };
};
 
export function evaluateQualification(state: QualState):
  "qualified" | "nurture" | "disqualified" | "wrong_fit" {
  const hasNeed = !!state.need?.painPoint;
  const hasTimeline = state.timeline?.horizon === "this_quarter"
    || state.timeline?.horizon === "next_quarter";
  const hasBudget = state.budget?.status === "disclosed"
    || state.budget?.status === "range_only";
  const hasAccess = state.authority?.isDecisionMaker
    || state.authority?.requiresChampion;
 
  if (state.budget?.status === "no_budget" && !hasTimeline) return "wrong_fit";
  if (hasNeed && hasTimeline && hasBudget && hasAccess) return "qualified";
  if (hasNeed && (hasTimeline || hasBudget)) return "nurture";
  return "disqualified";
}

The agent's system prompt has four short branches, one per result. On qualified it offers a calendar slot. On nurture it asks for permission to follow up next quarter. On disqualified it thanks the prospect and ends. On wrong_fit it skips straight to the polite exit. No prose summarization, no inference about whether the deal is hot. The score is a function of what was captured, full stop.

Conversation analyst reviewing data

Sentiment Analysis

Last 7 days

Positive 68%
Neutral 24%
Negative 8%
Top Topics
Billing342
Support281
Onboarding197
Upgrade156

This is also where you stop double-coding the rules. Whatever rubric scores the live call is the same rubric that grades the recording afterward, which is the whole point of scorecards over vibes. If you change the threshold for "qualified" you change one function, not two.

The Polite Exit, and Why It's a Compliance Gate

Disqualified is not just a routing label. It is a contract with the prospect. The FCC confirmed in February 2024 that TCPA's restrictions on artificial or prerecorded voice extend to AI-generated voices, and consumers can revoke consent in any reasonable form including stop, quit, end, revoke, opt-out, cancel, or unsubscribe. The agent must honor revocation as soon as practicable and no later than 10 business days. The exit needs three states.

Soft no, e.g. not now Hard no, e.g. not interested Stop calling, opt-out, hostile Prospect signal Type? Ask permission to follow up next quarter Acknowledge, confirm, end call Add to DNC, end call immediately, log opt-out record_timeline + close record_disqualified + close dnc.add + close + audit log
Three exit states: soft-no continues with consent, hard-no ends and logs, hostile ends immediately and writes DNC.

There are also state-level gates the federal rules don't cover. California AB 1836 extends publicity rights to voice replicas with damages up to $10,000 per violation when consent is missing. Florida's rules require AI disclosure and prohibit calls before 8am or after 9pm local time. The 11 states with their own DNC lists must be checked alongside the federal list. The FCC's one-to-one consent rule, vacated by the Eleventh Circuit in early 2025 and still being relitigated, is the direction the regulatory wind is blowing: the consent record naming your specific seller is the safe default to build against, regardless of which version of the rule survives appeal. Shared lead-gen consent records will not age well.

In practice this turns into a dialer guard that runs before every call.

tcpa-guard.ts·typescript
export async function canDial(lead: Lead, sellerId: string): Promise<DialDecision> {
  const local = toLocalTime(new Date(), lead.timezone);
  if (local.hour < 8 || local.hour >= 21) {
    return { allowed: false, reason: "outside_quiet_hours" };
  }
  if (await dnc.isOnFederalList(lead.phone)) {
    return { allowed: false, reason: "federal_dnc" };
  }
  if (await dnc.isOnStateList(lead.phone, lead.state)) {
    return { allowed: false, reason: "state_dnc" };
  }
  if (!(await consent.hasOneToOne(lead.phone, sellerId))) {
    return { allowed: false, reason: "no_one_to_one_consent" };
  }
  return { allowed: true, requiredDisclosure: AI_DISCLOSURE_LINE };
}

The agent's first utterance, after the prospect picks up, has to include the AI disclosure. "Hi, this is Ava calling from Acme. I'm an automated assistant, do you have two minutes?" That single sentence is what keeps a pilot alive when compliance reviews the recording.

CRM Write-Back Without the Latency Tax

The moment the agent says goodbye, the qualification state is a structured object. Write it to Salesforce or HubSpot from a post-call worker, never from the live turn. A sobject('Opportunity').create() call takes 200-600ms which is half your conversational latency budget.

crm-writeback.ts·typescript
import jsforce from "jsforce";
import { Client as HubSpot } from "@hubspot/api-client";
 
export async function writeOpportunity(call: CompletedCall) {
  const sf = new jsforce.Connection({ accessToken: process.env.SF_TOKEN });
  const score = evaluateQualification(call.qualificationState);
 
  if (score === "wrong_fit" || score === "disqualified") {
    await sf.sobject("Lead").update({
      Id: call.leadId,
      Status: "Disqualified",
      DisqualificationReason__c: score,
    });
    return;
  }
 
  await sf.sobject("Opportunity").create({
    AccountId: call.accountId,
    Name: `${call.companyName} - ${score}`,
    StageName: score === "qualified" ? "Qualified" : "Nurture",
    Amount: call.qualificationState.budget?.annualValueUsd ?? null,
    CloseDate: timelineToCloseDate(call.qualificationState.timeline?.horizon),
    Metrics__c: call.qualificationState.need?.quantifiedImpact ?? null,
    EconomicBuyer__c: call.qualificationState.authority?.economicBuyer ?? null,
    DecisionProcess__c: serializeAuthority(call.qualificationState.authority),
    IdentifiedPain__c: call.qualificationState.need?.painPoint ?? null,
  });
}

The schema map is doing the work. BANT axes captured on the call become MEDDIC fields on the opportunity. Same data, two vocabularies, one pass. HubSpot users substitute hubspotClient.crm.deals.basicApi.create({ properties: {...} }) and the rest of the shape is identical.

What the Pipeline Above Doesn't Give You

The Pipecat pipeline ships. What it doesn't tell you is whether the agent is actually qualifying or just sounding fluent on the recording. The gap between those two states is where pilots die.

Three things close it. Replay the agent against fifty personas before you point it at real numbers, so the script breaks in a scenario sandbox rather than on a live call. Reuse the same qualification rubric for the live score and the post-call grade, so sales and engineering aren't arguing about whether the call counts. Initiate calls programmatically and pull the qualification result back when they finish, so the CRM gets the same answer the dialer logged. That is the part where building it yourself stops being free.

The Same Axes, One Runtime

The four BANT tools above are exactly the kind of HTTP tool a Chanl agent executes on every voice call without re-deploying the bot. You register the schema once, attach a toolset, and the same record_budget runs on every call. The qualification state lives in the platform, not in the bot process, which matters the moment you have more than one campaign. For a deeper look at the sales shape this targets, the same record-axis discipline applies to discovery, save desks, and renewals.

chanl-bdr-setup.ts·typescript
import { ChanlClient } from "@chanl/sdk";
 
const sdk = new ChanlClient({ apiKey: process.env.CHANL_API_KEY });
 
const budget = await sdk.tools.create({
  name: "record_budget",
  type: "http",
  inputSchema: { /* same Zod shape as above */ },
});
const authority = await sdk.tools.create({ name: "record_authority", /* ... */ });
const need = await sdk.tools.create({ name: "record_need", /* ... */ });
const timeline = await sdk.tools.create({ name: "record_timeline", /* ... */ });
 
const toolset = await sdk.toolsets.create({
  name: "bdr-qualification",
  toolIds: [budget.id, authority.id, need.id, timeline.id],
});
 
await sdk.scenarios.run(scenarioId, {
  agentId: outboundAgentId,
  simulationMode: "text",
});
 
const call = await sdk.calls.initiate({
  to: lead.phone,
  agentId: outboundAgentId,
  metadata: { campaignId, sellerId },
});
const scorecard = await sdk.scorecards.getByCallId(call.id);

The persona run happens against sdk.scenarios.run before any real number is dialed. Replay the agent against fifty personality variants (over-budget, no-authority, wrong-timeline, hostile, distracted) and watch which ones break the script. sdk.scenarios.runAll() runs the full library in one shot for regression. After the live call, sdk.scorecards.getByCallId(call.id) returns the same axes you recorded, so the rubric you scored live is the rubric that grades the recording. You don't double-code the rules and you don't argue with sales about whether the call qualified.

A few honest gaps. The TCPA-aware dialer guard above is something we think sdk.calls.initiate should enforce natively, including DNC and quiet-hour rejection. Today you wrap it. Per-axis weights and confidence on scorecards are also coming, because a flat rubric scores a budget answer from six months ago the same as a budget answer from today. And a prebuilt salesforce.upsertLead and hubspot.createDeal tool template should ship in every workspace so nobody is writing jsforce glue in 2026. Those are real product gaps. We're shipping them.

Back to the bad call. The reason the agent answered "tell me about your business" twice is that nothing in its loop knew what got captured the first time. Four typed fields, a scoring function, and a polite exit fix that. It's not a prompt-engineering problem. It's a state problem. Build the state, and the script runs itself.

Ship a discovery agent that actually qualifies

Run the same BANT rubric live and post-call. Replay fifty personas before you dial real prospects.

Explore Scorecards
DG

Co-founder

Building the platform for AI agents at Chanl — tools, testing, and observability for customer experience.

The Signal Briefing

One email a week. How leading CS, revenue, and AI teams are turning conversations into decisions. Benchmarks, playbooks, and what's working in production.

500+ CS and revenue leaders subscribed

Frequently Asked Questions