Operate the restaurant, watch the backend
The point-of-sale platform I led at IPOT, reimplemented in the browser in IPOT's own design language. A cashier terminal, a kitchen display, a guest app and the Nalu assistant run against the same in-page services. Every tap becomes a request through Router → Service → Model, one transaction each, and the trace below shows exactly which rows it wrote.
1 · Run the restaurant
Play the tour, or drive it yourself: tap a table, build the order, send it to the kitchen, take payment, refund a line, void a bill. The guest phone is on the selected table and signed in by phone number; the kitchen screen gets its tickets over WebSocket. The Bookings tab shows what the Reservation API holds; the Requests tab shows guest requests and Nalu's refund proposals.
Nothing yet. Tap a table on the cashier terminal.
2 · Talk to Nalu, the guest assistant
Nalu is the bilingual (EN / 中文) guest chat at the edge of the platform. Rules classify the intent and read every field; the booking runs only after a bare “yes”; every tool is an HTTP request into the POS (read-only) or the Reservation API, with an Idempotency-Key on the write. A refund is a proposal until a manager approves it on the terminal. In pre-launch testing the model is qwen2.5:7b served by Ollama on the restaurant's own GPU box, so guest messages never leave the network; it gets at most one call per turn, to classify the intent and fill fields the rules missed, and every value it returns is re-validated before it is used. Here the model is off until you connect a local Ollama below; the rules alone carry the whole flow.
What the agent may and may not do
- Read the POS:
GET /menu-items,GET /orders/{id}. No ORM import, no write methods, no guest token stored or sent to inference. - Write the POS once:
POST /customer/guest-requests, the same public endpoint the QR app uses, keyed by the table session. - Book through the Reservation API:
POST /api/v1/reservationswith anIdempotency-Key, after a bare yes. A full slot comes back 409 and the agent offers open times. - Propose a refund, never move money: the proposal passes the same checks the refund endpoint applies, then waits on the terminal's Requests tab. Over $50 needs a manager.
- Never overclaim: replies about refunds and bookings are fixed templates; the code, not the model, decides what the reply may say.
- The model, when connected: one bounded call per turn (about 150 output tokens, 10 s timeout, invalid JSON counts as no result). Its intent is used only where the rules matched nothing; its fields only where the rules found nothing, and only if they survive re-validation against the guest's own words.
Local model · off
Nalu's model runs on the restaurant's NVIDIA DGX Spark through Ollama: about 47 tokens/s warm, about 10 s on a cold first call, requests served one after another. Nothing is sent to a cloud API. The flag CUSTOMER_CHAT_USE_LLM ships off; the deterministic path is what the guest gets when it is off or the model fails. If you run Ollama, connect it and watch the model's reading next to the rules. Nothing leaves your computer.
# once, then restart Ollama
ollama pull qwen2.5:7b
OLLAMA_ORIGINS="https://kaihuan-huang.github.io" ollama serve
3 · The platform
Five clients, one FastAPI service in three layers, one PostgreSQL, and two neighbouring services with a hard boundary. Click a box to see what it does and which parts I built.
Pick a box
Routers validate input and shape responses. Services hold every business rule and own the transaction. Models are SQLAlchemy tables migrated with Alembic. Nothing below that line is reachable from a router.
The entry points
Every way something outside the POS gets in or out, what guards it, and where this page shows it. “Show me” runs the real request in the trace.
| Entry point | Direction | Guard | On this page |
|---|---|---|---|
| Guest QR app → POS guest requests | public POST | keyed by table session only; dedupe; 3-minute SLA | Phone “Call a server” |
| Nalu → POS | read-only GET | no ORM, no write methods, no stored guest token | “What's on the menu?”, “How much is my bill?” |
| Nalu → Reservation API | POST with Idempotency-Key | bare-yes gate; same key + same payload replays, different payload 409 | Book a table, then |
| Reservation API → Twilio | outbound SMS + status webhook | OTP via Verify; templates only; delivery status by callback, never guessed | |
| Reservation API → Revenue Agent | outbound callback | UTM and click ids on the booking; the booking never waits on marketing | Appears on every booking as an outbound call in the trace |
| Reservation → POS floor | staff action | a reserved table seats only with its reservation code; the bill carries the code | Terminal “Bookings” tab: confirm, reserve a table, seat the party |
| Nalu refund proposal → POS refund | manager approval | same checks as the endpoint; approval is the only path that moves money | Terminal “Requests” tab |
| Kitchen display, iOS station | WebSocket rooms; API contract | tickets numbered by the API; contract doc answers the station's asks | Kitchen device; platform map |
4 · Settlement: four calls, or one transaction
Before my change, the till settled a bill with four requests: capture the payment, close the bill, move the table, write the drawer. Each committed on its own. I replaced them with POST /orders/{id}/settle, which does all four inside one transaction. Pick where the process dies and run both.
Before: four calls from the till
After: one settle transaction
Three holes the same change closed
- Any signed-in user could close an unpaid bill.
PATCH /orders/{id}withstatus=closedskipped every payment check, back-filledpaid_totalfrom the total and freed the table. Closing now goes through/closeor/settleonly. - An abandoned payment attempt raised the guest's bill. The tip was added to
order.totalwhen the intent was created and never removed when it was cancelled. The tip now lives on the payment that carries it; the bill's tip is the sum over captured payments. - A paid table stayed dirty forever.
dirtywas written in one place and cleared nowhere. Settle takes the target floor status, andPATCH /tables/{id}/floor-statuswrites it explicitly.
5 · Try to break it
Each button runs against a fresh, fully settled shift. The service answers with the status code and error code the real API uses, and the transaction is rolled back.
6 · Tests
Money rules, price snapshots, kitchen rounds, guest requests, the crash behaviour of settlement, the Reservation API's idempotency and callbacks, and Nalu's gates, run in your browser against the code on this page.
7 · Who built what
The platform is a team effort. Commit hashes are in the IPOT repositories; the demo reimplements the behaviour, not the code.
| Area | Mine | Team |
|---|---|---|
| Money flow | Atomic settlement, tips on payments, refunds with reason and approver, audited void, cash-drawer movements (6de5698, 7eb27be, b3c7846) | Payment-intent model, PIN login, shifts |
| Kitchen | Many rounds per bill, price by the menu the cashier saw, ticket numbers owned by the API, status derived from every round (a9814b2) | KDS screen, WebSocket rooms, printing |
| Guests | Guest-request inbox with dedupe and a 3-minute SLA (392e5ac); QR ordering flow with payment step; phone sign-in | QR app shell, menus, i18n |
| Floor and staff | Table triage with Postgres persistence (97c8270); scheduling and availability (0975dec); cashier rota | Floor plan, table model, iOS station |
| Delivery | iOS API contract answering the station's 11 asks (e520b80); staging CI; diverged Alembic heads repaired; a long-red test suite brought back to green (7fc408c) | MVP, Docker stack, deployment |
| Neighbours | Reservation API from the first commit, in production; Nalu booking flow v2 (pre-launch) | Nalu as a separate service: scaffold, read-only POS grounding |
Not shown here and not claimed: real card capture (simulated on the main branch), receipt printing, production deployment. For the tamper-evident audit trail behind these events, see the ledger page.