IA & Backend · Deployed
Agente de WhatsApp multimodal
Agente de ventas por WhatsApp dentro de un solo Cloudflare Worker, estado en Durable Objects.
- Cloudflare Workers
- Durable Objects (SQLite)
- Gemini multimodal
- WhatsApp Cloud API
- TypeScript
Un agente de ventas/servicio multimodal por WhatsApp que vive entero en un Worker: sin VPS, sin Redis, sin cola externa. El estado por conversación vive en Durable Objects con SQLite, y el cerebro es Gemini llamado por
fetchdirecto. ~5.4k LOC de TypeScript, desplegado y atendiendo clientes reales.
Stack: Cloudflare Workers · Durable Objects (SQLite) · Workers KV · Gemini (multimodal, sin SDK) · WhatsApp Cloud API · Meta Conversions API · pdf-lib · OTLP→Grafana/Loki · TypeScript estricto Estado: Deployed Rol: diseño + desarrollo end-to-end (solo)
El problema
Un cliente quiere vender por WhatsApp con un bot que de verdad atienda: que entienda la foto que le manda el comprador, la nota de voz, el PDF; que no prometa lo que no existe; que cierre la venta o pase a un humano sin fricción. Las plataformas no-code (ManyChat, Chatfuel, Kommo) son árboles de decisión con un LLM pegado encima: no leen una imagen contra tu catálogo, no resuelven a qué producto se refiere un cliente cuando cita un mensaje de hace tres días, y te cobran por contacto mientras te encierran en su infra.
La primera versión de esto corrió sobre n8n + Redis. Dos dolores la mataron: Redis Cloud se saturaba con el batching, y el historial completo envenenaba el prompt en conversaciones largas. La pregunta de rediseño fue: ¿cuál es la pieza de estado mínima y dónde vive sin un servidor que mantener?
Qué construí
Un Worker que es a la vez el endpoint del webhook y el router, y delega TODO el estado y el razonamiento a un Durable Object por conversación (la clave del DO es el número de WhatsApp). El DO acumula mensajes, mantiene memoria corta, clasifica intención, y maneja el ciclo de vida de la conversación (activa → ganada / humano). Sobre un solo número conviven tres servicios distintos vía un menú-split: ventas de catálogo, intake documental para abrir una LLC en Florida, y agendamiento de citas. Cada uno es una rama aislada con su propio prompt y su propio estado.
El cliente al que sirve hoy es una tienda DTC de moda en Colombia. La arquitectura es multi-tenant: la persona, el catálogo, los precios y los textos son configuración, no código.
Decisiones técnicas interesantes
El diferenciador real: contexto multimodal del cliente. No es “el bot entiende imágenes”. Es que el bot razona sobre la procedencia de lo que el cliente manda y lo amarra al catálogo y al hilo:
- Foto → match visual contra catálogo. Cuando llega una imagen, una llamada a Gemini la compara por parecido (no por igualdad de pixeles) contra las fotos de referencia del catálogo. Devuelve la línea que coincide o
nullexplícito. El no-match es un resultado de primera clase: el bot dice “ese modelo no lo manejo” en vez de alucinar uno que sí. - Memoria de media por wamid, bidireccional. Cada imagen que el bot envía y cada foto que el cliente manda se recuerdan por su
wamid(el id de mensaje de WhatsApp). Cuando el cliente responde citando un mensaje viejo, el sistema resuelve qué producto era y quién lo mandó (catálogo del bot vs. foto del cliente), y se lo confirma antes de seguir. Eso es lo que las plataformas no-code no hacen: entender un reply a un mensaje de hace días. - Un solo turno multimodal. Texto + audio + imagen + documento llegan juntos, se descargan a base64 y se pasan a Gemini en una sola llamada
generateContent. La nota de voz se extrae a datos estructurados igual que el texto. El PDF se lee nativo (no OCR: Gemini consume el PDF porinlineData).
Anti-alucinación por procedencia, no por prompt. Esta fue la decisión más dura y la que más me enseñó. En el módulo de intake de LLC medí que pasarle a Gemini un responseSchema formal lo hace tratar el esquema como “llena este formulario”: fabricaba 41 campos —incluido un SSN inventado— con input mínimo, e ignoraba la instrucción anti-fabricación mientras el schema estuviera presente. Dos cambios lo arreglaron:
- Quitar el
responseSchemade la API y describir la forma del JSON como texto en la instrucción. La fabricación cayó a ~0; el modelo devuelvenulldonde no hay dato. - Un guardia de grounding determinista en código como backstop real. Sabe la procedencia de cada campo: el nombre y la fecha de nacimiento vienen de la imagen del ID (no se pueden text-groundear, se conservan); el nombre de la LLC, el agente registrado y el domicilio vienen solo del texto que el cliente escribió (un ID nunca trae datos del negocio). Cada campo de texto libre se valida contra la concatenación acumulada de todo lo que el cliente tecleó en la sesión: si menos del 60% de sus tokens significativos aparece ahí, el campo se anula porque el modelo lo inventó. Los números sensibles (SSN/ITIN/EIN) se validan por dígitos. Hay excepciones de espejo para los casos legítimos (el “responsible party” del SS-4 suele ser el fundador, cuyo nombre vino de la imagen). El mismo principio aplica al módulo de ventas: el prompt se arma con BASE + solo el módulo de la línea activa, así el modelo no puede cruzar los precios de cuatro líneas porque nunca ve más de uno a la vez.
Dos Durable Objects con responsabilidades distintas.
- Conversación — una sola alarma del DO, multiplexada entre dos propósitos: el batching de mensajes (un cliente manda cinco mensajes seguidos → se procesan como un turno, ventana de ~8s) y el nudge de re-engagement (~22h). En cada
ingestse persistenbatch_due_atynudge_due_at, yrearm()arma la alarma al evento más próximo. El handleralarm()es un orquestador: procesa el lote si hay pendientes, dispara el nudge si venció, re-arma. Cero cron, cero polling global. Mantener una sola alarma y multiplexarla evita el costo y la complejidad de un scheduler central. - AgendaBook — un segundo DO por negocio que actúa de portero anti-doble-booking. Un DO es single-threaded, así que dos reservas concurrentes del mismo horario se serializan: el segundo
/bookve la cita que el primero ya insertó y devuelve conflicto. El re-chequeo de disponibilidad vive dentro del/book, idéntico al de/check, no solo en el endpoint de consulta. Es el store y el lock a la vez, sin DB externa.
Un tercer DO, ReminderTimer (uno por cita), tiene su propia alarma para no chocar con el batching, y al disparar manda una plantilla aprobada con backoff de 5 min si falla.
Compliance de la ventana de 24h de WhatsApp, codificado. WhatsApp solo permite texto libre dentro de las 24h desde el último mensaje del usuario; fuera, únicamente plantillas aprobadas. El sistema respeta el límite por diseño: el nudge de re-engagement se programa a 22h precisamente para caer dentro de la ventana y poder ser un mensaje libre generado por el LLM (específico de dónde se quedó la charla, no un template genérico). El recordatorio de cita, que cae fuera, usa una plantilla aprobada. Si la ventana ya cerró, el nudge libre se aborta y se loguea, en vez de mandar algo que Meta rechazaría.
Llenado de PDFs gubernamentales, XFA-safe. Cuando un expediente de LLC queda completo, el Worker genera el IRS Form SS-4 (EIN) y los Florida Articles of Organization rellenados con pdf-lib (JS puro, corre en el Worker). El detalle no obvio: los formularios del IRS son híbridos AcroForm + XFA, y si rellenas solo el AcroForm muchos visores muestran la capa XFA vacía encima y el texto “desaparece”. La solución es eliminar el diccionario XFA y aplanar el formulario para quemar los valores en el contenido visible. Los nombres internos de los campos no siguen ningún patrón legible; los mapeé inspeccionando el AcroForm real y correlacionando las coordenadas de cada widget con el layout de texto del formulario.
Detalles que solo se ven en producción. Los documentos van con una key única por emisión porque Meta cachea por URL y serviría una versión vieja. Entre envío y envío de varias imágenes/documentos hay pausas deliberadas porque WhatsApp descarta o reordena envíos seguidos. El texto del bot lo escribe el modelo en español correcto y una capa determinista lo “despeina” después (minúsculas, sin tildes) para que suene a WhatsApp real —separar la corrección de la presentación hace imposible que el estilo rompa un precio o una palabra.
Observabilidad sin filtrar PII. Cada evento de negocio se emite como una línea JSON estructurada que console.log exporta a Grafana/Loki vía OTLP. El identificador de conversación nunca va crudo: se loguea un hash corto no reversible del número. El expediente de intake (que contiene ITIN, cédula) se auto-borra del DO tras 24h de inactividad, y los archivos generados en KV expiran en 24h.
Panel admin autocontenido. El Worker sirve su propio dashboard (HTML embebido, sin build aparte) para ver conversaciones por etapa y editar los textos/prompts del bot en caliente, protegido por token. Como los DOs no se pueden enumerar, mantengo un índice de conversaciones en la metadata de las keys de KV: un solo list lo devuelve sin leer cada objeto.
Arquitectura
Qué demuestra
- Diseño de estado en el edge: elegir Durable Objects + SQLite en vez de Redis/colas, y exprimirlos (una alarma multiplexada, un DO como lock de concurrencia) en vez de añadir infraestructura.
- LLMs en serio, no “llamar a la API”: medir y derrotar un modo de fallo concreto (fabricación inducida por el schema) con un guardia de procedencia determinista, y diseñar prompts modulares para que el modelo no pueda equivocarse de precio.
- Integración profunda con APIs hostiles: la ventana de 24h de WhatsApp, el caché por URL de Meta, el reordenamiento de envíos, los formularios híbridos XFA del IRS. Cosas que solo aparecen cuando algo está en producción.
- Criterio de producto: el foso competitivo (contexto multimodal del cliente) está implementado, no prometido, y la arquitectura es multi-tenant desde el día uno.
- Higiene: TypeScript estricto, PII hasheada en logs, expiración de datos sensibles, fallbacks best-effort que nunca rompen el flujo de venta.
Estado y siguiente paso
Desplegado en Cloudflare y atendiendo a un cliente real (~5/mes de infra, BYO-key de Gemini en centavos). El módulo de ventas está maduro; el de agenda quedó verificado end-to-end (extracción de fechas relativas reales, AgendaBook contra carreras, booking→recordatorio); el de intake de LLC funciona con su guardia de grounding y emite los tres documentos. La atribución a Meta (Conversions API) y el espejo a Chatwoot están cableados en modo esqueleto: se “encienden” poniendo los secrets, sin tocar código. Lo siguiente es empaquetarlo como oferta reusable —cada vertical nuevo es un módulo de prompt + un store enchufable, no un fork.