Inspiration
Reviews describe the past. They cannot tell you whether the kitchen is still seating at 9pm tonight, whether the trail lot is closed for resurfacing this week, or whether this branch takes walk-ins on a Saturday. Those answers exist, but they live in one place: someone's head, behind a phone number.
So the obvious build is "an app that phones the business for you." We built that — and then noticed the interesting problem is the opposite one.
If a hundred people want to know whether a café has outlets, a hundred phone calls is not automation. It is a denial-of-service attack on a small business, delivered politely. The call is only worth placing if the answer outlives it. That inverted the whole design: Local Atlas is mostly a system for not calling, wrapped around a small number of calls whose answers are kept, dated, quoted, and served to everyone who asks next.
What it does
Local Atlas is a map-first local guide for any town in the US or Canada — places, weather, alerts, parks, events — where the question a listing cannot answer becomes one phone call, and then a dated, evidence-quoted fact that every later visitor reads without a second call.
Open a listing, pick one of 14 question templates or type your own. Before anything is dialled, the question runs a gauntlet:
- A stored answer that is still fresh is served instead of calling. This is the common case, and it is the point of the product.
- Opinions are refused — "is this the best pizza?" has no factual answer, so it cannot spend a credit.
- Compound questions are refused. "Parking and walk-ins?" is one answer slot and half an answer.
- Account-specific questions are refused — "where's my order?" is not a fact about a place.
- A business listed as closed is not called.
- Nothing is dialled outside 10:00–20:00 local to the business being called, read from that listing's own coordinates.
- An in-flight lock plus an idempotency key mean one question is one call, even if fifty people ask at once.
- The daily ceiling is reserved atomically, so concurrent requests cannot race past it.
Only what survives all of that reaches a phone. Then the app returns HTTP 428 with a confirmation preview showing the exact question, the exact opening line, the exact AI disclosure, and the number about to ring. Nothing dials until the user confirms that specific screen.
On the call, the agent opens with "Hi — is now a good moment for one quick question about your café?", waits for a yes, and only then discloses: "Thanks — I'm an AI assistant, calling for someone who's planning a visit and couldn't ring you themselves." The disclosure is unconditional, always precedes the question, and the agent may never deny being an AI if asked. One question. At most one clarifying follow-up. "I don't know" is a valid recorded outcome. It hangs up on voicemail without leaving a message, and never books, orders, or promises anything.
The answer then becomes a Verified Fact carrying the words the staff member actually said, the date collected, and an expiry set by outcome — a firm "yes, we have a lot" keeps longer than a hedge. Facts requested privately are written where the public list cannot read them.
Asking several places at once
"Which of these three has the shortest wait?" is not three questions. It is one question whose answer only exists once all three have been asked, so it goes out as a single CALL-E task with recipients[], a recipientResultSchema per business, and a call-level resultSchema that compares them — not a loop over the single-call path.
Each business answers only for itself, and never learns that anyone else is being called. Each answer is held to the same evidence rule as any other fact here: that recipient's own dial must have completed, and the answer must quote that recipient's own transcript.
The comparison itself is not a fact anybody said, so it is labelled on screen as derived rather than quoted, and it is bound to the places actually dialled — a winner naming a business this round never called, or one that never answered, is dropped rather than shown.
Try it
- Live app: local-atlas-api.onrender.com — sign in with Google, no email code needed
- Demo video: https://youtu.be/VHswi1U5qkE
- Source: github.com/omarmiri/Local-atlas (MIT) — submitted as
apps/web/local-atlas - **Running it locally with no credentials: clone, npm install, then start with review mode and dry run both set — the complete flow runs, and no call is placed
How we built it
A Node/Express server and a single-file frontend on Render, with Upstash Redis for facts and budget counters and Supabase for accounts. CALL-E is reached through the official @call-e/calle server SDK.
Four decisions did most of the work.
Calls take 30–90 seconds, longer than Render's proxy will hold a request. So POST /api/ask-place returns a call id immediately and the answer arrives by webhook, with polling as the fallback for local development behind NAT.
CALL-E webhooks are unsigned, so a POST to our webhook URL is untrusted input. The receiver takes only the call id from the body, guards on a secret path token, and re-reads the authoritative record from the Calls API before storing anything. A forged POST costs us one authenticated GET and nothing else.
Nothing is published unless it binds on all seven axes. We must hold a stored request for that call id, the call must be completed, the number dialled must be the number we intended, the metadata must match, and the answer must quote that call's own transcript. A recipient in a round is held to the same rule at recipient level.
The recipient is proven, not submitted. The place arrives in the request body, which means the number to dial did too — and a well-behaved UI is not a property of a system. Someone could otherwise pair a real listing's name and coordinates with a number of their choosing and have the app ring it while announcing it was calling for a visitor. The server now looks the number up again itself from the listing's provider id and dials that. A live call with nothing to check against is refused.
One smaller decision was more interesting than it looks: the winner of a comparison round is identified by phone number, not by name. The task names no business — one script is read to all of them — so there is nothing in the conversation from which a name could be known. A schema asking for "the exact name from the recipient list" was asking the model for something it had never been shown. The number is the one identifier the request and the record share.
98 tests hold these gates to what the documentation claims about them, and the whole suite runs with no credentials.
Challenges we ran into
Canada is not a US call. The app covered both countries and the call did not: every recipient went out as a US region with an en-US locale, and the script told the person answering in Ottawa that "you are calling a local US business," in American vocabulary. NANP numbers dial either way, so nothing failed loudly — it was just wrong about somebody else's business, on a call they did not ask for.
The calling window was a single Eastern clock. Which made "10am Eastern" a 7am call in Vancouver and a 4am one in Honolulu, while refusing perfectly civil calls at 6pm Pacific. The hour that matters is the one on the callee's wall, so it is now read from the listing's own coordinates.
A round must be answerable by everyone and specific to nobody. One script goes to several businesses, so a question that names one of them is refused structurally — naming a business makes the question about that business. The opener's noun has to be true of all of them too: a round covering a restaurant and its neighbours must not open "one quick question about your restaurant" at a hardware store. When they share no noun it falls back to a generic "place", and a round straddling the border claims neither country rather than the anchor's.
A ranking is a judgement about businesses that never agreed to be compared. So rounds are private by construction, with no public form at all, and the confirmation names every line that will ring — "we'll call 3 nearby places" is not consent to ring three specific strangers.
Letting reviewers in without handing out credentials. Review mode plus dry run together exercise the complete path — validation, moderation, the 428 confirmation, storage, polling, the result panel — with no CALL-E key, no Supabase project and no Redis, producing a stored fact marked simulated that the UI says is simulated on screen. The bypass only engages when Supabase is unconfigured and the server cannot dial, so it is harmless by construction rather than by trust.
Accomplishments that we're proud of
The gates are the product. Most phone-agent work is about placing a call well. Almost all of the engineering here goes into the decision not to place one, so that the calls that do happen pay for themselves many times over.
Answers keep their uncertainty. A hedge stays a hedge. "I don't know" is recorded as a real outcome rather than smoothed into a confident sentence, and a simulated answer is never presented as a real one.
It degrades instead of breaking. With an empty environment file the app still runs — map, weather, alerts, places and already-collected facts all work, because every keyed feature hides itself when its key is missing.
What we learned
Template questions are safer than free text, for a reason that is not obvious. When a user picks a template, no user-authored text reaches the call script at all — so the template path can skip model moderation entirely, because there is nothing to moderate. Free text earns a model check; templates make the check unnecessary.
A refusal is a feature if you say why. Rejecting "is this the best pizza?" with a rewrite nudge turns a dead end into a usable question. Most refused questions come back in answerable form.
Freshness is not one number. Expiry by outcome beat expiry by clock: a definite yes about a permanent fixture is good for months; anything about today is spent within the hour.
Unsigned webhooks change your threat model, not just your parsing. Once a delivery cannot be trusted, the only safe shape is to treat it as a hint that something happened and go re-read the truth yourself.
What's next for Local Atlas
- Let visitors vote on unanswered questions, so the call budget goes where demand actually is — the purest form of the reuse idea.
- Business claim and correction, so a place can verify, correct or pre-empt its own facts and take itself out of the call queue entirely.
- French for Québec. The agent currently speaks English to Québec businesses; the question, disclosure and extraction are all English, so a French locale on its own would pair a French voice with an English question — worse than the honest version. Doing it properly means translating the whole chain.
- Recheck on a decaying cadence for facts whose expiry has passed but whose demand has not.
- Category-aware suggestions, so the question you are most likely to want is already on the screen.
Built With
- call-e
- express.js
- foursquare
- gemini
- google-maps
- google-places
- html
- javascript
- node.js
- pwa
- redis
- render
- service-worker
- supabase
- upstash
Log in or sign up for Devpost to join the conversation.