TL;DR
Construimos un recepcionista IA para una clínica dental en español mexicano que contesta el teléfono, agenda citas, consulta disponibilidad real y confirma por WhatsApp. Stack 100% controlable: Pipecat para orquestación de voz (alternativa OSS a Vapi/Retell), Deepgram para STT (con opción local Whisper.cpp), Cartesia para TTS natural en español (con opción local Coqui TTS), Claude Sonnet vía LiteLLM para razonamiento, Qdrant para RAG del negocio (servicios, horarios, FAQs), Langfuse para trazar cada llamada y Redis para el contexto por turno. Twilio Media Streams conecta con el PSTN. Costo por llamada: ~$0.11 USD. Latencia de primera respuesta: 900-1200 ms. El repositorio completo y el compose quedan publicados.
Por qué no Vapi o Retell
Vapi y Retell son excelentes como SaaS — setup rápido, buen modelo por defecto, UI amigable. Tienen tres límites que terminan importando:
- Español mexicano suena a español ibérico en la voz default. Cartesia permite clonado y tuning fino; con ElevenLabs pagas caro por lo mismo.
- Integración telefónica acotada a sus proveedores; si ya tienes Twilio o Vonage, sales del camino feliz.
- Datos sensibles en salud y legal exigen on-prem — Vapi almacena audios y transcripciones en su infra.
Pipecat (OSS por Daily.co, Apache 2.0) es el framework que resuelve esto: pipeline declarativo de STT → LLM → TTS, transportes intercambiables, deploy donde quieras.
Arquitectura
Llamada telefónica ──► Twilio SIP Trunk / Voice
│
├── Media Streams (audio mu-law 8kHz)
│
▼
┌─────────────────────────────────────────────────┐
│ Pipecat pipeline (Python, container) │
│ │
│ Audio in → VAD → Deepgram STT │
│ │ │
│ ▼ │
│ Context Aggregator ◄─── Redis │
│ │ │
│ ▼ │
│ LLM (Claude vía LiteLLM) │
│ │ │
│ Tool calls ──► [Qdrant RAG] [Calendario] │
│ │ │
│ ▼ │
│ Cartesia TTS │
│ │ │
│ ▼ │
│ Audio out │
└─────────────────────────────────────────────────┘
│
▼ trazas
Langfuse
Pipeline Pipecat mínimo
Python 3.11+. Archivo agent.py:
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.cartesia import CartesiaTTSService
from pipecat.services.openai import BaseOpenAILLMService # funciona con LiteLLM
from pipecat.transports.network.fastapi_websocket import FastAPIWebsocketTransport
from pipecat.vad.silero import SileroVADAnalyzer
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
async def run_agent(websocket, tenant_id: str):
transport = FastAPIWebsocketTransport(
websocket=websocket,
params=FastAPIWebsocketTransport.InputParams(
audio_sample_rate=8000, # Twilio mu-law
vad_enabled=True,
vad_analyzer=SileroVADAnalyzer(),
serializer="twilio",
),
)
stt = DeepgramSTTService(
api_key=os.getenv("DEEPGRAM_KEY"),
language="es",
model="nova-3",
)
tts = CartesiaTTSService(
api_key=os.getenv("CARTESIA_KEY"),
voice_id="mx-female-warm-v1",
language="es",
speed=1.0,
)
llm = BaseOpenAILLMService(
api_key=os.getenv("LITELLM_MASTER_KEY"),
base_url="https://api.numoru.com/v1",
model="claude-sonnet",
)
tools = load_clinic_tools(tenant_id)
context = OpenAILLMContext(
messages=[{"role": "system", "content": system_prompt(tenant_id)}],
tools=tools,
)
pipeline = Pipeline([
transport.input(),
stt,
context.user_aggregator(),
llm,
tts,
transport.output(),
context.assistant_aggregator(),
])
runner = PipelineRunner()
task = PipelineTask(pipeline)
await runner.run(task)
Prompt del sistema
Regla: corto, específico, con ejemplos. El modelo no necesita 2k tokens de personalidad.
Eres Rocío, recepcionista de la Clínica Dental Numoru en Querétaro.
Hablas español mexicano cálido y profesional. Respondes breve (máximo 2
frases por turno a menos que el paciente pida detalle).
Tu objetivo es UNO: ayudar a agendar, reagendar o cancelar citas, y
responder preguntas generales sobre la clínica.
Reglas duras:
- NUNCA das diagnósticos ni recomendaciones médicas.
- NUNCA prometes tiempos de tratamiento ni precios que no estén en RAG.
- Si no sabes algo, ofreces pasar con una recepcionista humana.
- Cuando agendes, confirma oralmente y envías WhatsApp con detalle.
Horario: lunes a sábado 9:00-19:00. Urgencias fuera de horario:
pasar con línea de guardia (tool: transfer_to_emergency).
Tools expuestas al LLM
TOOLS = [
{
"name": "search_clinic_info",
"description": "Busca información sobre servicios, precios y FAQs de la clínica.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
{
"name": "find_available_slot",
"description": "Encuentra un horario disponible para cierto servicio y preferencia.",
"parameters": {
"type": "object",
"properties": {
"service_id": {"type": "string"},
"from_date": {"type": "string", "format": "date"},
"preferred_time": {"type": "string", "enum": ["morning", "afternoon", "any"]},
},
"required": ["service_id"],
},
},
{
"name": "book_appointment",
"description": "Agenda una cita confirmada.",
"parameters": {
"type": "object",
"properties": {
"patient_phone": {"type": "string"},
"patient_name": {"type": "string"},
"slot_id": {"type": "string"},
"notes": {"type": "string"},
},
"required": ["patient_phone", "patient_name", "slot_id"],
},
},
{
"name": "transfer_to_human",
"description": "Transfiere la llamada a una recepcionista humana.",
"parameters": {"type": "object", "properties": {"reason": {"type": "string"}}},
},
]
search_clinic_info consulta Qdrant. find_available_slot consulta Google Calendar vía nuestro MCP. book_appointment escribe en ambos + dispara plantilla WhatsApp.
RAG del negocio con Qdrant
Antes del lanzamiento cargamos:
- Catálogo de servicios (100-300 items): nombre, precio aproximado, duración, descripción.
- FAQs (30-80 items): "¿aceptan seguro X?", "¿tienen estacionamiento?".
- Políticas (10-20 items): cancelación, llegada tarde, depósito.
Chunking con Chonkie para respetar frases; embedding con text-embedding-3-small de OpenAI (768 dims) vía LiteLLM; colección en Qdrant con payload_filter por tenant_id.
def build_retriever(tenant_id: str):
client = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY)
def retrieve(query: str, k: int = 4):
hits = client.search(
collection_name="clinic_kb",
query_vector=embed(query),
query_filter=Filter(must=[FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id))]),
limit=k,
)
return [h.payload for h in hits]
return retrieve
Cuando el modelo llama search_clinic_info, recuperamos top-4 y devolvemos al contexto solo el texto útil (no embeddings).
Latencia: dónde se pierden los milisegundos
En 50 llamadas de prueba:
| Componente | p50 (ms) | p95 (ms) |
|---|---|---|
| STT (Deepgram Nova-3) | 180 | 320 |
| LLM primer token (Claude Sonnet) | 540 | 880 |
| TTS primer audio (Cartesia) | 160 | 290 |
| Time-to-first-speech | 920 | 1380 |
| Turno completo | 2200 | 3600 |
El dolor está en el LLM. Mitigaciones usadas:
stream: trueen LiteLLM para empezar TTS con el primer chunk.- Prompt <500 tokens en el mayoría de turnos.
- Cache semántico Redis para FAQs frecuentes (40% hit rate en producción).
- Claude Haiku para clasificación inicial, Sonnet solo cuando el intent requiere razonamiento.
Twilio: integración concreta
Configuración en Twilio Console:
- SIP Trunk o número de Voice con TwiML:
<Response>
<Connect>
<Stream url="wss://voz.numoru.com/ws/clinica-dental-123" />
</Connect>
</Response>
El websocket en nuestro servidor recibe paquetes mu-law 8kHz y los pasa a Pipecat. Retorno idéntico en sentido inverso.
Integración con WhatsApp post-llamada
Cuando se agenda con éxito, el agente dispara (como tool side-effect) una plantilla de WhatsApp Business Cloud vía nuestro MCP mcp-whatsapp:
Hola {{nombre}}, confirmamos tu cita:
🦷 Servicio: {{servicio}}
📅 {{fecha}} a las {{hora}}
📍 {{direccion}}
Para reagendar responde CAMBIAR.
Esto baja no-shows en 35-50% según lo que vemos en clientes.
Guardrails
Dos capas:
1. Guardrails LLM (NeMo)
Una política declarativa bloquea respuestas donde el agente ofrezca diagnósticos o precios no verificados.
define user ask diagnosis
"¿tengo caries?"
"¿qué me pasa?"
define bot refuse diagnosis
"No soy quien para diagnosticar. ¿Quieres que te agende con el doctor?"
define flow
user ask diagnosis
bot refuse diagnosis
2. Guardrails determinísticos
Código Python valida que book_appointment nunca agende:
- Fuera de horario de clínica.
- En un slot ya ocupado.
- Sin teléfono válido.
Si falla, el tool regresa error estructurado y el LLM reintenta o transfiere a humano.
Trazas en Langfuse
Cada llamada genera una session con spans para STT, LLM y TTS. Enriquecemos con metadata:
tenant_id,phone_number_hashed(no PII en claro).booked: bool,transferred: bool,duration_s.- Evaluaciones automáticas programadas cada hora: "¿el agente siguió el guión?", "¿contradijo RAG?".
El dashboard de Langfuse nos permite detectar:
- Spikes de llamadas transferidas → prompt o RAG falla.
- Latencia p95 > 2s → Cartesia o Deepgram con problemas.
- Alucinaciones por día → alerta si >3 detectadas.
Deploy en Digital Ocean
Sobre el stack OSS self-hosted:
services:
voice-agent:
image: numoru/voice-agent:latest
environment:
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
LITELLM_BASE_URL: http://litellm:4000
DEEPGRAM_KEY: ${DEEPGRAM_KEY}
CARTESIA_KEY: ${CARTESIA_KEY}
LANGFUSE_PUBLIC_KEY: ${LF_PUBLIC_KEY}
LANGFUSE_SECRET_KEY: ${LF_SECRET_KEY}
QDRANT_URL: http://qdrant:6333
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/2
ports: ["8765:8765"]
networks: [core]
Nginx enruta wss://voz.numoru.com/ a voice-agent:8765. Un solo contenedor atiende ~40 llamadas simultáneas en el droplet de 8 GB.
Costos reales por llamada
Duración promedio: 2.5 min.
| Concepto | USD |
|---|---|
| Deepgram STT (0.0043 USD/min) | 0.011 |
| Cartesia TTS (0.065 USD/1k chars, ~800 chars salida) | 0.052 |
| Claude Sonnet (4 turnos × 2k tokens promedio) | 0.012 |
| Twilio PSTN (0.013 USD/min) | 0.033 |
| Total por llamada | ~0.11 |
Volumen clínica tipo 20 camas: 2000 llamadas/mes → 220 USD/mes en costos variables + 46 USD del stack base = ~270 USD/mes. Un recepcionista humano part-time cuesta 8-12× más.
Partición en USD entre los vendors managed. Twilio es la línea más cara — cambiar a un SIP como Telnyx recorta 20-30%.
Datos de producción Numoru, promedio de clientes febrero 2026.
Impacto de negocio y casos
Las dos fugas que cierra el producto
Clínicas dentales, despachos legales y servicios electivos pierden dinero en dos frentes que un recepcionista IA resuelve de inmediato: llamadas fuera de horario y a la hora de comida (en dental, ~67% del volumen entrante ocurre fuera del horario 9-18h según datos de práctica de ADA) y no-shows del mismo día (promedio industria 15-25% de las citas). Cada llamada perdida es $150-450 en cita que no entra; cada no-show es un hueco de $120 que la clínica no puede rellenar fácilmente.
Fracción de llamadas entrantes por franja horaria en 6 clientes corriendo el stack de voz de Numoru. Las clínicas solo-humano pierden las barras grises.
Telemetría de clientes Numoru, ago 2025 – feb 2026. Baseline validado contra la encuesta 2024 de ADA Health Policy Institute sobre patrones de contacto de pacientes.
Industrias y rangos de ticket
Pricing mensual por vertical (Numoru 2026, USD)
Casos públicos
Dental Intelligence — benchmark telefónico
Weave — impacto de recordatorios de citas
Deepgram + Cartesia — benchmark de voz en español
Caso ilustrativo — grupo dental mid-size
Grupo dental de 4 sucursales en Querétaro desplegando el agente de voz Numoru
Calculadora ROI — reemplazar staff telefónico part-time
Clínica de 1 sede: recepcionista humano vs agente Numoru (12 meses)
| Retainer Numoru (12 mo × $399) | −$4,788 |
| Uso por llamada (24k × $0.11) | −$2,640 |
| Implementación (one-time) | −$3,500 |
| Recuperado de llamadas perdidas | +$29,480 |
| Recuperado de menos no-shows | +$28,800 |
| Recepcionista humano (no reemplazada) | $0 |
| Contribución bruta neta año 1 | +$47,352 |
El agente rara vez reemplaza humanos — captura el volumen que los humanos no pueden. La configuración con más ROI mantiene al humano y lo mueve a trabajo presencial, mientras el agente cubre los teléfonos.
Tiers de pricing que vende Numoru
- 1 sede / 1 número
- Template dental o legal
- Voz en español (MX / CO / AR)
- Confirmación WhatsApp
- Integración Google Calendar
- Dashboard Langfuse básico
- SLA de lanzamiento 60 días
- Hasta 3 sedes
- Integración Dentrix / OpenDental / Abrera
- Voz tuneada por vertical
- System prompt custom
- Guardrails avanzados (NeMo)
- Evals mensuales con Promptfoo
- SLA humano 24 / 5
- Sedes ilimitadas
- Opción self-hosted (on-prem)
- SAML / SSO, audit log
- Voz custom (clone Cartesia)
- Adendo de compliance (HIPAA-equivalente)
- CSM dedicado + PoC de ingeniería
- Migración desde Vapi / Retell
Uso sobre el bucket incluido factura a $0.14 / llamada. La opción self-hosted reduce por-llamada a ~$0.05 (electricidad + amortización) con >15k llamadas / mes.
Opciones 100% locales (sin dependencia de APIs)
Si el cliente exige cero llamadas a externos:
- STT: Whisper.cpp (medium-es) en GPU pequeña o CPU con cuantización.
- TTS: Coqui TTS con modelo XTTS-v2 fine-tuned.
- LLM: Llama 3.3 70B vía vLLM o Qwen 2.5 32B.
Requiere GPU (al menos A10 o RTX 4090). Costo de droplet sube a 500-800 USD/mes, pero el por-llamada cae a electricidad + amortización. Punto de equilibrio: ~15,000 llamadas/mes.
Testing: evals con Promptfoo
50 guiones sintéticos ("quiero cita el martes de tarde", "cuánto cuesta blanqueamiento") con respuestas esperadas. Suite corre en CI con Promptfoo + asserts custom. Ver Evals de agentes en CI/CD.
FAQ
¿Por qué no usar la voz por defecto de OpenAI Realtime?Para español se usa modelo único sin control fino de estilo mexicano; además mete vendor lock-in.
¿Aguanta dialectos fuera de México?Sí configurando Deepgram con language="es" y Cartesia con voice apropiado. Probado en Colombia, Argentina y Chile con ajuste menor.
¿Qué pasa si el paciente habla muy rápido o encima del agente?Silero VAD detecta interrupción; el pipeline cancela la respuesta TTS en curso y escucha. Sin esto, suena a robot que interrumpe.
¿Cuál es el primer cliente adecuado?Clínica dental o despacho legal mediano con 30-100 llamadas/día. Debajo, el ROI tarda; encima, hay que dimensionar más.
¿Integración con otros calendarios además de Google?Microsoft 365 via mcp-calendar. Software propietario (Dentrix, OpenDental) por adaptador custom — 2-5 días de trabajo.
Próximos pasos
Repo en github.com/numoru-ia/voice-agent-es. La próxima pieza cubre cómo orquestar tres agentes (citas, recordatorios, reseñas) con LangGraph para una clínica dental completa, usando este agente de voz como punto de entrada.