Martita
Recepcionista de IA en WhatsApp para pymes mexicanas. Proactiva por diseño — un heartbeat de cron que actúa antes de que alguien escriba. 21 herramientas, 2,640 pruebas, construida en solitario.
Martita es una recepcionista de WhatsApp para los pequeños negocios mexicanos que viven de las citas: clínicas dentales, salones, consultorios médicos, fisioterapeutas. Sus clientes escriben a un número normal de WhatsApp y Martita responde en español mexicano, cotiza servicios y precios, revisa disponibilidad real, agenda, reagenda y cancela, y lo escribe todo en el Google Calendar del dueño.
El dueño usa el mismo número para operar el negocio. “¿Qué tengo mañana?”, “bloquéame el viernes de 2 a 4”, “agenda a María el lunes a las 3.” El mismo número de teléfono, distinta persona, distintos permisos.
La empecé en julio de 2025, hice suficiente desorden como para tirar el código a la basura, y volví a empezar desde cero en diciembre. Esta es la segunda: siete meses, 587 commits, 29,000 líneas de código de aplicación y unas 56,000 líneas de pruebas.
Dónde está, honestamente
Dos tenants en producción: uno es el negocio real de un amigo corriendo como demo, el otro es mío para desarrollo. Nadie está pagando todavía. La facturación es una página simulada que guarda un timestamp; Stripe está definido pero no conectado. Estoy usando el periodo de amigos y familia para descubrir qué se rompe de verdad con clientes reales del otro lado antes de cobrarle a alguien.
La apuesta: actúa primero
Los chatbots que solo responden son un commodity. Cualquiera puede conectar Claude a un webhook y contestar preguntas. Lo difícil — y donde puse la mayor parte del esfuerzo — es una asistente que hace trabajo cuando nadie le ha escrito.
Un heartbeat de cron corre cada treinta minutos y revisa el estado del mundo: recordatorios de cita con 24 horas de anticipación, un briefing matutino compuesto para el dueño entre las 7 y las 8, seguimientos post-visita a media mañana, un reporte diario de salud de la plataforma.
Dos restricciones dan forma a todo ese subsistema. WhatsApp solo permite mensajes libres dentro de las 24 horas del último mensaje entrante del cliente, así que cada envío proactivo verifica la ventana con margen de seguridad y cae a una plantilla aprobada fuera de ella. Y cada envío queda en una bitácora de auditoría detrás de una restricción de unicidad, con los recordatorios reclamando su lugar antes de enviar, porque un heartbeat que dispara dos veces no debe escribirle dos veces a un paciente.
Una llamada a Claude, 21 herramientas
No hay ningún if 'agendar' in message en toda la base de código, y eso es una regla aplicada, no un accidente. Entender el español mexicano — “el jueves que viene tempranito”, servicios implícitos, tres ideas en un solo mensaje — es trabajo del modelo. Una sola llamada a Claude Haiku con 21 definiciones de herramientas decide qué hacer, en un loop con tope de cinco iteraciones.
El system prompt no es estático. Hasta once bloques de contexto se ensamblan por mensaje, cada uno con su propia consulta acotada: el negocio y sus servicios, quién es este cliente y qué falta para que pueda agendar, sus próximas citas, preferencias guardadas, notas del dueño, memoria entre conversaciones, y una tabla de contenido de la base de conocimiento del negocio para que el modelo sepa qué puede consultar.
Dos de esos bloques se computan en lugar de consultarse. Un perfil de comportamiento derivado de hasta 50 citas pasadas produce un nivel de riesgo de cancelación, la hora a la que esta persona suele agendar, y cuánto suele pasar entre visitas — suprimido debajo de tres citas, porque tres puntos no son un patrón. De ahí sale una sola oración accionable: quien cancela repetidamente recibe confirma disponibilidad antes de comprometerte; alguien con 90 días de ausencia recibe dale la bienvenida de vuelta, sugiere lo de siempre.
El detalle que más me gusta cuesta 40 tokens. Un calendario precomputado de 14 días se inyecta en cada prompt con la instrucción de consultarlo y nunca calcular de memoria. Los LLMs son malos para “¿qué día de la semana cae el 8?”, y ese único bloque eliminó una clase entera de citas agendadas el día equivocado.
La acción fantasma
La falla que más me preocupaba no es una caída del sistema. Es que Martita diga “¡Listo! Tu cita quedó agendada para el viernes a las 3” sin haber llamado a book_appointment. El paciente llega. No hay nada en el calendario. Eso pasa una vez y el negocio no vuelve a confiar en el producto — y es silencioso: sin error, sin alerta, sin nada que despierte al on-call.
Así que cada mensaje saliente se verifica antes de enviarse. Siete clases de acción (agendar, cancelar, reagendar, bloquear tiempo, quitar un bloqueo, prometer avisar a alguien, cambiar consentimiento) tienen patrones en español afinados que buscan lenguaje de confirmación, cruzados contra lo que el modelo realmente llamó. La supresión exige que la herramienta haya corrido y devuelto su prefijo de éxito. Nombrar la herramienta no cuenta. Si hay coincidencia, la respuesta se reemplaza con un mensaje de recuperación y la traza queda etiquetada.
Las sutilezas son el trabajo, y cada una es un bug que se le escapó a una versión anterior del guardián:
- “No tienes ninguna cita agendada” contiene el patrón pero afirma una ausencia, así que las coincidencias se revisan hacia atrás buscando negación — solo dentro de la misma cláusula.
- Los límites de cláusula tienen que incluir pero, aunque y sin embargo, porque el español une una ausencia con una afirmación sin puntuación alguna: “aún no recibo el pago pero tu cita está confirmada.” Sin ellos, el no inicial desarma al guardián frente a una alucinación real.
- Dos tokens de negación están deliberadamente acotados, porque un token que dispara mal desarma al guardián y deja pasar una alucinación, lo cual es mucho peor que un falso positivo.
- Las coincidencias suprimidas por negación se registran de todas formas, para que la sobre-supresión sea medible en lugar de invisible.
No duplicarle la cita a nadie
Cuatro capas, porque ninguna basta por sí sola: la disponibilidad se revisa antes de ofrecer cualquier horario; un advisory lock de Postgres por negocio envuelve la ruta de agendado para atrapar la carrera select-then-insert; la disponibilidad se re-verifica dentro de ese lock para atrapar horarios que se traslapan sin ser idénticos; y una restricción única sobre la llave del horario atrapa el minuto de inicio idéntico, con el error de integridad saliendo como un reintento cortés en español.
La disponibilidad misma es la intersección de los horarios del negocio esa semana, las citas existentes más un margen, los bloqueos del dueño, los eventos en vivo de Google Calendar, y el tiempo de traslado entre ubicaciones físicas — esto último porque el negocio piloto trabaja en dos pueblos y lleva un calendario por ubicación. Martita no ofrece las 10:00 en un pueblo cuando hay una cita a las 09:30 en el otro.
Multi-tenancy, defendida de cuatro formas
Un solo despliegue sirve a todos los negocios. El aislamiento se aplica en cuatro capas independientes: cada consulta está explícitamente acotada y el resolver lanza una excepción en lugar de asumir el tenant 1; un hook de SQLAlchemy inspecciona cada sentencia antes de ejecutarla y lanza en producción si una tabla de tenant no está acotada; un lint de CI basado en AST bloquea el merge; y llaves de API HMAC por negocio acotan la superficie de administración.
El riesgo residual está escrito. Las búsquedas por llave primaria evitan la red de seguridad en runtime por diseño, así que quien llama debe verificar el tenant después de consultar. Es una convención que se hace cumplir en revisión de código, no en el compilador.
Cómo se prueba
2,640 pruebas contra 29,000 líneas de código de aplicación — cerca de dos líneas de prueba por línea de app. Un carril separado de CI corre contra Postgres real para todo lo que SQLite no puede modelar — advisory locks, comportamiento real de restricciones — y está armado para fallar si recolecta cero pruebas, porque un carril verde debería tener que demostrar que corrió.
Más allá del linting, cuatro compuertas propias bloquean merges: análisis de acotamiento por tenant, una compuerta de PII que corre en CI y como pre-commit hook para que los datos de clientes no entren a git, un guardián de ciclos de import, y una prueba de drift que asegura que la agente no pueda describir una capacidad que no existe.
Y 16 escenarios dorados corren contra el modelo en vivo antes de cualquier cambio de prompt o herramientas: agendado, disciplina de disponibilidad, permisos de cancelación por persona, agendado por terceros, escalamiento, y dos casos anti-fantasma explícitos. Las pruebas unitarias estructuralmente no pueden atrapar una regresión de prompt. Ese arnés sí, y encontró el bug donde Claude habla antes de llamar una herramienta y luego termina el turno con contenido vacío.
Lo que decidí no construir
Sin Redis, sin Celery, sin Kubernetes, sin base de datos vectorial, sin microservicios, sin paso de build en el frontend. Todo el producto es un proceso de Flask y una base de datos Postgres, con HTMX haciendo el trabajo que de otro modo haría una app de React. Es barato de operar y cabe en la cabeza de una persona, lo cual importa cuando esa persona es toda la rotación de on-call.
Eso tiene un costo, y la versión honesta es esta: el trabajo en segundo plano corre en un thread pool dentro del proceso y no en una cola durable, así que un reinicio a media tarea pierde esa respuesta. Los límites de tasa y las métricas son por proceso y en memoria, lo que significa que con dos workers los límites efectivos son aproximadamente el doble de lo configurado. Ambas cosas están bien a escala de piloto y ambas necesitan reemplazo antes de volumen real. Son lo siguiente, junto con Stripe.
Stack
Python 3.12, Flask 3.1, SQLAlchemy 2.0 con modelos tipados, Alembic, Postgres en Supabase. Claude Haiku 4.5 con prompt caching y un circuit breaker. La WhatsApp Cloud API de Meta, con Kapso como proxy para el aprovisionamiento multi-tenant que Meta hace doloroso. Jinja2, HTMX y Tailwind para todas las superficies. Gunicorn en Railway, Langfuse para trazas de la agente, Sentry para errores.
Martita es también donde pruebo patrones de agentes en producción antes de que lleguen al trabajo empresarial: loops de evaluación, salidas estructuradas, observabilidad de costo y latencia, y lo que de verdad se necesita para mantener honesta a una agente.