# TomaNota: An assistant that takes orders on WhatsApp without being able to invent a price.

> It answers the shop's WhatsApp, builds the order with the real menu and drops it on the same board as the QR menu and the counter. The AI interprets; the server calculates the totals.

Industry: My own product, for shops · Status: In production · Developed by Ricardo Brossard
URL: https://ricardobrossard.com/en/projects/tomanota/

- tomanota.lat: https://tomanota.lat
- Try the assistant: https://app.tomanota.lat/probar

## By the numbers

- **900+** automated tests
- **1,203** simulated conversations against the real model
- **8** tools for the model, none of which accepts a price
- **46** tables with per-shop permissions (RLS)

## The problem

The neighborhood pizzeria, takeout shop or butcher sells on WhatsApp. Customers write the way they talk ("two large and one half mozzarella, half Neapolitan"), ask about delivery and wait for someone to confirm. That someone is the owner, who is also working the counter at the same time.

At peak hours the messages pile up, orders get written on paper with mistakes in the halves and the totals, and at the end of the month nobody knows how much came in through that channel. Delivery apps charge a commission on customers the shop already has, and a bot with canned replies does not understand how people write.

## What I built

**TomaNota**, my own product, with three ways in that all end on the same board:

- **A WhatsApp assistant** that chats in natural language, builds the order with the shop's real menu, shows a summary and waits for the person to tap Confirm. If the customer asks to talk to someone, or the owner writes from their phone, it steps aside.
- **An online menu with a QR code**, for customers who prefer to choose without typing.
- **Counter orders**, for whatever comes in person or by phone.
- **The shop's screen**: the real-time board with every status, the catalog, the delivery zones, the assistant's texts and a monthly report by channel.

Inside, there are three pieces. A **conversation engine** in TypeScript with no network or disk access, which runs the same in production and in the tests. A **Fastify server** that connects it to WhatsApp, to Redis and to the model, through the **Vercel AI SDK over an OpenAI-compatible API**. And **PostgreSQL on Supabase**, with row-level security on all 46 tables: each shop sees only its own data, and the server connects with a role that cannot bypass those permissions.

Before the AI steps in, every message goes through **fixed checks**: one turn per chat at a time (two messages in a row add both things to the cart), discarding webhook retries, staying quiet when the owner writes, and a cut-off at 60 replies per hour that stops two bots answering each other in a loop.

## The hardest part

### Making sure the model cannot invent an amount

If the assistant states a price different from the menu, the shop loses money or argues with a customer who has the screenshot. I solved it by design: **the model has no way to write a price into the order.**

It works with **8 closed tools** (add, half and half, extra, change quantity, remove, delivery, show the cart and signal that it is ready) and only emits **catalog ids and quantities**. A sold-out product is not even among the valid ids. The server runs each tool, does the math and writes the summary: the text the person confirms is never written by the model.

On confirmation, a single database function quotes everything again at that moment's prices, with an idempotency key so a retry does not duplicate the order. The bot, the menu and the counter all go through that same function.

The half pizza put it to the test: on a real menu, the half is not half the price of the whole. I found four places that divided by two when the data was missing, including the rule that evaluated the assistant. Today the half has its own price and, if it is not set, that product is only sold whole.

### The confirmation belongs to the person

The tool that signals "ready" confirms nothing: it asks the system to show the summary with Confirm, Modify and Cancel. **No order exists until the person taps Confirm.** And since the model could write "order confirmed" on its own, a rule checks every closing sentence against the orders actually saved in that turn.

## How it is tested

Five layers, each one for a different kind of error:

- **More than 900 automated tests**: 847 in the fast suite and 88 integration tests against real PostgreSQL, with the server's role. One of them goes through every database function and fails if any is left executable without explicit permission.
- **13 full conversations stored as specification**, on every change.
- **21 SQL suites** that test the database from the inside: isolation between shops, and that all three ways in charge the same.
- **A test bench against the real model**, with 77 cases and 11 rules that check every reply: it does not invent prices, does not promise payment methods that do not exist, does not announce an unconfirmed order. It adds up to 1,203 simulated conversations, deliberately hard: prompt injection, discounts that do not exist, a half-pizza, half-empanada order.
- **GitHub Actions** on every push: secret scanning over the whole history, type checking and the fast suite.

## Stack

- Application: React 18, Vite, TypeScript, Tailwind CSS, TanStack Query, PWA, Leaflet
- Server and AI: Node.js 22, Fastify, Vercel AI SDK, OpenAI-compatible API, Tool calling, WhatsApp Cloud API, Redis
- Data: Supabase, PostgreSQL, Row Level Security, Realtime, plpgsql functions
- Quality and deployment: Vitest, SQL suites, GitHub Actions, gitleaks, Docker Compose, Vercel
