Storía
A multi-tenant platform that gives a Colombian business an AI sales agent on WhatsApp, Messenger, Instagram, Facebook Marketplace and the phone line. One that reads the real catalogue, sends the photos, books the visit, and hands off to a human the moment it should. Spanish first. Built solo, end to end.
- Role
- Sole engineer: product, schema, AI, front-end, mobile
- Build window
- 2026-02-12 → 2026-02-15
- Came out of
- Luxura →
- Stage
- Built Feb 2026, untouched since, no pilot yet
$ 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?
One worked example, not a transcript. Behind those six lines: referral context from the Marketplace listing · hybrid product search (SQL filters + tsvector + pgvector) · three images sent and de-duplicated · lead scored and written to the CRM · appointment flagged for the human inbox.



$ cat ORIGIN.md
It started as the chore at the bottom of another product.
The idea came out of a chore on a different product. Through February I was tending Luxura's social accounts by hand, every day: posting the creative I had made for it, writing the captions, answering whatever came back in the inboxes. Storía has never had an ad campaign of its own. It came from the work of running someone else's.
Somewhere in that grind I asked why I was doing it by hand. Around the same time some friends of mine, small retail shop owners here in Bogotá, told me they could not keep up with their WhatsApp. Customers wrote at all hours asking for prices and photos, and the messages sat unanswered until the customer bought somewhere else. I was drowning in outbound social work and they were drowning in inbound customer messages, and it was the same problem. One person, several channels, no time. So Storía started with their half of it: an agent that reads the real catalogue, sends the photos, answers in Spanish, and hands the conversation to a human when it should.
$ cat README.md
In Colombia, the storefront is a WhatsApp number.
Small and mid-size businesses here do not sell through a website checkout. They sell through a phone. A Facebook Marketplace listing, an Instagram story, a printed sign. Every one of them funnels into the same WhatsApp inbox, and someone has to answer "¿todavía lo tienen?" forty times a day.
Off-the-shelf chatbots fail here for three specific reasons. They are decision trees, so they collapse the moment a customer deviates. They are translated from English, so they sound like a call center reading a script, and a Colombian buyer reads that instantly as a scam. And they are blind to the actual inventory: they cannot tell you whether the blue one in size M is left.
The bet: give the model real tools instead of a flowchart. Let it search the catalogue, send the photos, write to the CRM, and escalate to a person, and spend the prompt budget on sounding like somebody from Bogotá rather than on rules.
$ ./bin/stat
$ cat architecture.md
One inbound message, end to end.
One Next.js codebase plus a separate worker process. Webhooks acknowledge in milliseconds and do nothing else; everything expensive happens in a queued job, which is what keeps Meta from de-registering the webhook under load.
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
Every one of the 25 models carries an organizationId, every query goes through one tenant helper, and every foreign key is indexed. The pgvector similarity searches are the only place raw SQL is allowed, and those filter on organizationId in the WHERE clause before the distance operator ever runs, so one tenant's embeddings can never rank against another's. A convention would have leaked the first time somebody wrote a query while tired.
$ ls -la ./hard-parts
When a conversation ends, a cheap model extracts a structured insight (intent, what worked, what failed, an outcome type and a score), validated through a schema and stored with its own embedding. On the next inbound message the orchestrator vector-searches those insights and injects the two closest into the system prompt. The filter matters: only outcomes scoring 0.6 and above are eligible. An agent that learns from everything learns to repeat its own failures. With no pilot yet, the loop has run on test conversations only, so the mechanism I am proudest of is the one with the least evidence behind it.
"Algo elegante para una fiesta" and "zapatos Nike talla 42 menos de 300 mil" hit the same code. One query combines typed-column filters, JSONB custom attributes, tag arrays, Postgres full-text and vector cosine similarity. It degrades rather than breaks: a product with no embedding yet silently falls back to text search instead of returning nothing, because returning nothing to a buyer is how you lose them.
Anyone can message the bot and every message costs money. Twelve modules sit in front of and behind the model: prompt-injection detection, content filtering, sliding-window rate limits with adaptive tightening, a per-tenant token budget, a circuit breaker on the AI provider, webhook de-duplication and freshness, PII redaction on the way out. Images get scanned too. Instructions embedded in a photo bypass every text-based filter, which is a current attack class rather than a hypothetical.
The model writes standard markdown. WhatsApp wants single asterisks. Messenger renders nothing through the API. Instagram renders nothing and truncates at 1,000 characters. Voice needs the punctuation stripped before text-to-speech. So there is a formatter per channel, and it is the only module in the repo with unit tests, because regex that rewrites nested emphasis without eating code blocks is exactly the code that breaks silently and takes a week to notice.
The agent can send product images. Naively that means re-sending the same three photos every time the customer mentions the product again, which reads as broken and costs money on every send. The fix is a set of already-sent URLs assembled from the conversation history. The hard part was the sentence after it: when images are filtered out, the tool result has to tell the model explicitly that none will go out, or it cheerfully promises photos that never arrive.
$ diff --chosen --rejected
| Chose | Instead of | The one reason |
|---|---|---|
| Meta Cloud API | 360dialog · Twilio | Zero platform fee and zero per-message markup. A BSP is convenience I would be renting forever on a product whose whole margin is message volume. |
| Postgres + pgvector | Pinecone · Weaviate | The corpus is small and always tenant-filtered. A dedicated vector DB adds a second consistency boundary to solve a problem I do not have. |
| BullMQ + Redis | Managed queues | The whole thing has to be able to run on one box. Redis was already there for caching, rate limits and pub/sub. |
| Wompi | Stripe | Stripe does not onboard Colombian merchants. This was built on Stripe first and ripped out the same day, six hours in. |
| Capacitor | React Native | The dashboard was already a responsive web app. Capacitor bought a real APK, push and native sign-in without a second codebase. |
| A cheap model and an expensive one | One model everywhere | The expensive one talks to customers. The cheap one does sentiment, classification and insight extraction. Same result, a fraction of the bill. |
$ cat voice.md
The prompt is the product.
The highest-leverage file in this repo is a research document: why chatbots sound like chatbots, and the prompt builder that acts on it. A Colombian buyer abandons the conversation the second it smells automated, so "sounds human" is a functional requirement, not a finish.
- Persona before rules. The system prompt opens with who the agent is and closes with constraints, not the other way round.
- An explicit list of what never to do. No introducing itself as a virtual assistant. No repeating the question back. No bullet points, because nobody sends a bulleted list on WhatsApp. No hedging: if the price is in the knowledge base, say it like a salesperson would.
- Tone presets carry worked examples, not adjectives. "Friendly" as a keyword produces nothing. A short sample exchange in the right register works.
$ cat method.md
Four days is only possible with three artifacts.
- A 1,045-line specification, written first. Data model, phases, API contracts, open questions. Settled before the first line of application code, and wrong about the payment rail, which the retro below covers.
- Standing constraints in the repo. Server components by default, every query tenant-scoped, schema validation on every external input, webhooks return 200 then queue, the cheap model for classification and the expensive one only for customers.
- A decision log written to be argued with: provider comparisons, the threat model, a speech-synthesis bake-off.
Four days of it produced 49,000 lines and one test file, which is the trade those artefacts bought.
$ cat STATUS.md
Storía is a prototype.
I built it in four days in mid February, in the same month Luxura launched, and I have not committed to it since. There are 54 commits, 25 Prisma models, 10 agent tools, and one test file. No shop has run it. Nobody has paid for it. The code can score a lead and book an appointment, and none of that has faced a real customer yet.
What the project needs next is one shop in Bogotá running it on their actual WhatsApp line for a month, so I can find out which of the 49,000 lines matter and which were guesses. The queue architecture is built for concurrency and has never run under it, so that month would be the first load test too. Until it happens, more features would add more guesses.
$ cat RETRO.md
What I would do differently
- Test the orchestrator from the first hour. Not the UI. The tool-calling loop. It has the most branches, the most money attached and the least determinism, and it is exactly the code an agent will confidently break during a refactor. The formatter got tests because I got burned once; the orchestrator should have had them first.
- Verify the payment rails before the schema. Stripe survived six hours and cost a migration to unwind, because it does not onboard Colombian merchants. A fifteen-minute check would have caught it before a single model was named after it.
- Write the threat model on day one, not day three. Day three worked and the implementation followed it faithfully, but it meant retrofitting a security guard into an orchestrator that had already grown past a thousand lines, and the seams are visible in the code.
- Ship one channel to one real business before building five. The breadth is genuinely useful and it is also the reason there are no pilot numbers on this page. A single Bogotá shop on WhatsApp alone would have taught me more than Instagram and voice combined.