HawkerLoop is a full-stack prototype that connects Singapore hawkers with nearby neighbours to rescue edible surplus from the bin. It runs as one Node 24 process with the built-in node:sqlite module, role-separated customer and hawker views, and a deliberately simulated PayNow provider so no real money ever moves.
The loop it closes. A hawker with unsold chicken rice at 8:30 pm lists a "rescue box" with a discounted price and a short pickup window. A customer nearby reserves one, the inventory hold is atomic in SQLite, the listing drops to sold_out if it was the last one, and the customer has ten minutes to confirm a demo PayNow payment. A 6-digit claim code is generated only after payment succeeds. When the hawker taps Collected in person, HawkerLoop writes the only thing that counts as "food saved" — an impact_events row. Cancelled, failed, or expired holds restore inventory and never produce impact.
Why I built it this way. I wanted the simplest possible deploy target: one process, one file, no node_modules to install or patch, no supply-chain surface to worry about. Node 24's node:sqlite plus the built-in http module cover everything the prototype needs, so the whole project ships as a handful of source files. That makes it trivial to push to a $0 Railway or Render box and trust.
What I'm most pleased with.
- A reservation state machine, not a flag soup. Every status change goes through one helper, every move writes a
reservation_transitionsaudit row, and inventory is restored on cancel, fail, or expire in the same SQLite transaction that flips the status. That's how the prototype guarantees: you can't collect an unpaid order, you can't double-spend a rescue box, and a no-show never inflates the impact counter. - An AI meal finder that can't lie. The optional AI Assist calls the OpenAI Responses API with only the current server-side live listings (not a stale catalogue) plus the customer's cuisine preferences and rescue-alert radius. If no live listing matches the dish or hawker centre, the model says so — it cannot invent a stall. Requests use
store: false, the browser receives the answer plus server-selected exact live-listing cards, and the call is rate-limited. - A PayNow provider that is honestly a demo.
lib/payments.jsis a singleDemoPayNowProviderinterface; the demo implementation just flips a status. A real provider slots in behind that interface without touching reservation rules. Hawkers can upload a small local QR image, customers see it during checkout, and the README says out loud that nothing is real yet.
What I learned.
- A state machine plus an audit table is a tiny amount of code and an enormous amount of safety. Every concurrency edge case I worried about, two customers racing for the last box, a payment that arrives after a cancellation, a no-show after confirmation, collapsed into "did we write a
reservation_transitionsrow in the right order, inside one transaction?" - Grounding an LLM in a small, server-controlled slice of live data is the single biggest lever for honest AI UX. The model only sees what's claimable right now, so it can only recommend what's claimable right now.
- The boring infrastructure matters: HttpOnly + SameSite cookies, role checks on every API route, a Content-Security-Policy that allows
data:images but no inline scripts, and the server as the single source of truth for users, listings, and reservations. The client does not uselocalStoragefor any domain record.
Challenges I wrestled with.
- Concurrent reservation races. Two customers clicking the last box at the same time. Solved with
BEGIN IMMEDIATEtransactions (insidelib/db.js'srunInTransactionhelper) on every status-changing operation and aremaining_quantitycheck inside the same transaction. - Impact accounting. The temptation is to count a "saved" meal the moment a customer reserves it. That's wrong — they could no-show, the payment could fail, the window could expire. Impact is only written on hawker-confirmed collection. The schema enforces this: the only
INSERT INTO impact_eventsin the codebase lives inside the/collecttransaction. - No external payments, by design. Singapore's PayNow can't be touched from a hackathon prototype, so I had to be very explicit in both the README and the UI that the provider is simulated. The interface in
lib/payments.jskeeps the door open for a real one. - AI honesty. The default with chat assistants is to hallucinate a plausible-sounding stall. I solved that by feeding the model only the JSON of currently live listings and instructing it to never invent, and the server still runs a local
matchingLiveListingspass to attach the exact reservation cards, so the user always gets a clickable result backed by a realmeal_listingsrow.
The impact math behind the scenes. For every collected rescue box, HawkerLoop writes:
$$ \text{food saved (kg)} \;=\; 0.35 \times \text{quantity} $$
$$ \text{CO}_2 \text{ avoided (kg)} \;=\; 0.72 \times \text{quantity} $$
These are the prototype's per-meal impact coefficients, simple placeholders in the /collect route that are easy to swap for a region-specific factor once real measurement arrives.
Built With
- commonjs
- css3
- html5
- http
- javascript
- node.js
- node:sqlite
- openai
- rest
- scrypt


Log in or sign up for Devpost to join the conversation.