From a WhatsApp message to a ticket on the kitchen screen
May 29, 20265 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.
What's real and what's the demo
Honesty about the embed: the 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.
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.