Skip to content
Ricardo Brossard
Projects

TomaNota

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

My own product, for shopsIn production

The shop's order board, with columns for new orders, preparing, ready and on the way, and the buttons to move each one forward
automated tests
900+
simulated conversations against the real model
1,203
tools for the model, none of which accepts a price
8
tables with per-shop permissions (RLS)
46
A customer orders two Neapolitan pizzas and a soda on WhatsApp, and the assistant builds the order with the real menu and the total. Then a WhatsApp order, a QR menu order and a counter order reach the same shop board, each with its source label, and move across the columns.A customer orders two Neapolitan pizzas and a soda on WhatsApp, and the assistant builds the order with the real menu and the total. Then a WhatsApp order, a QR menu order and a counter order reach the same shop board, each with its source label, and move across the columns.A customer orders two Neapolitan pizzas and a soda on WhatsApp, and the assistant builds the order with the real menu and the total. Then a WhatsApp order, a QR menu order and a counter order reach the same shop board, each with its source label, and move across the columns.A customer orders two Neapolitan pizzas and a soda on WhatsApp, and the assistant builds the order with the real menu and the total. Then a WhatsApp order, a QR menu order and a counter order reach the same shop board, each with its source label, and move across the columns.
Three ways in, a single board and the same price on all of them.

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.

How it works

  1. 01WhatsAppverified signature
  2. 02Ordering, duplicatesloop cut-off
  3. 03Language model8 tools
  4. 04Server calculatescatalog price
  5. 05Summarybuilt by the system
  6. 06Confirmtapped by the person
  7. 07Databasequotes it again
  8. 07QR menuthe customer picks
  9. 07Counterin person or phone
  10. 08Boardin real time

Results

  • Three ways in, one price

    WhatsApp, the QR menu and the counter quote with the same database function and always charge the same.

  • The owner takes over

    If the owner writes in a chat from their phone, the assistant goes quiet in that conversation for 30 minutes.

  • It never goes silent

    With a limit of 20 seconds per call and 45 per turn, the customer is handed to a person and the owner gets the reason.

  • 17 out of 17 addresses

    The delivery zone is worked out from the address with street segments and outlines defined by the streets that bound it.

  • The way each shop sells

    Sizes, extras with a minimum and maximum, half and half, a half pizza with its own price and sale by weight.

  • Five languages

    It detects the customer's language and keeps it for the whole order.

Stack

Application
React 18ViteTypeScriptTailwind CSSTanStack QueryPWALeaflet
Server and AI
Node.js 22FastifyVercel AI SDKOpenAI-compatible APITool callingWhatsApp Cloud APIRedis
Data
SupabasePostgreSQLRow Level SecurityRealtimeplpgsql functions
Quality and deployment
VitestSQL suitesGitHub ActionsgitleaksDocker ComposeVercel
A full conversation with the assistant: a half-and-half pizza and half a dozen empanadas, the delivery zone worked out from the address, the summary with Confirm, the payment method and the new order alert sent to the shop