Storía
Una plataforma multi-tenant que le da a un negocio colombiano un agente de ventas con IA en WhatsApp, Messenger, Instagram, Facebook Marketplace y la línea telefónica. Uno que lee el catálogo real, manda las fotos, agenda la visita, y le pasa la conversación a una persona en el momento en que debe. Español primero. Construido solo, de punta a punta.
- Rol
- Único ingeniero: producto, esquema, IA, front-end, móvil
- Ventana de construcción
- 2026-02-12 → 2026-02-15
- Salió de
- Luxura →
- Etapa
- Construido en feb 2026, sin tocar desde entonces, sin piloto
$ cat ./examples/whatsapp-thread.txt
Hola, vi el iPhone en Marketplace. ¿Todavía lo tienen?
Sí señor, todavía está. El 13 Pro de 256, en $2.850.000.
¿Tiene garantía? ¿Y me mandas fotos?
Garantía de 6 meses por la tienda. Ya te mando las fotos 📸
Listo. ¿Puedo pasar mañana a verlo?
Claro, mañana estamos de 9 a 6. ¿A qué hora te queda bien?
Un ejemplo trabajado, no una transcripción. Detrás de esas seis líneas: contexto de referencia del anuncio de Marketplace · búsqueda híbrida de productos (filtros SQL + tsvector + pgvector) · tres imágenes enviadas y de-duplicadas · lead puntuado y escrito en el CRM · cita marcada para la bandeja humana.



$ cat ORIGIN.md
Empezó como la tarea de más abajo de otro producto.
La idea salió de una tarea de otro producto. Durante febrero atendía las cuentas sociales de Luxura a mano, todos los días: publicaba los creativos que le había hecho, escribía los textos, contestaba lo que llegara a las bandejas. Storía nunca ha tenido una campaña propia. Salió del trabajo de correr la de otro.
En algún punto de esa rutina me pregunté por qué lo hacía a mano. Por esos mismos días unos amigos míos, dueños de tiendas pequeñas acá en Bogotá, me dijeron que no daban abasto con su WhatsApp. Los clientes escribían a toda hora pidiendo precios y fotos, y los mensajes se quedaban sin respuesta hasta que el cliente compraba en otro lado. Yo me estaba ahogando en trabajo social de salida y ellos se estaban ahogando en mensajes de entrada, y era el mismo ahogo. Una persona sola atendiendo varios canales, sin tiempo. Así que Storía arrancó por la mitad de ellos: un agente que lee el catálogo real, manda las fotos, contesta en español, y le pasa la conversación a una persona cuando debe.
$ cat README.md
En Colombia, la vitrina es un número de WhatsApp.
Las pymes de acá no venden por un checkout web. Venden por un teléfono. Un anuncio de Facebook Marketplace, una historia de Instagram, un aviso impreso. Todos desembocan en la misma bandeja de WhatsApp, y alguien tiene que contestar "¿todavía lo tienen?" cuarenta veces al día.
Los chatbots de caja fallan acá por tres razones concretas. Son árboles de decisión, así que se caen apenas el cliente se sale del guion. Están traducidos del inglés, así que suenan a call center leyendo un libreto, y un comprador colombiano lee eso al instante como una estafa. Y son ciegos al inventario real: no pueden decirte si queda el azul en talla M.
La apuesta: darle al modelo herramientas reales en vez de un diagrama de flujo. Que busque en el catálogo, mande las fotos, escriba en el CRM, y escale a una persona, y gastar el presupuesto del prompt en sonar como alguien de Bogotá, no en reglas.
$ ./bin/stat
$ cat architecture.md
Un mensaje entrante, de punta a punta.
Un solo repo de Next.js más un proceso worker aparte. Los webhooks confirman en milisegundos y no hacen nada más; todo lo caro ocurre en un job encolado, que es lo que evita que Meta dé de baja el webhook bajo carga.
receive webhook verify HMAC · drop replays via Redis · enqueue · 200 │ // no AI call on this thread ▼ queue BullMQ 5 priority queues · payloads carry IDs only │ // messages · knowledge · analytics · broadcasts · notifications ▼ think orchestrator security guard → RAG over pgvector → memory of past wins │ → model with 10 tools → second pass to phrase the results ▼ reply adapter rewrite markdown per platform · text then images · mirror into the live inbox
Cada uno de los 25 modelos lleva un organizationId, cada consulta pasa por un solo helper de tenant, y cada llave foránea está indexada. Las búsquedas por similitud con pgvector son el único lugar donde se permite SQL crudo, y esas filtran por organizationId en el WHERE antes de que corra el operador de distancia, así los embeddings de un tenant nunca pueden rankear contra los de otro. Una convención se habría filtrado la primera vez que alguien escribiera una consulta cansado.
$ ls -la ./hard-parts
Cuando una conversación termina, un modelo barato extrae un insight estructurado (intención, qué funcionó, qué falló, un tipo de resultado y un puntaje), validado contra un esquema y guardado con su propio embedding. En el siguiente mensaje entrante el orquestador busca por vectores entre esos insights e inyecta los dos más cercanos en el system prompt. El filtro es lo que importa: solo califican los resultados con puntaje de 0,6 hacia arriba. Un agente que aprende de todo aprende a repetir sus propios fracasos. Sin piloto todavía, el ciclo solo ha corrido sobre conversaciones de prueba, así que el mecanismo del que estoy más orgulloso es el que menos evidencia tiene.
"Algo elegante para una fiesta" y "zapatos Nike talla 42 menos de 300 mil" llegan al mismo código. Una consulta combina filtros por columna tipada, atributos JSONB personalizados, arreglos de tags, full-text de Postgres y similitud coseno por vectores. Degrada en vez de romperse: un producto sin embedding cae en silencio a búsqueda por texto en vez de devolver nada, porque devolverle nada a un comprador es como lo pierdes.
Cualquiera puede escribirle al bot y cada mensaje cuesta plata. Doce módulos se sientan delante y detrás del modelo: detección de inyección de prompts, filtrado de contenido, rate limits de ventana deslizante con endurecimiento adaptativo, presupuesto de tokens por tenant, circuit breaker sobre el proveedor de IA, de-duplicación y frescura de webhooks, redacción de datos personales a la salida. Las imágenes también se escanean. Instrucciones incrustadas en una foto esquivan todo filtro de texto, y eso es una clase de ataque actual, no una hipótesis.
El modelo escribe markdown estándar. WhatsApp quiere un solo asterisco. Messenger no renderiza nada por la API. Instagram no renderiza nada y corta a los 1.000 caracteres. La voz necesita la puntuación limpia antes del text-to-speech. Así que hay un formateador por canal, y es el único módulo del repo con tests unitarios, porque un regex que reescribe énfasis anidado sin comerse los bloques de código es justo el código que se rompe en silencio y toma una semana en notarse.
El agente puede mandar imágenes de producto. Ingenuamente eso significa reenviar las mismas tres fotos cada vez que el cliente vuelve a mencionar el producto, lo que se lee como algo roto y cuesta plata en cada envío. El arreglo es un conjunto de URLs ya enviadas, armado desde el historial de la conversación. Lo difícil fue la frase siguiente: cuando las imágenes se filtran, el resultado de la herramienta tiene que decirle al modelo explícitamente que no va a salir ninguna, o promete alegremente fotos que nunca llegan.
$ diff --chosen --rejected
| Elegí | En vez de | La razón |
|---|---|---|
| Meta Cloud API | 360dialog · Twilio | Cero comisión de plataforma y cero recargo por mensaje. Un BSP es comodidad que estaría alquilando para siempre en un producto cuyo margen entero es volumen de mensajes. |
| Postgres + pgvector | Pinecone · Weaviate | El corpus es chico y siempre está filtrado por tenant. Una base vectorial dedicada agrega una segunda frontera de consistencia para resolver un problema que no tengo. |
| BullMQ + Redis | Colas gestionadas | Todo esto tiene que poder correr en una sola máquina. Redis ya estaba ahí para caché, rate limits y pub/sub. |
| Wompi | Stripe | Stripe no da de alta comercios colombianos. Esto se construyó sobre Stripe primero y se arrancó el mismo día, seis horas después. |
| Capacitor | React Native | El panel ya era una web app responsive. Capacitor compró un APK real, push y login nativo sin un segundo codebase. |
| Dos modelos, uno caro y uno barato | Un solo modelo | El caro habla con clientes. El barato hace sentimiento, clasificación y extracción de insights. Mismo resultado, una fracción de la cuenta. |
$ cat voice.md
El prompt es el producto.
El archivo de mayor apalancamiento en este repo es un documento de investigación: por qué los chatbots suenan a chatbot, y el constructor de prompts que actúa sobre ella. Un comprador colombiano abandona la conversación en el segundo en que huele a automatizado, así que "suena humano" es un requisito funcional, no un acabado.
- Persona antes que reglas. El system prompt abre con quién es el agente y cierra con las restricciones, no al revés.
- Una lista explícita de lo que nunca se hace. No presentarse como asistente virtual. No repetir la pregunta. Nada de viñetas, porque nadie manda una lista con viñetas por WhatsApp. Nada de titubeos: si el precio está en la base de conocimiento, se dice como lo diría un vendedor.
- Los presets de tono llevan ejemplos trabajados, no adjetivos. "Amigable" como palabra clave no produce nada. Un intercambio corto de muestra en el registro correcto produce el registro.
$ cat method.md
Cuatro días solo son posibles con tres artefactos.
- Una especificación de 1.045 líneas, escrita primero. Modelo de datos, fases, contratos de API, preguntas abiertas. Resueltas antes de la primera línea de código, y equivocadas sobre el rail de pagos, que el retro de abajo cubre.
- Restricciones permanentes en el repo. Server components por defecto, cada consulta acotada al tenant, validación de esquema en cada entrada externa, los webhooks responden 200 y luego encolan, el modelo barato para clasificar y el caro solo para clientes.
- Un registro de decisiones escrito para que se le discuta: comparaciones de proveedores, el modelo de amenazas, una prueba comparativa de síntesis de voz.
Cuatro días de eso produjeron 49.000 líneas y un archivo de test, que es el intercambio que esos artefactos compraron y no escondieron.
$ cat STATUS.md
Storía es un prototipo.
La construí en cuatro días a mediados de febrero, el mismo mes en que salió Luxura, y no he commiteado desde entonces. Hay 54 commits, 25 modelos de Prisma, 10 herramientas de agente, y un archivo de test. Ninguna tienda la ha corrido. Nadie ha pagado por ella. El código puede puntuar un lead y agendar una cita, y nada de eso se ha enfrentado a un cliente real todavía.
Lo que el proyecto necesita ahora es una tienda en Bogotá corriéndolo en su línea real de WhatsApp durante un mes, para averiguar cuáles de las 49.000 líneas importan y cuáles eran suposiciones. La arquitectura de colas está hecha para concurrencia y nunca ha corrido bajo ella, así que ese mes sería también la primera prueba de carga. Hasta que eso pase, más funciones serían solo más suposiciones.
$ cat RETRO.md
Qué haría distinto
- Testear el orquestador desde la primera hora. No la interfaz. El ciclo de llamado a herramientas. Tiene la mayor cantidad de ramas, la mayor cantidad de plata encima y la menor determinación, y es justo el código que un agente va a romper con toda confianza durante un refactor. El formateador tiene tests porque me quemé una vez; el orquestador debió tenerlos primero.
- Verificar los rieles de pago antes del esquema. Stripe sobrevivió seis horas y costó una migración deshacerlo, porque no da de alta comercios colombianos. Un chequeo de quince minutos lo habría atrapado antes de que un solo modelo llevara su nombre.
- Escribir el modelo de amenazas el día uno, no el día tres. El día tres funcionó y la implementación lo siguió con fidelidad, pero significó meterle un guardia de seguridad a un orquestador que ya había pasado las mil líneas, y las costuras se notan.
- Sacar un canal a un negocio real antes de construir cinco. La amplitud sirve de verdad y también es la razón por la que no hay números de piloto en esta página. Una sola tienda de Bogotá en WhatsApp me habría enseñado más que Instagram y voz juntos.