8Examples / blog

CQRS · Event sourcing · Event Modeling · LLM extraction

How I Built ForkLocal.ca: Every Restaurant's Own Website, Read Daily, and a Robot That Books the Table

Every restaurant already publishes its menu, its hours and its phone number, on its own website, in its own way: a Squarespace page, a PDF from 2019, a photograph of a printed menu. ForkLocal.ca reads all of them, every day, rebuilds each restaurant as one clean page, and when you ask for a table it phones the restaurant for you. This is how it is built.

By Sean Bennett · · 14 min read

The first commit was on October 3 with three Calgary restaurants. That evening it had 1,471 of them from a spreadsheet; the next day, the 22 largest cities in Canada. Three weeks later it knows 13,121 restaurants, has read the menus of 6,773, and holds 1,052,992 menu items, each one with a history. It is the second product built on the same shape as DealerProxy.ca: a Next.js app that owns an event store, a background processor that does the slow work, and an Event Model that says exactly how they talk.

The screenshots were captured from the live site on October 25, 2026, and the figures here are from that day’s diagnostics page. They are a snapshot, not a promise about what the site shows when you open it. The booking form was photographed, not submitted: submitting it texts a real phone and calls a real restaurant.

ForkLocal.ca city chooser: 22 Canadian cities from Brampton to Winnipeg, each with its restaurant count
The front door, forklocal.ca: one app and one event store for every city, each on its own subdomain. A city is a line in a file and a hostname on the Cloudflare tunnel. Select the image to open it at full size.

The shape: CQRS and event sourcing, drawn first

I design these systems with Event Modeling before I write them: a timeline of what a person or a job sees, what they ask for (a command), what becomes true (an event), and what the next screen or job reads (a read model). The model is the specification, and it maps one to one onto code: every command is a route, every decider is a function from a stream’s past events and a command to new events, and every read model is a projection. The README of the repository has the whole model as a table, one row per slice, and the code is organised the same way.

BACKGROUND PROCESSOR · NODE.JS, PLAYWRIGHT, SQLITERestaurant websites13,121 in 22 citiesWix, Squarespace, PDFsSpider jobheadless Chromium3 restaurants at a timeCrawl databaseone SQLite file per crawlnever deletedInterpretation jobfingerprint, then cache,then a chain of modelsReservation jobtexts the code, phonesthe restaurant, texts backrecord-restaurant-profilerecord-menu-observationcomplete-crawl-interpretationrecord-reservation-call-endedto-do lists: restaurants due for crawl, crawls needing interpretationNEXT.JS APP · COMMANDS, EVENT STORE (OWN THREAD), PROJECTIONS, PAGESDecidersreplay the streamappend only what changedmain.dbappend-only eventsUNIQUE(aggregate, version)Projectorin-memory SQLitesnapshot every 30 minRead modelsrestaurants · menus · FTS5reservations · to-do lists<city>.forklocal.casearch, menus, bookingNothing in main.db is updated or deleted.The processor says what a site showed; the app decides what changed.
Two processes, one rule: only the app writes events, and only through commands.

The split that matters most is in the middle of that diagram. The background processor never writes facts. It reads a website and tells the app what the site showed: “this page lists these 75 items with these prices”. The app replays that menu’s stream and decides what happened: one price went up, two items are new, nothing else changed. Only the app appends events, only through commands, and each decision is appended after the version it was made at (UNIQUE(aggregate_id, version)), so two decisions on one stream cannot both land.

The streams are small and specific: restaurant:<slug>, crawl:<uuid>, menu:<slug>:<menuKey> (one per menu, keyed by its name: dinner, drinks, happy hour) and reservation:<uuid>. Nothing in main.db is ever updated or deleted.

Crawling 13,000 restaurant websites a few times a week

Restaurant websites are the worst-built websites on the internet, and I mean that fondly. Most render with JavaScript, half put the menu in a PDF on someone else’s CDN, and some are a single photograph: Ten Foot Henry’s menu is one 5,100 by 6,600 pixel image. So the spider is a real headless Chromium, three restaurants at a time, breadth first with menu pages first, then contact, location, about and booking. It honours robots.txt, stays on the restaurant’s hosts, scrolls so lazy content loads, downloads every linked PDF wherever it lives, and keeps any image the page presents as a menu at full size.

Every crawl is its own SQLite file, and none of them is ever deleted:

-- crawls/ryuko-west/2026-10-20-<crawlId>.db: one file per crawl, kept forever
CREATE TABLE events (
  id            INTEGER PRIMARY KEY AUTOINCREMENT,
  event_type    TEXT NOT NULL,  -- RobotsRead, PageRendered, DocumentFetched,
                                -- MenuImageFetched, ResourceFetched, ...Failed
  url           TEXT,
  event_data    TEXT NOT NULL,  -- status, headers, timing, as JSON
  body_sha256   TEXT,
  body          BLOB,           -- the DOM after JavaScript ran, a PDF, a menu image
  body_encoding TEXT,           -- 'gzip' for text: a tenth of the size
  timestamp     INTEGER NOT NULL
);
-- A large body unchanged since the restaurant's previous crawl is not copied
-- again; the new file refers to the one that holds it, by hash.

That is the record of what each site said each day: 10.38 GB of new crawls yesterday, gzipped and deduplicated, all kept. It cost me nothing to decide to keep them, and it has paid for itself every time I changed a prompt: I can read last Tuesday’s crawl again with today’s reader without visiting the restaurant.

ForkLocal Calgary: search, cuisine, postal code distance and open-now filters above 1,455 restaurant cards with photos, cuisines, addresses, open-now badges and menu and item counts
Calgary’s list. Every card is a restaurant whose own website was read: its photo, cuisines, price level, address, whether it is open now by the hours its site gives, and how many menus and items were copied from it. Select the image to open it at full size.

Reading menus with a chain of models

Each rendered page is reduced to its text, links, images and schema.org data before any model sees it, which is about 1% of the HTML: Ryuko’s food menu page is 986 KB of HTML and 10 KB of text. Hidden tab panels are kept, because a menu’s tabs each hold part of it. A PDF with a text layer is read locally; only a scanned one is sent as a file. Then a model is asked, against a strict JSON schema, for two things: the restaurant’s details from the pages most likely to hold them, and every menu, item by item, with prices exactly as written.

The models are a chain, cheapest first, and the chain is configuration:

# comma-separated, one URL per host; KEY@ is its key, #openai an OpenAI-style API
LLM_HOSTS=\
https://[email protected]/coding?model=k3&tier=1#openai,\
https://[email protected]/api?model=google/gemma-4-26b-a4b-it:free&parallel=2&tier=2#openai,\
https://qwen3.fusenv.com?model=qwen3.5:4b&numCtx=32768&maxTokens=4000&tier=4,\
...
# ?menus=off   a small self-hosted model reads the details, never menus
# ?schema=prompt   a model that cannot be held to a JSON schema is told it in words
# ?web=on      it can answer with a web search (the licence screening uses these)

The next host answers when one fails, answers in the wrong shape, or runs out of credit or quota (it then rests a while). The 4B model on my own GPU reads the restaurant’s details but never its menus, because given a menu it invented prices. When every host is out at once, reading pauses rather than failing crawls, and crawls that failed only for want of a model get their attempts back when one answers. That last rule exists because one bad afternoon gave up on 5,757 crawls before I wrote it.

ForkLocal diagnostics, the last 24 hours: 5,085 crawls finished, 10.38 GB kept, 2,346 read by a model, 420 unchanged, 10,779 model calls plus 8,212 from the cache, estimated cost $5.16, and a table of fifteen models with their calls, tokens and cost
One day of reading: 10,779 model calls, 8,212 more answered from the cache, and an estimated $5.16. Most of the work lands on a subscription (Kimi’s K3) and a flat-rate host (MiniMax), which cost nothing per call. Select the image to open it at full size.

An unchanged website costs nothing

Most restaurants change their menu a few times a year, so most crawls should cost nothing to read, and the expensive part is proving that. Before any model is asked, the crawl is prepared and fingerprinted: each page’s text as the model would read it (the raw HTML differs on every visit: generated ids, tokens, timestamps), the hashes of the PDFs and menu images, the share image, the prompts’ version and the restaurant’s own details. When that equals the fingerprint of the restaurant’s last complete reading, the crawl becomes one CrawlFoundUnchanged event. No model call, no menu commands, no sightings.

Below that is a cache keyed by exactly what was asked: the same content, question and model give the cached answer. A restaurant that changed one PDF pays for one PDF.

Deciding what changed

This is the decider for a menu reading, lightly trimmed. It is a pure function, which is the point: given the menu’s past and today’s reading, it returns the events, and the unit tests are given-events / when-command / then-events with no database at all.

export function decideMenuObservation(state: MenuState, command: RecordMenuObservationCommand): Decision {
  if (!state.seen || !state.menu) return [{ eventType: 'MenuFirstSeen', data: { ...base, menuKey, menu } }];

  const before = itemsOf(state.menu);
  const after = itemsOf(menu);
  // A menu that loses more than half its items at once is more often a reading gone
  // wrong than a restaurant that halved its menu: held until the next reading agrees
  if (state.listed && before.size >= SHRINK_GUARD_MIN_ITEMS
      && after.size < before.size * SHRINK_GUARD_RATIO && !state.shrinkHeld) {
    return [{ eventType: 'MenuShrinkHeld', data: { ...base, items: after.size, previousItems: before.size } }];
  }

  for (const [key, { section, item }] of after) {
    const was = before.get(key);
    if (!was) { events.push({ eventType: 'MenuItemAdded', data: { ...base, section, item } }); continue; }
    if ((was.item.price ?? null) !== (item.price ?? null))
      events.push({ eventType: 'MenuItemPriceChanged', data: { ...base, section, name: item.name, from: was.item.price, to: item.price } });
    // ...description and dietary changes become MenuItemDetailsChanged
  }
  for (const [key, { section, item }] of before)
    if (!after.has(key)) events.push({ eventType: 'MenuItemRemoved', data: { ...base, section, item } });

  // The whole menu after any change, so its state is one event away; otherwise a sighting
  events.push(changed ? { eventType: 'MenuRevised', data: { ...base, menu } }
                      : { eventType: 'MenuSighted', data: base });
  return events;
}

Two guards in there came from production, not design. The shrink guard: a menu that loses more than half its items at once is far more often a model having a bad day or a page that half loaded than a restaurant that halved its menu, so it is held (MenuShrinkHeld) until a second reading agrees. And a crawl that found no menus at all does not withdraw anything; that is a broken crawl, not a restaurant without food. The same rule protects the profile: a page that failed to load today does not mean the restaurant has no phone number, so a profile reading only ever adds or changes, never erases.

TRIGGERCOMMANDEVENTSREAD MODELDue: 23 h since lastor a new licence, or askedSpider jobcrawl done, file moved into placeInterpretation jobfingerprint matches the lastInterpretation jobone command per menu readInterpretation jobafter the last menustart-crawlrefused while one is runningrecord-crawl-completedor record-crawl-failedrecord-crawl-unchangedno model askedrecord-menu-observation“this is what the page showed”complete-crawl-interpretationCrawlStartedCrawlCompletedor CrawlFailedCrawlFoundUnchangedits menus marked seenMenuFirstSeen · ItemAddedItemRemoved · PriceChangedDetailsChanged · Revised · SightedMenuWithdrawn · CrawlInterpretedwhat the crawl no longer found(unless it found no menu at all)restaurants due for crawlcrawls needing interpretationmenus · menu changes · searchrestaurant page
The automation pattern: a read model lists the work, a job does it and reports back with a command, and the event takes it off the list.
Ryuko West on ForkLocal: the restaurant's logo, an Open now badge, cuisines, description, six menu tabs, the food menu with prices, and a Book a table form with party size, date, time range and first name
One restaurant page, rebuilt from ryuko.ca: six menus, every item with its price as the site writes it, contact details, hours, and the booking form. Each menu links back to the page it was copied from. Select the image to open it at full size.
Recent menu changes for Ryuko West: on October 20, items added to the Family Style Course Menu, descriptions changed, and four drinks gone
Recent menu changes, which is just the menu streams read backwards: on October 20 Ryuko added a family-style course menu and dropped four drinks. Prices that change show as $16 → $18. Across the site there have been 12,730 price changes since the first crawl. Select the image to open it at full size.

Search is SQLite FTS5 over names, cuisines, places and every dish, in the in-memory read model, ranked name first. Searching “ramen” finds the ramen shops and the izakayas whose menus have it. Cuisines are a fixed list, because the labels sites give themselves are free text (over a hundred labels for the first hundred restaurants), and each restaurant is filed under every cuisine its labels match. Distance needs no outside service at query time: all 900,000 Canadian postal codes are in a gzipped file held in memory as one sorted string and two float arrays, about 18 MB. A restaurant whose site gives a street and no postal code is geocoded once with OpenStreetMap’s Nominatim, one request a second, again only when the address changes.

ForkLocal Calgary search for ramen: 79 restaurants including Wakado Ramen, Ramen Ichinen, Ramen Arashi, Globefish, Kinton Ramen and Day Day Bistro
79 places in Calgary that serve ramen, by name or by menu. The query is an index lookup, so it stays fast when the list is every restaurant in Canada. Select the image to open it at full size.

A robot that phones the restaurant

The feature I most wanted is the one most likely to go wrong: a public form that makes my server phone a small business. So the form takes fixed values only (party size 1 to 10, a date in the next fortnight, one of seven time ranges), the request is something a restaurant can always answer, and the call never interprets free text. Nothing is called until the visitor confirms their mobile number with a texted code. Then the reservation job waits until the restaurant may be called:

// Never before or after these, local time, whatever the website says
export const POLITE_FROM = '10:00';
export const POLITE_UNTIL = '21:00';
// The last call is this long before the time asked for
export const LAST_CALL_BEFORE_MINUTES = 30;

export function callableAt(at: number, hours: DayHours[] | null, province: string): boolean {
  if (!within(POLITE_FROM, POLITE_UNTIL)) return false;
  if (!hours || hours.length === 0) return within(DEFAULT_FROM, DEFAULT_UNTIL);
  return isOpenAt(hours, at, zone, CLOSING_MARGIN_MINUTES);   // not in its last quarter hour
}

The call itself goes through my own phone-call gateway, the same one the OpenClaw workers use. The voice agent says it is an automated assistant calling for a guest, asks for a table for the party within the range under the guest’s first name, gives the guest’s number only if asked, accepts only a time within the range and asks for the closest ones otherwise, hangs up on voicemail, and never offers a card. When the call ends a model reads the transcript for the outcome, and the answer is texted to the visitor and shown on their page, which follows the request live.

VISITORNEXT.JS APPRESERVATION JOBPHONE GATEWAYRESTAURANTBook a tableparty, date, time rangeReservationRequestedphone snapshottedText a codesix digits, HMAC keptSMSVerificationCodeSentTypes the codeon /reservations/<id>PhoneVerifiednow due for a callWhen it may callopen, 10–21h, 30 min aheadOrchestrated callvoice agent, script fixedPicks upbooks, offers times, or notranscriptRead the transcriptbooked? declined? when?ReservationBookedor Declined · FailedText and live pageOutcomeTextSent
Every arrow into the app lane is a command; every gold box is an event the visitor’s page is projected from, live.

The retry rule is the one I thought about longest. A call nobody took (no answer, voicemail, a failed line) is tried again twenty minutes later, three calls in all. A call the restaurant answered that confirmed nothing is not retried, because calling again could book a second table. The abuse limits are just as plain: a code expires in ten minutes, five wrong codes end the request, a number gets three requests a day and one open request per restaurant, toll-free and non-North-American numbers are refused, and so is the restaurant’s own number. I will be honest about usage: the event log shows one real table request so far, and I cancelled it. The flow is tested end to end in CI against a stand-in gateway and a stand-in restaurant, and a single environment variable sends every call to my own phone for a live rehearsal.

Finding restaurants the day they open

A list of restaurants goes stale the week it is made, so ForkLocal reads the cities’ own open data: Calgary’s and Edmonton’s business licences, Toronto’s DineSafe inspections, Vancouver’s licences, Montréal’s food establishments, Nova Scotia’s licensed food establishments and Surrey’s business directory. The app keeps every id it has seen (BusinessLicenceSeen, 44,057 of them), the processor sends only the ones it has not: the diff. On a city’s first look everything older than 30 days is the baseline and never screened.

A new licence is screened by a model with a web search: is it a place to eat or drink, is it a chain, what cuisine, and what is its own website (never a social page, a delivery app or a directory). The app decides: a place to eat, the model at least 60% sure, with a website of its own and not already listed by site, name or address, is registered and crawled first. Everything else is BusinessLicenceSkipped with the reason, which is what the right-hand column of the screenshot is.

ForkLocal diagnostics: new places from business licences and inspections, a table of seven cities with how many are on the list, already there, added, not added and waiting, each source's last look, and recent decisions such as added, no website of its own, or not sure enough it is a place to eat
165 restaurants found this way so far and 404 turned down, each with why: no website of its own, not sure enough it is a place to eat, already listed at that address. Select the image to open it at full size.

The ones turned down for having no website turned out to be the interesting list. They are new places to eat without a site, which is exactly who 8Examples builds websites for. So the admin side of ForkLocal grew a small CRM: every licence the screening found to be a place to eat, a second model-with-a-search pass for its public phone, email, owner and operating company, a stage per lead (new, contacted, interested, won, lost), and each change an event on lead:<licence>. For 79 of them I built a preview site first, and this week the first postcards went out through PostGrid’s API: “Your website is already built.”

The 6 by 4 inch postcard: For new restaurants and cafés. Your website is already built. Menu, hours, map, reservations. We host it, we tune it, you run the kitchen. See your site, reply by November 30.
The postcard template, filled in here for 8Examples itself (that copy was the live test mailing). Each eatery’s version names its own preview site. Select the image to open it at full size.

Keeping one small server fast

ForkLocal runs on one Beelink mini PC (server9 on the shelf), so it inherited every performance lesson DealerProxy learned on a gigabyte of read models:

  • The event store has its own thread. Every command and every read of events runs on a worker thread, the only one that opens main.db. The main thread serves HTTP from the read models and never waits on the event store’s disk.
  • Read models in memory. A checkpointed projector keeps an in-memory SQLite current every second and the moment an event lands, snapshots it every half hour, and a restart loads the snapshot and catches up instead of replaying the log. A command’s response waits until the read models include its result, so the caller’s next read sees it.
  • Analytics that never block a page. Every page view is a PageVisited event, noted in memory by the proxy and written in batches every two seconds on the event-store worker.
  • Model calls only for what changed, by the fingerprint and the cache, subscription and free models before paid ones.

Knowing what is broken

A pipeline that spans a browser, a dozen model hosts, a phone gateway and two processes fails quietly, so the diagnostics page is linked from every page’s footer and says plainly what is stuck. Today it says the honest thing: there are 65,586 crawls waiting to be read, the oldest 320 hours old, because crawling outruns reading and I chose to keep crawling. At the last day’s pace each website is crawled about every three days, not daily, and the page says that too rather than the number I aimed for.

ForkLocal diagnostics: 13,121 restaurants, 6,773 with menus read, 1,052,992 menu items; right now 4 crawling, 5 being read, 12,021 waiting, 7,274 due; a Blockages panel listing crawls given up on, the oldest waiting crawl, and restaurants left out because their site is parked or not loading
Diagnostics on October 25, live every fifteen seconds: what is crawling and being read right now, and a Blockages panel that lists every way the pipeline is behind, with counts. Select the image to open it at full size.

What I would tell someone building the same thing

Draw the Event Model before the code. Every slice in ForkLocal was a row in a table before it was a file, and the table is still the best documentation the project has.

Let the processor observe and the app decide. A model will misread a menu. When the thing that reads only ever says what it saw, and a pure function decides what changed with the whole history in hand, a bad reading becomes one held event instead of a corrupted record.

Keep every crawl. Disk is cheap and re-crawling a restaurant to test a prompt is rude.

Make the expensive path rare, not cheap. The fingerprint saved more money than every model choice combined.

Put the safety rules in code a person can read. When to call, how often, and when never to call again are twenty lines in one file, with tests, because they are promises to restaurants that never signed up for this.

If you are hungry in any of 22 Canadian cities, ForkLocal.ca is live. Find somewhere local, read the menu the restaurant actually published, and let it make the call.

Find somewhere local to eat.

13,000 restaurants in 22 Canadian cities, their menus read from their own websites, and a table booked by phone.

Open ForkLocal.ca →

Comments 0

No comments yet. Start the conversation.

Leave a comment

Site author? Sign in to reply officially.

Commenting is temporarily unavailable while CAPTCHA is being configured.