TL;DR
Desplegamos tres agentes especializados para una clínica dental: agente de citas (toma llamadas/WhatsApp y agenda), agente de recordatorios (envía confirmaciones y pre-consultas) y agente de reseñas (post-cita, invita a dejar review). Los tres orquestados con LangGraph y checkpointer en Postgres, memoria de paciente en Mem0 sobre Qdrant, flujos largos (recordatorio 24h, follow-up 7 días) con Temporal, guardrails sanitarios con NeMo Guardrails y observabilidad completa en Langfuse. El sistema reduce no-shows 38%, captura 87% de leads fuera de horario y libera ~60 horas/mes de recepción. Todo el código, los tools MCP y los workflows Temporal quedan publicados.
Por qué tres agentes y no uno gigante
Un agente monolítico puede responder a todo pero pierde foco. Hablar con paciente que quiere agendar es distinto de recordarle que tiene cita mañana o pedirle reseña a las 24h post-tratamiento. Tres ventajas del diseño multi-agente:
- Prompts más cortos y focalizados — cada agente sabe exactamente su trabajo.
- Evaluación independiente — puedes mejorar uno sin tocar los demás.
- Separación de privilegios — el agente de reseñas nunca puede agendar; el de citas nunca puede enviar emails masivos.
Diagrama general
┌──────────────────────────────────────────────────────────┐
│ LangGraph orchestrator │
│ │
│ ┌──────────────┐ │
│ │ Router │ ◄─── Canal (voz / WhatsApp / web) │
│ └──────┬───────┘ │
│ │ │
│ ┌───────┼──────────┬───────────────┐ │
│ ▼ ▼ ▼ ▼ │
│ Citas Recordatorio Reseña Fallback human │
│ ▲ ▲ │
│ │ │ │
│ ┌────┴─────┐ ┌────┴───────┐ │
│ │ Temporal │ │ Temporal │ │
│ │ 24h/1h │ │ 24h post │ │
│ └──────────┘ └────────────┘ │
└──────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
Calendario WhatsApp Google Reviews
(MCP) (MCP) (MCP)
│ │ │
└─────────────────┴──────────────────┘
│
▼
Postgres + Qdrant + Redis + Mem0 + Langfuse
Stack en concreto
| Pieza | Tecnología | Rol |
|---|---|---|
| Orquestador | LangGraph (Python) | Grafo de estados entre agentes |
| Checkpointer | Postgres | Persistencia de estado conversacional |
| Memoria semántica | Mem0 sobre Qdrant | Hechos por paciente |
| Working memory | Redis | Contexto por turno |
| Flujos largos | Temporal | Recordatorios 24h, follow-ups 7d |
| LLM | Claude Sonnet vía LiteLLM | Razonamiento |
| Voz | Pipecat + Deepgram + Cartesia | Canal telefónico |
MCP mcp-whatsapp | Canal mensajería | |
| Calendario | MCP mcp-calendar | Slots disponibles |
| Reseñas | MCP custom mcp-reviews | Google Business Profile |
| Guardrails | NeMo Guardrails | No diagnóstico, no precios inventados |
| Observabilidad | Langfuse self-host | Trazas, evals, sesiones |
El grafo LangGraph
Archivo clinic_graph.py:
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.postgres import PostgresSaver
from typing import TypedDict, Literal
class ClinicState(TypedDict):
session_id: str
patient_id: str | None
channel: Literal["voice", "whatsapp", "web"]
user_message: str
intent: Literal["book", "reschedule", "cancel", "ask_info", "confirm", "review", "unknown"]
working_memory: dict
response: str
tool_calls: list
done: bool
def router_node(state: ClinicState) -> ClinicState:
state["intent"] = classify_intent(state["user_message"])
return state
def booking_node(state: ClinicState) -> ClinicState:
# agente especializado con su propio prompt + tools de calendario
result = booking_agent.run(state)
return {**state, **result}
def reminder_handler(state: ClinicState) -> ClinicState:
# atiende confirmaciones de recordatorios
result = reminder_agent.run(state)
return {**state, **result}
def review_handler(state: ClinicState) -> ClinicState:
result = review_agent.run(state)
return {**state, **result}
def human_fallback(state: ClinicState) -> ClinicState:
trigger_human_handoff(state)
return {**state, "done": True, "response": "Te paso con una recepcionista."}
def route(state: ClinicState) -> str:
intent = state["intent"]
if intent in ("book", "reschedule", "cancel", "ask_info"):
return "booking"
if intent == "confirm":
return "reminder"
if intent == "review":
return "review"
return "fallback"
graph = StateGraph(ClinicState)
graph.add_node("router", router_node)
graph.add_node("booking", booking_node)
graph.add_node("reminder", reminder_handler)
graph.add_node("review", review_handler)
graph.add_node("fallback", human_fallback)
graph.set_entry_point("router")
graph.add_conditional_edges("router", route, {
"booking": "booking",
"reminder": "reminder",
"review": "review",
"fallback": "fallback",
})
graph.add_edge("booking", END)
graph.add_edge("reminder", END)
graph.add_edge("review", END)
graph.add_edge("fallback", END)
checkpointer = PostgresSaver.from_conn_string(os.getenv("POSTGRES_URL"))
app = graph.compile(checkpointer=checkpointer)
Agente de citas
Prompt corto, tools específicos, guardrails:
BOOKING_SYSTEM = """
Eres el agente de reservas de Clínica Dental Numoru.
Tu única tarea es ayudar a agendar, reagendar o cancelar citas.
Reglas:
- Confirma nombre completo y teléfono antes de agendar.
- Respeta horario: lu-sa 9:00-19:00.
- Si el paciente pregunta precio o diagnóstico, usa search_clinic_info;
si no está en el RAG, ofrece hablar con el doctor, nunca inventes.
- Si detectas urgencia médica real, transfiere inmediatamente con transfer_to_emergency.
"""
booking_agent = Agent(
system_prompt=BOOKING_SYSTEM,
model="claude-sonnet",
tools=[
tool.find_available_slot,
tool.book_appointment,
tool.reschedule,
tool.cancel,
tool.search_clinic_info,
tool.transfer_to_emergency,
],
guardrails=NeMoGuardrails(config="guardrails/clinic.colang"),
)
Agente de recordatorios (con Temporal)
El agente de recordatorios es reactivo (contesta) pero el envío lo dispara Temporal:
# workflow Temporal
@workflow.defn
class AppointmentReminderWorkflow:
@workflow.run
async def run(self, appt: Appointment):
# 24h antes
await workflow.sleep(appt.start - timedelta(hours=24))
await workflow.execute_activity(
send_whatsapp_template,
ReminderInput(patient_id=appt.patient_id, slot=appt.start, kind="24h"),
start_to_close_timeout=timedelta(minutes=2),
)
# 1h antes
await workflow.sleep(appt.start - timedelta(hours=1))
await workflow.execute_activity(
send_whatsapp_template,
ReminderInput(patient_id=appt.patient_id, slot=appt.start, kind="1h"),
start_to_close_timeout=timedelta(minutes=2),
)
Temporal sobrevive reinicios, upgrades y caídas del worker. Si el paciente responde al WhatsApp, el mensaje entrante dispara el graph LangGraph con intent="confirm".
Agente de reseñas (post-cita)
@workflow.defn
class PostVisitReviewWorkflow:
@workflow.run
async def run(self, appt: Appointment):
# 4h post-cita
await workflow.sleep(appt.end + timedelta(hours=4))
satisfied = await workflow.execute_activity(
nps_one_question,
NpsInput(patient_id=appt.patient_id),
start_to_close_timeout=timedelta(minutes=10),
)
if satisfied >= 8:
# 24h después, pedir reseña pública
await workflow.sleep(timedelta(hours=20))
await workflow.execute_activity(
request_review,
ReviewInput(patient_id=appt.patient_id, channel="google"),
)
El agente no pide reseña a pacientes insatisfechos — dispara un workflow interno para que un humano los contacte antes.
Memoria de paciente con Mem0
Hechos extraídos de conversaciones pasadas (con consentimiento):
from mem0 import Memory
memory = Memory.from_config({
"vector_store": {
"provider": "qdrant",
"config": {"host": "qdrant", "port": 6333, "collection_name": "clinic_patients"},
},
"llm": {"provider": "litellm", "config": {"model": "claude-sonnet"}},
})
def enrich_prompt_with_memory(patient_id: str, query: str) -> str:
facts = memory.search(query=query, user_id=patient_id, limit=5)
if not facts:
return ""
bullets = "\n".join(f"- {f['memory']}" for f in facts)
return f"\n\nContexto previo del paciente:\n{bullets}\n"
Ejemplos de facts útiles:
- "Prefiere citas por la tarde"
- "Alérgico a la penicilina"
- "Últimas 2 consultas: limpieza y endodoncia en molar 36"
- "Trae seguro GNP"
Guardrails sanitarios
Archivo guardrails/clinic.colang:
define user ask medical_diagnosis
"¿tengo caries?"
"¿qué enfermedad tengo?"
"¿es grave lo que tengo?"
"¿necesito ortodoncia?"
define bot refuse medical_diagnosis
"Para diagnosticar necesitamos verte. ¿Te agendo con el doctor?"
define user ask price
"¿cuánto cuesta?"
"¿qué precio tiene?"
define flow
user ask medical_diagnosis
bot refuse medical_diagnosis
define flow
user ask price
execute search_clinic_info
# si no encuentra precio en RAG, el tool devuelve error y el flujo default responde "consulta con recepción"
Observabilidad en Langfuse
Cada ejecución del graph emite una session con:
patient_id(hash).channel.intentdetectado.- Tool calls con input/output.
- Score de "goal completion" (evaluación automática: ¿cerró la intención del usuario?).
Dashboards que miramos:
- Goal completion rate por intent (target >85% para
book). - Transfer rate humano (target <12%).
- Latencia p95 (voz: <2.5s por turno).
- No-show rate post-recordatorio (baseline vs actual).
Métricas reales del cliente piloto
Clínica dental con 3 doctores, 850 citas/mes.
| Métrica | Pre | Post 90 días |
|---|---|---|
| No-show rate | 22% | 14% |
| Leads capturados fuera de horario | 12% | 87% |
| Horas recepción/mes | 170 | 108 |
| NPS promedio | 7.8 | 8.4 |
| Reseñas Google nuevas/mes | 4 | 18 |
| Costo operativo agente (USD/mes) | — | 420 |
ROI: la recepcionista adicional que no contrataron cuesta ~$600 USD/mes. El agente cuesta menos y trabaja 24/7.
Misma práctica, 3 doctores, 850 visitas al mes. Agentes desplegados en secuencia (citas → recordatorios → reseñas) durante 6 semanas; periodo de estabilización al día 60.
- Pre (baseline = 100)
- 90 días post
Telemetría de cliente Numoru, medida contra baseline del año anterior.
Impacto de negocio y casos
Dónde se concentra el valor
La configuración multi-agente desbloquea revenue que un solo agente de voz no puede. Recordatorios es la línea de dólares más grande — cada punto de no-show recuperado es un slot de $120-180 que se rellena. Reseñas es el motor de crecimiento a largo plazo — nuevos pacientes llegan vía ranking de Google Maps, que se mueve con reseñas frescas de 5 estrellas. El agente de citas es apuesta mínima; los otros dos son el moat.
Dónde se vende este patrón más allá de dental
Ticket de orquestación multi-agente por vertical (Numoru, USD)
Benchmarks públicos que anclan el valor
Dental Intelligence — analítica de agenda y recordatorios
Google — impacto de local search + reseñas
LangChain / LangGraph — casos de producción multi-agente
Caso ilustrativo — cadena de estética
Cadena estética / MedSpa (6 sedes) adoptando el stack de 3 agentes
Calculadora ROI — práctica dental de 3 doctores
Práctica dental única, 3 doctores, 850 visitas / mes (12 meses)
| Retainer plataforma (12 mo × $420) | −$5,040 |
| Implementación (one-time) | −$6,500 |
| Recuperación de no-shows (+68 visitas × $180 × 12) | +$146,880 |
| Nuevos pacientes por captura fuera de horario (+19 × $720 LTV × 12) | +$164,160 |
| Horas recepción ahorradas (62 × $12 × 12) | +$8,928 |
| Visitas nuevas por reseñas (+6 × $720 × 12) | +$51,840 |
| Contribución bruta neta año 1 | +$360,268 |
Tiers de pricing que Numoru vende
- 1 sede / 1 teléfono / 1 WhatsApp
- Orquestación LangGraph + Temporal
- Memoria paciente Mem0
- Guardrails sanitarios NeMo
- Dashboard mensual Langfuse
- Google Calendar + Google Reviews
- Setup: $6,500 one-time
- KB Qdrant compartida
- Branding + voz por sede
- Dashboard analítico cross-site
- Add-on elegibilidad de seguros
- Integración Dentrix / OpenDental
- Revisión de evals trimestral
- Setup: $14,000 one-time
- Opción self-hosted (HIPAA scope)
- Branding white-label del agente
- Contrato maestro / friendly a PO
- CSM dedicado
- Guardrails + políticas custom
- Auditoría anual de compliance
- Setup: $28,000+ one-time
El mensual incluye bucket de uso (2,000 llamadas + 5,000 WhatsApp + 8,000 inferencias LLM por clínica). Overages a rate card.
Deploy en el stack OSS
Sobre el droplet del stack self-hosted, agregamos:
services:
clinic-orchestrator:
image: numoru/clinic-orchestrator:latest
environment:
POSTGRES_URL: postgres://lf:${LF_DB_PASSWORD}@langfuse-db:5432/clinic
QDRANT_URL: http://qdrant:6333
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/3
LITELLM_URL: http://litellm:4000
LANGFUSE_PUBLIC_KEY: ${LF_PUBLIC_KEY}
TEMPORAL_HOST: temporal:7233
depends_on: [langfuse-db, qdrant, redis, litellm, temporal]
temporal:
image: temporalio/auto-setup:1.25
environment:
DB: postgres12
DB_PORT: 5432
POSTGRES_USER: lf
POSTGRES_PWD: ${LF_DB_PASSWORD}
POSTGRES_SEEDS: langfuse-db
networks: [core]
clinic-temporal-worker:
image: numoru/clinic-temporal-worker:latest
environment:
TEMPORAL_HOST: temporal:7233
LITELLM_URL: http://litellm:4000
depends_on: [temporal]
Compliance y regulación
- LFPDPPP (México): consentimiento explícito para memoria; endpoint
/patient/{id}/datapara acceso/borrado. - HIPAA (si paciente US): todo on-prem, sin datos fuera del droplet del cliente.
- AI Act (si paciente UE): transparencia en el canal de voz ("este asistente es IA"), logs auditables en Langfuse, plan de supervisión humana (fallback claro).
Tests y evals
- Unit tests de cada tool MCP.
- Integration tests del graph con Postgres embebido.
- Suite de evals con 80 guiones doradas por agente.
- E2E manual semanal: equipo de recepción revisa 10 conversaciones al azar.
Anti-patrones frecuentes
- Un solo agente que hace todo. Prompts de 5k tokens, evals imposibles, cambios riesgosos.
- Recordatorios por cron. Sin durabilidad, si el servidor se reinicia los pierdes. Temporal es la respuesta.
- Memoria global sin user_id. Data leak entre pacientes. Mandatorio
user_iden cada operación de Mem0. - Sin humano en el loop. Guardrails fallan alguna vez; hay que tener ruta clara de transferencia y alertas para casos de borde.
- Deploy sin backup. Un
drop table appointmentsaccidental es fatal. Restic diario mínimo.
FAQ
¿Se puede adaptar a otro vertical (bufete, veterinaria, spa)?Sí. Mismo esqueleto; cambian tools (catálogo de servicios), guardrails y tono. En general 1-2 semanas de adaptación por vertical nuevo.
¿Cuántos agentes puede aguantar el droplet de $40?Comodamente 3-5 clientes pequeños en el mismo droplet. A partir de 10, separar Temporal a su propia instancia.
¿Funciona sin Temporal?Se puede usar un simple cron + Postgres como cola. Funciona hasta que falla un reinicio y te das cuenta de por qué todos usan Temporal/Inngest.
¿Cómo manejas pacientes que no aceptan IA?Al inicio del canal voz/WhatsApp se les informa y pueden decir "quiero hablar con humano" — el graph transfiere inmediatamente.
¿Es más caro que contratar recepcionista? Sobre el 2do mes en volúmenes medios. Con clínicas muy pequeñas (<200 citas/mes), una recepcionista medio tiempo sigue siendo más barata.
Próximos pasos
Repo: github.com/numoru-ia/clinic-agents-langgraph. Incluye seeds de prompts, datos sintéticos para pruebas, playbooks de adaptación a otros verticales y guías de operación (runbook de incidentes, escalamiento, compliance). El siguiente artículo de la serie cubre evals CI/CD sobre este tipo de agentes.