From a WhatsApp message to a ticket on the kitchen screen
May 29, 20267 min read
A customer types "quiero 2 órdenes de pastor para recoger" into WhatsApp. Ninety seconds later there's a ticket on the screen in the kitchen of El Camioncito, a taquería in Reynosa, with a folio number, the line items broken out per person, and a total already computed. Nobody on staff typed anything. This is how that path is wired.
The board below is a non-functional demo of the kitchen display — three columns, fabricated tickets, a fake "kitchen tick" that advances orders, and the same live age timer the real screen uses. No network, no real customers, no phone numbers. Poke it.
The path, end to end
There are five hops between the customer's thumb and the kitchen screen.
-
The message lands. Every order channel for El Camioncito is WhatsApp. The number is connected through Evolution API — an open-source WhatsApp gateway that speaks the Baileys protocol and exposes inbound messages as HTTP. When a customer writes, Evolution POSTs the event to a webhook on the backend.
-
The webhook classifies it. The handler at
/webhook/whatsapplooks at what arrived. WhatsApp messages aren't just text — they can be audio (a voice note: "mándame tres de asada"), a shared location pin, or an image. Each type takes a different branch: audio gets transcribed, a location pin becomes delivery coordinates, text goes straight to the order parser. -
An order row is created. Once the message is understood, the bot writes an
ordersrow. It carries aconversation_idlinking back to the WhatsApp thread, thephoneit came from, astatusthat starts atdraft, afulfillment_type(dine-in, pickup, delivery), aservice_date, and — the part the customer cares about — acontrol_number: the folio, a unique running number the customer can quote when they show up to collect. Line items live in a separateorder_taco_linestable, one row per filling group, withquantity_orders, afilling_label, and afinish(doradas, semidoradas, suaves). -
The total is computed server-side, in cents. This is the rule I won't bend: the bot never quotes a price the AI made up. Totals are derived from the line items by the backend —
taco_total_cents,drink_total_cents, plusdelivery_fee_cents,glass_fee_cents, andextras_cents. Everything is stored as integer cents, never floats, and summed on the server. The conversational layer can describe the order; only the backend gets to price it. -
It surfaces on the board. The staff screen at
/admin/ordersreads theorderstable and renders each one as a ticket, grouped by status into columns. As the kitchen works an order, it moves right: incoming → preparing → ready. Thestatusfield on the row is the source of truth; the board is just a view of it.
The escalation flag
Bots are good until they aren't. A customer asks for delivery to an address the geofence doesn't recognize, or starts negotiating a catering order, or just says something the parser can't make sense of three times in a row. When that happens the bot stops guessing.
The mechanism is one column on the conversation, not the order. whatsapp_conversations carries a needs_human boolean and a needs_human_reason text field. When the bot decides it's out of its depth, it flips needs_human = true, writes why, and stops auto-replying on that thread. On the board, any ticket whose conversation is flagged gets a visible escalation banner — the red strip you see on one of the demo cards. A person picks it up from there.
This is the honest version of "AI handles your orders." It handles the ones it can, cleanly, and it's loud about the ones it can't. The escalation flag isn't a failure state — it's the feature that makes the whole thing trustworthy enough to leave running.
Why a board and not a feed
The first instinct is to build an inbox: a list of orders, newest on top, mark them done as you go. That falls apart in a kitchen. A cook doesn't want a chronological log; they want to know what's on the comal right now versus what just came in versus what's bagged and waiting for pickup. That's a kanban, and it maps one-to-one onto the status field:
- Incoming —
draft/ freshly created, not started. - Preparing — being cooked.
- Ready — done, waiting on the customer.
The age timer matters more than it looks. Each ticket counts up from when it was placed, green for the first five minutes, amber to ten, red after. In a busy hour the color is the whole interface — a glance tells the cook which ticket is about to make someone wait too long, without reading a single word. The demo above runs the same timer logic; the cards open in different colors precisely because they were "placed" at staggered times.
The other path: building the order on the site
WhatsApp wasn't the only door. The site carried its own order panel: the customer assembled the order with their thumb and sent it to the same kitchen without messaging anyone. That whole panel is below — poke at it.
Five steps, and nearly all the interesting decisions live in how it handles money and addresses.
The price is estimated on the client and decided on the server. The panel totals as you build — órdenes at $80, drinks at $30, plus a $10 glass-bottle charge for every soda leaving the local — and labels that number explicitly as an estimate. The total that counts is the one the backend returns on commit. Any other design invites a customer seeing one number while the kitchen charges another.
The delivery fee can't be trusted to the browser. The panel asks for a quote with the coordinates and the backend answers with distance and tariff: the first km free per orden, every excess km at $20 before 10pm and $30 after. That quote is re-requested at submit, because it goes stale two ways — change the number of órdenes and the free km change with it, cross the 10pm boundary and the rate does.
The address the customer typed outranks the reverse-geocode. In production the address came from a Reynosa-bounded Google Places autocomplete or from dropping a pin on a map, and either way the customer dragged that pin onto their own gate before quoting. Browser geolocation alone kept resolving to the neighbor's house — that's exactly how repartidores ended up one door over. In the end the pin's coordinates rule the money, but the words the customer typed are what the driver reads.
Every dead end routes to WhatsApp. Kitchen closed, out of coverage, a filling ran out, the network dropped: none of those branches strand the customer, they all offer the message button. And the ugly case — the connection dying right at commit — does not say "retry", because the POST isn't idempotent and the order may well have landed: it says confirm over WhatsApp before sending again, so it doesn't go out twice.
What's real and what's the demo
Honesty about the embeds: the kitchen board demo is a faithful rebuild of the board's shape, not the production code. The real board talks to a Postgres database (gaba-postgres) over an authenticated admin API; the demo holds four invented orders in React state and advances them with a setInterval you can pause. The schema in the cards — folio, per-person line groups, server-priced totals, the escalation banner — mirrors the real orders / order_taco_lines / whatsapp_conversations tables. The taco names and customers are made up. Nobody named Marco A. ordered anything.
The order panel, by contrast, is the production code — same components, same cart model, same arithmetic. The only thing that changed is what sits behind it: where there were backend calls there are now fixtures, so the folio you get doesn't exist and no kitchen hears about it. The address picker is the other difference: instead of the Google map with its draggable pin, the demo offers a short list of sample addresses — same delivery arithmetic, no API key and no quota to burn. El Camioncito's site no longer takes orders; the panel is kept here.
If you've ever wondered what "we built a WhatsApp bot for the restaurant" actually means underneath, this is it: a gateway, a webhook, a couple of well-shaped tables, money kept in cents on the server, and a flag that knows when to call a human.