← Back to list

Building a Concurrent AI Call Center for Field Service Operations — VAPI, Idempotency, and What…

VAPI, caller identity resolution, idempotency keys, TCPA compliance, and a closed operational loop — built in production for a real field…

Carlos Alberto Pena Molina · 2026-06-10 22:08 · 0 claps · 10.8 min read
#software-development #artificial-intelligence #voice-ai #saas #field-service-management
Open on Medium ↗
Wiki topics: AI · AI · General BIZ · Business Strategy

Building a Concurrent AI Call Center for Field Service Operations — VAPI, Idempotency, and What $147M in Funding Still Hasn’t Solved

VAPI, caller identity resolution, idempotency keys, TCPA compliance, and a closed operational loop — built in production for a real field service business

By Carlos Alberto Pena Molina — Founder & AI Systems Architect, Runox

Week 22 of building Runox, I shipped the AI Call Center module.

Not a chatbot. Not a “press 1 for scheduling” IVR. A fully concurrent, outbound AI voice system that calls real clients, confirms real appointments, collects real QC ratings, and logs 64 structured data points per call — automatically, while the dispatcher does something else.

I built it while running Be2Clean LLC, a field service company in Cape Coral, Florida. The same company that uses it in production every day.

This is the complete technical story: the architecture decisions, the concurrency problems, the TCPA compliance layer, and why Housecall Pro — with $147M in venture funding — still doesn’t have this.

The Problem That Forced This Module Into Existence

Every field service operator knows the confirmation call problem.

You have 12 appointments tomorrow. You need to confirm each one. Someone has to make 12 calls, handle the voicemails, log the outcomes, and follow up on the ones that didn’t pick up. Then after each job, someone calls to collect a quality rating. Then someone calls the leads that came in this week. Then someone calls the clients who haven’t booked in 90 days.

That’s not a business. That’s a call center with a mop.

At Be2Clean, we were spending 2–3 hours per day on outbound calls that followed predictable scripts, collected predictable data, and required zero human judgment. The information existed. The scripts existed. The only bottleneck was a human dialing a phone.

That bottleneck is what the Runox AI Call Center eliminates.

The Architecture: Five Call Types, One Queue Processor

The system is built around a single Edge Function — vapi-outbound-call — that handles five distinct call types:

type CallType = 
  | 'confirmation'      // Appointment confirmation before job
  | 'qc'               // Quality control after job completion  
  | 'lead_cold_call'   // First contact with new leads
  | 'lead_follow_up'   // Cadence-based lead nurturing
  | 'upsell'           // Service expansion during/after QC

Each call type has its own prompt configuration, voice assignment, knowledge base context, and outcome schema. The confirmation call has a different success definition than the QC call. The cold call has different escalation logic than the follow-up. But they all flow through the same dispatch infrastructure.

The Queue Processor

The orchestration layer is process-call-cadences — a cron job that runs every minute. It evaluates enrollment records, determines which calls are due based on cadence step definitions, and dispatches them to VAPI with full context:

// Simplified dispatch logic
const pendingSteps = await supabase
  .from('cadence_enrollments')
  .select('*, cadence_steps(*), contacts(*)')
  .eq('status', 'active')
  .lte('next_step_at', new Date().toISOString());
for (const enrollment of pendingSteps) {
  const step = enrollment.cadence_steps;

  if (step.type === 'call') {
    await dispatchVAPICall({
      phoneNumber: enrollment.contacts.phone,
      callType: step.call_type,
      context: buildCallContext(enrollment),
      idempotencyKey: `cadence-${enrollment.id}-step-${step.position}`
    });
  }
}

The idempotencyKey is the detail that matters most here. I'll explain why in a moment.

The Concurrency Problem Nobody Warns You About

Here’s what happens when you run a call center at scale without idempotency controls:

The cron job fires at 10:00:00. It finds 8 calls due. It dispatches all 8 to VAPI.

The cron job fires again at 10:01:00. Due to a processing delay, 3 of those 8 calls haven’t been marked as dispatched yet. The cron job sees them as still pending and dispatches them again.

The client who was going to get one confirmation call now gets two calls 60 seconds apart from the same AI. That’s not a feature. That’s a compliance incident.

The solution is a database-backed idempotency layer:

async function dispatchVAPICall(params: CallDispatchParams) {
  const { idempotencyKey, phoneNumber, callType, context } = params;

  // Check if this exact call has already been dispatched
  const existing = await supabase
    .from('vapi_call_logs')
    .select('id, status')
    .eq('idempotency_key', idempotencyKey)
    .single();

  if (existing.data) {
    // Call already exists — skip silently
    return { skipped: true, reason: 'duplicate_idempotency_key' };
  }

  // Create the log record BEFORE dispatching to VAPI
  // This prevents race conditions if two processes check simultaneously
  const { data: callRecord } = await supabase
    .from('vapi_call_logs')
    .insert({
      idempotency_key: idempotencyKey,
      phone_number: phoneNumber,
      call_type: callType,
      status: 'dispatching',
      created_at: new Date().toISOString()
    })
    .select()
    .single();

  // Now dispatch to VAPI
  const vapiResponse = await vapi.calls.create({
    phoneNumberId: process.env.VAPI_PHONE_NUMBER_ID,
    customer: { number: phoneNumber },
    assistant: buildAssistantConfig(callType, context),
    metadata: { runox_call_id: callRecord.id }
  });

  // Update with VAPI call ID
  await supabase
    .from('vapi_call_logs')
    .update({ 
      vapi_call_id: vapiResponse.id, 
      status: 'in_progress' 
    })
    .eq('id', callRecord.id);

  return { dispatched: true, vapiCallId: vapiResponse.id };
}

The key architectural decision: create the database record before dispatching to VAPI, not after. If you create it after, a second process can check, find nothing, and dispatch a duplicate in the milliseconds between your VAPI call and your database write. Write first, dispatch second.

The TCPA Compliance Layer

The Telephone Consumer Protection Act restricts outbound calls to the 8AM–9PM window in the recipient’s local time zone. Violations carry statutory damages of $500–$1,500 per call. For a call center making hundreds of outbound calls per week, non-compliance isn’t a risk — it’s a liability.

Every outbound call in Runox runs through validate_outbound_call before dialing:

CREATE OR REPLACE FUNCTION validate_outbound_call(
  _phone TEXT,
  _timezone TEXT DEFAULT 'America/New_York'
)
RETURNS JSON AS $$
DECLARE
  _local_hour INT;
  _attempts_today INT;
  _on_dnc BOOLEAN;
  _blockers TEXT[] := '{}';
  _warnings TEXT[] := '{}';
BEGIN
  -- Check local hour
  _local_hour := EXTRACT(HOUR FROM NOW() AT TIME ZONE _timezone);

  -- Check DNC list
  SELECT EXISTS(
    SELECT 1 FROM dnc_list 
    WHERE phone = _phone 
    AND (expires_at IS NULL OR expires_at > NOW())
  ) INTO _on_dnc;

  -- Count today's attempts
  SELECT COUNT(*) FROM call_sessions
  WHERE phone_number = _phone
  AND DATE(created_at AT TIME ZONE _timezone) = CURRENT_DATE AT TIME ZONE _timezone
  INTO _attempts_today;

  -- Apply rules
  IF _on_dnc THEN
    _blockers := array_append(_blockers, 'dnc');
  END IF;

  IF _attempts_today >= 3 THEN
    _warnings := array_append(_warnings, 'frequency_cap');
  END IF;

  IF _local_hour < 8 OR _local_hour >= 21 THEN
    _warnings := array_append(_warnings, 'outside_calling_hours');
  END IF;

  RETURN json_build_object(
    'ok', array_length(_blockers, 1) IS NULL,
    'blockers', _blockers,
    'warnings', _warnings,
    'attempts_today', _attempts_today,
    'local_hour', _local_hour
  );
END;
$$ LANGUAGE plpgsql;

The distinction between blockers and warnings is intentional. A DNC listing is a hard blocker — the system will not dial regardless of any override. Outside calling hours is a warning — a supervisor can acknowledge and proceed if there’s a legitimate operational reason (a client who explicitly requested an early call, for example). Frequency cap is a warning because three attempts in a day is aggressive but not necessarily illegal depending on context.

The validation runs at the API level in usePreDialValidation before any call is dispatched. The dispatcher sees the result in real time before confirming the dial.

What Happens After the Call: 64 Data Points

When VAPI sends the end-of-call-report webhook, vapi-webhook-handler processes it and writes to vapi_call_logs. Here's what gets captured:

Call metadata: duration, cost_usd, recording_url, ended_reason, talk_ratio

Conversation intelligence: transcript (full), summary, key_points, action_taken, outcome

Sentiment analysis: sentiment_score, customer_emotion, escalation_flag

Lead qualification: qualification_score, engagement_score, readiness_to_buy, next_best_action

Intent detection: detected intents are extracted and written to call_intent_detections for downstream automation

QC-specific: For quality control calls, the handler extracts the numerical rating from the summary using pattern matching and writes it directly to appointment_quality_control and appointments. No manual data entry. The QC call ends, the appointment record updates, the owner sees the rating in the dashboard.

// QC rating extraction from call summary
const ratingMatch = summary.match(/rating[:\s]+([1-5])/i) 
  || summary.match(/([1-5])\s*(?:out of|\/)\s*5/i)
  || summary.match(/score[:\s]+([1-5])/i);
if (ratingMatch) {
  const rating = parseInt(ratingMatch[1]);

  await supabase
    .from('appointment_quality_control')
    .upsert({
      appointment_id: callContext.appointmentId,
      rating,
      source: 'ai_call',
      call_log_id: callRecord.id,
      collected_at: new Date().toISOString()
    });
}

The Adaptive Knowledge Base

This is the part that makes the system genuinely intelligent rather than just automated.

Every tenant’s inbound AI assistant (Zoe) handles questions from clients. When a client asks something Zoe can’t answer — a question not covered in the knowledge base — the system logs it:

await supabase
  .from('ai_unanswered_questions')
  .upsert({
    tenant_id: tenantId,
    question: detectedQuestion,
    frequency: 1,
    first_seen_at: new Date().toISOString(),
    last_seen_at: new Date().toISOString()
  }, {
    onConflict: 'tenant_id, question_hash',
    ignoreDuplicates: false // increment frequency instead
  });

A background process process-knowledge-gaps runs periodically, takes the highest-frequency unanswered questions, generates suggested answers using the AI, and presents them to the admin for approval. Once approved, they enter ai_knowledge_base and Zoe starts answering them correctly.

The system learns from real conversations. Not from pre-programmed responses. Not from a static FAQ. From actual questions actual clients actually asked — ranked by how often they come up.

The human approval step is intentional. Fully automated knowledge base updates would allow a badly phrased client question to corrupt the knowledge base. Human oversight is not a limitation of the system — it’s a design decision.

Why This Doesn’t Exist in Competing Platforms

Housecall Pro has $147M in venture funding. Jobber has raised over $100M. Neither offers native AI outbound voice calls for field service operations.

This isn’t an oversight. It’s a product prioritization decision driven by their customer base — which skews toward operators who are still getting comfortable with digital scheduling, let alone AI calls. The features that look obvious from the outside are genuinely hard to prioritize when your median customer just wants reliable invoicing.

Runox approached this differently because Be2Clean needed it. Not as a product roadmap item. As an operational necessity. We were making those calls manually every day and the cost in time and attention was measurable.

That’s the advantage of building software you use yourself: you don’t build features for a market segment you’ve researched. You build solutions to problems you’ve personally experienced at 8am on a Tuesday when three clients haven’t confirmed and two more just cancelled.

What We’d Do Differently

A few things I’d change with hindsight:

Outside-hours should be a blocker, not a warning. The current implementation warns the dispatcher but allows them to proceed. In practice, no legitimate business reason justifies calling a residential client at 7am. This will become a hard blocker in the next version.

Frequency cap should be rolling 24 hours, not calendar day. A call at 11:58pm and two calls the next morning at 8am technically stay under the three-per-calendar-day cap but are actually four calls in less than 9 hours. Rolling window logic is more protective and more accurate.

Company-wide rate limiting is missing. Currently the cap is per phone number, not per tenant. A tenant with a large team could theoretically saturate outbound capacity. A global queue with tenant-level rate limiting is on the roadmap.

The Broader Point

Field service businesses in the United States employ millions of workers and generate tens of billions in annual economic activity. The owners of these businesses are exceptional operators — they manage crews, clients, routes, and quality under enormous daily pressure.

What they are not is software engineers. They shouldn’t have to be. The operational intelligence that makes the difference between a business that grows and one that plateaus should be as accessible to a 10-person cleaning company in Cape Coral as it is to a 500-person facilities management firm in Chicago.

That’s what the Runox AI Call Center is about. Not technology for its own sake. Operational capacity that scales with the business — so the owner can spend Tuesday morning looking at QC scores and productivity metrics instead of dialing a phone.

Carlos Alberto Pena Molina is the founder and AI Systems Architect of Runox, a field service management platform built for U.S. SMBs. Runox is built on Supabase, React, VAPI, and Capacitor with native Android and iOS deployment.

Technical article series: → Part 1: GPS Anti-Teleportation: https://medium.com/@be2cleanllc/how-a-vehicle-traveling-at-3-220-km-h-broke-our-fleet-tracking-and-what-we-built-to-fix-it-8be15531467d → Part 2: This article

Connect: linkedin.com/in/carlos-alberto-pena-molina-344475264

Inbound: When the Client Calls First

The outbound system handles proactive communication. But field service operations don’t run on a schedule the business controls. Clients call unexpectedly. Equipment breaks. Appointments need to change. Someone locked themselves out and the cleaning crew is already en route.

For those moments, Runox has Zoe — the inbound AI assistant. And Zoe does significantly more than answer questions.

Caller Identity Resolution

Before Zoe says the first word, the system runs a real-time lookup against the client database using the incoming phone number:

// vapi-webhook-handler — on call start
const incomingNumber = event.call.customer.number;
// Normalize the number
const normalized = normalizePhone(incomingNumber);
// Query against clients table
const { data: client } = await supabase
  .from('clients')
  .select('id, name, email, status, service_history_summary')
  .eq('phone_normalized', normalized)
  .single();
const callerContext = client 
  ? {
      type: 'known_client',
      clientId: client.id,
      name: client.name,
      hasOpenAppointments: await getOpenAppointments(client.id),
      lastServiceDate: client.service_history_summary?.last_date,
    }
  : {
      type: 'unknown_caller',
      leadMode: true,
    };
// Inject context into VAPI assistant prompt dynamically
const assistantConfig = buildInboundAssistant(callerContext, tenantConfig);

This lookup completes in milliseconds — before Zoe’s greeting plays. The caller never knows it happened. But Zoe does.

Two Completely Different Conversations

Known client: Zoe greets them by name. Has access to their appointment history, open jobs, account status, and service preferences. A client calling to report a problem gets empathy and an immediate escalation path. A client calling to reschedule gets their upcoming appointments read back to them and a confirmed change. The conversation feels personal because it is — it’s powered by their actual data.

Unknown number: Zoe enters lead capture mode. She qualifies intent, collects name and contact information, identifies the service type they’re looking for, and creates a structured lead record in the CRM before the call ends. The lead arrives in the platform with the full conversation transcript, detected intent, and a suggested follow-up action.

The Request Creation and Manager Alert

When urgency is detected — a client reporting an active problem, a complaint, a same-day change request — two things happen simultaneously before the call ends:

// Create structured request in platform
await supabase
  .from('client_requests')
  .insert({
    tenant_id: tenantId,
    client_id: callerContext.clientId,
    request_type: detectedIntent.type,
    urgency: detectedIntent.urgency,
    summary: detectedIntent.summary,
    source: 'inbound_ai_call',
    call_log_id: callRecord.id,
    status: 'pending_review',
    created_at: new Date().toISOString()
  });
// Fire SMS to manager if urgent
if (detectedIntent.urgency === 'high') {
  await twilio.messages.create({
    to: tenantConfig.manager_phone,
    from: process.env.TWILIO_NUMBER,
    body: `🔴 Urgent request from ${callerContext.name}: "${detectedIntent.summary}". View in Runox: ${requestUrl}`
  });
}

The manager receives the SMS while the call is still in progress. By the time Zoe says goodbye, the request is in the system, categorized, and the right person has been notified.

Listen to a real Zoe inbound call — live production, Be2Clean LLC, Cape Coral Florida:

https://soundcloud.com/runox-technologies/runox-ai-call-center-zoe

No receptionist. No voicemail to check at the end of the day. No request lost in a text thread between a client and a crew member. The client called, the AI handled it, the manager is informed, and the platform has the complete record with transcript.

Why This Architecture Matters

The combination of caller identity resolution, real-time database lookup, dynamic AI prompt injection, automated request creation, and SMS dispatch — completing in a single inbound call — is not a feature. It’s a closed operational loop.

Most field service businesses handle inbound calls the same way they did in 2005: someone answers, writes something on a notepad or sends a WhatsApp message, and hopes it gets acted on. The information exists in a human’s short-term memory until it gets transferred somewhere — if it gets transferred at all.

The Runox inbound system eliminates that gap. Every call creates a structured record. Every urgent call notifies the right person immediately. Every unknown caller becomes a qualified lead rather than a missed opportunity.

The business doesn’t need to be staffed to answer the phone. The AI handles it — and handles it with full context about who’s calling and why.

The Complete Picture: One Platform, Closed Loop

Putting it all together, the Runox AI Call Center handles the full communication lifecycle of a field service operation:

Before the job: Automated confirmation calls reduce no-shows. The client confirms, the system logs it, the route is locked.

During the job: Inbound calls from clients are handled by Zoe with full context. Urgent requests are created and escalated automatically.

After the job: QC calls collect structured ratings that update appointment records without manual entry. Upsell opportunities are detected and logged for follow-up.

Ongoing: Lead cadences run automatically. Cold calls, follow-ups, and re-engagement sequences execute on schedule without dispatcher involvement.

Compliance throughout: Every dial is validated against DNC lists, calling hour windows, and frequency caps before the call is placed.

The dispatcher’s job changes from making calls to reviewing outcomes. From dialing to deciding. From administrative overhead to operational oversight.

That’s the shift Runox is designed to enable — not for enterprise companies with dedicated call center teams, but for the 10-person operation in Southwest Florida that has a dispatcher, a manager, and a crew trying to do the work of thirty.


메타데이터
post_id
da102dd3d1bf
slug
building-a-concurrent-ai-call-center-for-field-service-operations-vapi-idempotency-and-what-da102dd3d1bf
url
https://medium.com/@carlospenamolina/building-a-concurrent-ai-call-center-for-field-service-operations-vapi-idempotency-and-what-da102dd3d1bf
canonical_url
https://medium.com/@carlospenamolina/building-a-concurrent-ai-call-center-for-field-service-operations-vapi-idempotency-and-what-da102dd3d1bf
author_url
https://medium.com/@carlospenamolina
status
ok
fetched_at
2026-06-11 18:57:12