Location Scout agent
An AI placement-research agent for operators: ask where to put the next machine — in plain language — and get back ranked candidate locations with citations, a transparent revenue estimate, cited reasoning, an enriched business contact card, and a one-tap handoff into DFY outreach, researched with real venue data. Gated behind a per-user subscription and orchestrated through n8n.
"3 schools in a 3 mile radius, zip code 75001"
│
▼
POST /api/v1/location-scout/requests (operator, subscribed)
│ parse → constraints ┌──────────────────────────────┐
├─────────────────────────────────► n8n "Location Scout" │
│ webhook dispatch │ workflow │
│ │ │ POST /research (token) │
│ │ ▼ │
│ │ research loop runs │
│ │ server-side (Photon/LLM │
│ │ keys never enter n8n) │
│ │ │ POST /callback/{id} │
▼ └───┼──────────────────────────┘
GET /api/v1/location-scout/requests/{id} ◄────┘
ranked results + evidence (n8n down? the backend runs the SAME
research inline — never stuck)
Subscription (per user-id)
The agent is a paid add-on (location-scout-monthly, $29/mo in the sandbox).
Activation is per user-id and idempotent:
| Call | What it does |
|---|---|
GET /api/v1/location-scout/subscription |
Current entitlement + price |
POST /api/v1/location-scout/subscription |
Activate (sandbox: immediate; revenueCatRef is the in-app-purchase integration point) |
POST /api/v1/location-scout/subscription/cancel |
Cancel — in-flight requests finish, new ones are blocked |
Submitting without an active subscription returns 402 with a clear
message. The entitlement lives in the money persistence tier: it survives
/sandbox/reset and pod restarts, and replicates across pods.
Submitting a request
POST /api/v1/location-scout/requests accepts free text and/or structured
fields — they merge (explicit structured fields win):
{
"query": "3 schools in a 3 mile radius, zip code 75001",
"origin": {"lat": 32.96, "lng": -96.84},
"address": "Addison, TX",
"desiredAreas": ["75001", "downtown Plano"],
"radiusKm": 4.8,
"keywords": ["pokemon", "snack"],
"venueRequirements": [{"venueType": "school", "minCount": 3}]
}
Anchor resolution order: explicit origin coordinates → parsed/structured
address or ZIP (server-side self-hosted Photon geocoding) → the operator's fleet
centroid (mean of their pinned machines). No anchor at all ⇒ the request
completes as needs_clarification with a question, never a silent guess.
Poll GET /requests/{id}; terminal statuses are completed, failed,
needs_clarification. A request stuck in flight past the stale window
(default 150 s — e.g. n8n accepted the webhook and died) is re-run locally by
whichever replica gets polled next, so polling always converges.
The natural-language grammar
The deterministic parser (always available — no LLM required) understands:
| Pattern | Example | Parsed as |
|---|---|---|
| Venue counts | 3 schools, 2 colleges and a mall |
venueRequirements (synonyms map: colleges→university, cinema→movie_theater, …) |
| Radius + units | 3 mile radius, within 5 km |
radiusKm (mile/mi/km/kilometers) |
| Bare radius | radius 4 |
assumed miles, with a warning |
| Multiple radii | 2 miles or maybe 5 miles |
last one wins, with a warning |
| ZIP anchors | zip code 75001, 02134 |
string ZIP (leading zeros preserved), geocoded |
| Place anchors | in downtown Austin |
geocoded free-place anchor |
| Exclusions | no competing snack machines, without bars |
competitor / venue exclusions |
| Verticals | pokemon, snack, vape … |
keyword scoring model |
| Foot traffic | high foot traffic |
a venue-density requirement |
| Gibberish | asdf qwerty |
clear 400 — "couldn't extract constraints" |
When the LiteLLM gateway is configured the same text is also parsed by the model against a strict JSON schema; the validated result refines the deterministic parse field-by-field. The query is data, never instructions: every LLM output field is whitelisted, clamped, and re-mapped through the venue/keyword registries, so a prompt-injection attempt can at worst produce empty constraints.
The research loop
Loop-engineered, with hard termination guarantees:
- Probe — candidate spots ring the anchor(s): the center plus 6–8 compass points at the current spread.
- Evaluate — every candidate is scored against every stated requirement
with real venue lookups (
≥3 schools within 3.0 mi→ the actual schools found, with distances, attached as evidence), plus the vertical fit, foot-traffic and competition signals. - Converge or widen — enough fully-satisfying candidates (3) ⇒ done. Otherwise the probe spread widens ×1.6 and the loop re-queries. Candidates dedupe on a ~110 m grid across iterations; venue lookups are cached per grid cell, so nothing is fetched twice.
- Terminate — always: a hard iteration cap (default 4), a per-request
venue-lookup budget (default 80), and a per-lookup HTTP timeout (6 s).
Budget exhaustion returns what was found, marked
partial: true.
Venue data comes from the best live source available, in order
(summary.dataSource always names which one served):
osm-photon— the platform's self-hosted Photon POI index (KIOSKX_GEOCODER_URL; same OSM-backed service behind the /geo endpoints). Cheap in-cluster calls, so lookups run per-candidate. Photon indexes named features — an empty answer is double-checked against Overpass before the loop concludes "none nearby".openstreetmap— public Overpass mirrors (keyless, real data;KIOSKX_SCOUT_OSM_URL, default overpass-api.de with a maps.mail.ru mirror retry). The fallback when Photon is absent or unreachable. To respect Overpass rate limits each venue type is fetched ONCE per request over a region covering the outermost probe ring, then filtered per candidate locally — distances are recomputed, so≥N within Rverdicts stay exact.model— the deterministic demand model, honestly labelled, used when neither live source is available (KIOSKX_SCOUT_OSM=falseand no geocoder URL).
A run that degrades AFTER regional OSM data landed reports mixed: the
per-requirement evidence is real, only the later scoring lookups are modelled.
Failure semantics: one flaky venue lookup yields empty data for that cell
(counted in venueLookupFailures); three consecutive failures switch the
rest of the run to the deterministic demand model with an explicit warning
and dataSource: "model" — the loop degrades, it never hangs.
If no candidate satisfies everything, the response says so explicitly
(constraintsSatisfiable: false, message lists the unmet requirements) and
returns the closest alternatives with the gaps visible per requirement.
Every result row carries:
{
"rank": 1, "label": "Near Addison Elementary", "lat": 32.97, "lng": -96.83,
"estMonthlyRevenueUsd": 1620, "score": 84.5,
"satisfiesAllRequirements": true,
"requirements": [{
"requirement": "≥3 schools within 3.0 mi",
"needed": 3, "found": 5, "satisfied": true,
"evidence": [{"name": "Addison Elementary", "distanceKm": 0.4}, ...],
"citations": ["c1", "c2", "c3"]
}],
"keywordScores": {"pokemon": 71.0},
"complianceNotes": [],
"reasoning": "23 venues within 3.0 mi drive foot traffic [c7]. Vertical fit
for Pokémon cards: 71/100 — anchored by Addison Elementary
[c1, c4]. … Estimate: $1400/mo × 0.83 × 1.19 × 1.1 =
$1620/mo (inputs cited or first-party).",
"citations": [ ... ], // see "Grounding & citations"
"revenueEstimate": { ... }, // see "Revenue estimate — math shown"
"venueName": "Addison Elementary",
"business": { ... }, // see "Business enrichment"
"grounded": true
}
Grounding & citations (the anti-hallucination layer)
Every claim on a result traces to a citation or first-party data — the same
discipline as Machine IQ (app/machine_iq.py::validate_insights):
citations[]— one entry per fact used:{id, source, url, title, snippet, fetchedAt, fact}. Live venue facts cite the real OpenStreetMap element URL (https://www.openstreetmap.org/node/…) resolved from Photon/ Overpass; scraped business facts cite the Firecrawl page URL and carry the verbatim snippet the value appeared in. Modelled facts are honestly labelledsource: "model"with no URL — nothing pretends to be live data.reasoningreferences only cited facts, with inline markers ([c1],[w5]) that resolve into thecitations[]list. Dollar figures in the prose must trace to the estimate inputs.sanitize_resultsruns on EVERY ingest path (n8n callback and local runs) before results are stored: malformed/duplicate citations are dropped, uncited business contact fields are stripped (never fabricated), a revenue estimate whose math doesn't check out is removed, and reasoning that fails the checks is regenerated from validated fields only. The outcome lands insummary.grounding = {checked, sanitized, violations[]}and violations are logged server-side.
Revenue estimate — math shown
Each result carries revenueEstimate with the arithmetic in the open:
{
"estMonthlyUsd": 1620,
"method": "venue-fit demand model over cited nearby venues",
"formula": "baseMonthlyUsd × verticalFitFactor × trafficMultiplier × competitionMultiplier",
"math": "$1400/mo × 0.83 × 1.19 × 1.1 = $1620/mo",
"inputs": [
{"name": "baseMonthlyUsd", "value": 1400, "source": "registry",
"detail": "Kiosk-X 'Pokémon cards' vertical revenue anchor (first-party registry)"},
{"name": "verticalFitFactor", "value": 0.83, "source": "citations",
"citations": ["c1", "c4"], "detail": "0.4 + 0.6 × fit — from weighted counts of the cited venues"},
{"name": "trafficMultiplier", "value": 1.19, "source": "citations", "citations": ["c7"]},
{"name": "competitionMultiplier", "value": 1.1, "source": "citations", "citations": ["c5"]}
],
"peerComparison": {
"fleetAvgMonthlyUsd": 1483.5, "machines": 4, "windowDays": 30,
"source": "first-party revenue ledger (GET /api/v1/revenue/summary)"
}
}
Every input is either registry (the first-party keyword registry),
citations (must resolve into the result's citation list), or
fleet-ledger. peerComparison is the operator's own machines' real
monthly-normalized revenue from the unified ledger (app/routes/revenue.py)
— present only when their fleet actually earned in the window, null
otherwise (never invented). The validator recomputes the product and strips
the whole estimate if the stated number doesn't match the inputs.
Business enrichment (Firecrawl)
For candidates anchored by a real named venue, the n8n workflow runs a Firecrawl search-with-scrape and extracts the business card deterministically and verbatim-only: a field is filled ONLY when its exact value appears on a scraped page, and each filled field carries a citation with the page URL and surrounding snippet. Emails and phone numbers are NEVER fabricated — phones are normalized to E.164 for outreach.
"business": {
"status": "enriched", // enriched | contact_unverified | partial | unverified
"name": "Addison Cafe", "address": "5100 Belt Line Rd, Addison, TX 75001",
"website": "https://addisoncafe.com", "email": "info@addisoncafe.com",
"phone": "+19725550117",
"fields": {"name": "cited", "address": "cited", "website": "cited",
"email": "cited", "phone": "cited"},
"attribution": {
"email": {"confidence": "high",
"reason": "found on a page that belongs to this business",
"citationId": "w8"},
"phone": {"confidence": "high",
"reason": "business name adjacent to the phone on the page",
"citationId": "w9"}
},
"citations": ["w8", "w9"],
"note": "contact details extracted verbatim from the cited pages and attributed to this business"
}
Not found ⇒ the field stays null and fields.<f> says missing;
no named venue ⇒ the whole card is unverified with an honest note. The
backend re-runs the same extraction rules' validator on ingest, so a
misbehaving workflow cannot smuggle in an uncited contact. When the backend
orchestrates locally it enriches directly iff KIOSKX_FIRECRAWL_API_KEY is
set; otherwise it ships the unverified card.
Contact attribution (business-identity match)
Verbatim extraction alone is not enough: a Yelp search page or a leasing brochure PDF really does contain an email — a broker's or a neighbouring listing's, not the venue's. Every contact field therefore carries an attribution confidence, scored deterministically against the business identity:
- high — the cited page demonstrably belongs to the business (ALL name tokens in the URL slug / site domain / non-listing page title), or (email) the business name is embedded in the address itself.
- low — verbatim-on-page but the page is not the business's own. The
value ships with
fields.<f> = "unverified"and statuscontact_unverified: visible in the UI for operator verification, never prefilled into outreach.
Name-adjacency in the snippet is deliberately NOT sufficient: a leasing
brochure's tenant roster puts the venue's name within characters of the
BROKER's fact-sheet phone, and a directory search page interleaves one
listing's name with its neighbour's address — both observed mis-attributing
contacts on live runs. Search/directory URLs (/search, find_desc=,
q= …) are never "owned" by the business — a name in their query string is
the search FOR the business, not a page OF it. Extraction prefers attributed
matches across pages (a high-confidence phone on page 3 beats an
unattributable one on page 1), and the backend re-scores every contact on
EVERY ingest path (score_business_attribution), so a stale workflow can't
skip the gate.
Dedupe by business identity
Candidates that resolve to the SAME business from different probe anchors
(two probes near one Starbucks) collapse to the best-ranked instance. Any
matching identity key collapses: the venue's OSM record URL (venueRef),
the enriched name+phone pair, name+street-address (one store often exists in
OSM as BOTH a node and a way, so the ref alone is not enough), and — only
without an OSM ref — name + coarse coordinates. Collapsed rows are logged in
summary.grounding.deduped (they are notes, not violations).
Scout → Outreach (one tap into DFY)
POST /api/v1/location-scout/requests/{id}/outreach bridges a completed
scout result into the DFY pipeline:
{"rank": 1, "confirm": true, "autoOutreach": true, "dealId": null, "title": null}
- Lands the scouted location on a DFY deal (new, or an existing pre-outreach
deal via
dealId) as a placement candidate: high-attribution contact info prefilled (phone already E.164), scout evidence attached (source: "location-scout", request id, citations). - Approves the candidate and arms sequenced outreach —
confirm: trueis required (400 otherwise) because this places REAL calls/emails; it IS the operator's outreach confirmation gate. All downstream guardrails (call window, decline stop, negotiation envelope, lease/money checkpoints) apply unchanged. - A candidate with no attributed email/phone lands as
needs_contactimmediately (needsContact: truein the response). Low-confidence (contact_unverified) contacts are NOT dialed — the candidate'scontactVerificationis"unverified"and the operator confirms (or replaces) the contact on the deal screen, which flips it to"verified"and re-queues the touch.
Response: {dealId, candidateId, stage, candidate, needsContact,
contactVerification, outreachStatus, deal}.
Keyword verticals
GET /api/v1/location-scout/keywords returns the extensible registry
(app/location_scout.py::KEYWORDS). Each vertical changes which venue types
the scoring model rewards:
| Keyword | Rewards | Base $/mo | Compliance |
|---|---|---|---|
pokemon |
schools, arcades, family venues, malls, toy stores | 1400 | — |
game |
arcades, family venues, malls, theaters | 1200 | — |
snack |
transit, airports, gyms, offices | 900 | — |
charger |
airports, transit, hotels, cafes | 600 | — |
vape |
bars, night clubs | 1600 | 21+ / Tobacco 21, state vapor licensing, ID-scan required |
alcohol |
bars, night clubs, hotels | 2000 | 21+ / liquor license, most states restrict to licensed on-premise venues |
Age-restricted verticals are never filtered out — their compliance notes are attached to every result and to the summary, prominently.
n8n wiring
The "Location Scout" workflow (n8n.intelli-verse-x.ai, JSON committed at
integrations/n8n/location-scout.json):
- Webhook trigger
POST /webhook/kioskx-location-scout— receives the dispatch payload (requestId, query, constraints,researchUrl,callbackUrl). - HTTP node →
POST /api/v1/location-scout/research— the research brain stays in the backend, so the in-cluster geo services and the LiteLLM key never enter n8n. Auth:X-Scout-Tokenshared secret (orX-Scout-SignatureHMAC-SHA256 of the body). - Code node "Plan Enrichment" — fans out one Firecrawl lookup per top candidate that has a real venue name from the live geo data (max 5).
- HTTP node "Firecrawl Business Lookup" →
api.firecrawl.dev/v1/searchwith scrape (onlyMainContentmarkdown). Auth is the shared n8n HTTP-header-auth credential "Firecrawl API (header auth)" — the same credential Operator Reads uses (env-var-based Firecrawl config in n8n nodes silently fails; always use the credential).onError: continue, so a failed scrape degrades to anunverifiedcard, never a stuck request. - Code node "Extract Business (verbatim + attributed)" — merges the
scraped pages into the results with the SAME deterministic extraction +
attribution rules as the backend (
extract_business_from_pages): fill a field only when its exact value appears on a page, cite the page + snippet, E.164 phones, score attribution confidence per field and prefer attributed matches. The backend re-scores on ingest regardless. - HTTP node →
POST /api/v1/location-scout/callback/{requestId}— stores results. Idempotent: the first completion wins; duplicates are acknowledged withapplied: false. Bad token ⇒ 401, malformed payload ⇒ 400. The backend sanitizes every callback with the grounding validator before storage.
Config (env): KIOSKX_SCOUT_N8N_WEBHOOK (empty disables n8n and the backend
orchestrates inline), KIOSKX_SCOUT_SHARED_TOKEN, KIOSKX_SCOUT_MAX_ITERATIONS,
KIOSKX_SCOUT_MAX_VENUE_CALLS, KIOSKX_SCOUT_STALE_SECONDS,
KIOSKX_SCOUT_PRICE_USD, KIOSKX_FIRECRAWL_API_KEY (optional
direct-Firecrawl enrichment for locally-orchestrated runs; n8n normally owns
the Firecrawl leg).
Assistant + app surface
- Chat: the
scout_locationsassistant tool takes the same natural-language query and returns the top locations with evidence inline (and deep-links to the Location Scout screen). Unsubscribed users get the activation pointer, not an error. - Operator X app: the Location Scout screen has the free-text query box, keyword chips (with 21+ badges), anchor options, and evidence-backed result cards — now with citations, the revenue-estimate math, the business contact card, and the "Start outreach" action.
- Operator web console: the
/scoutscreen mirrors the same surfaces (citations, reasoning, estimate breakdown, contact card, one-tap outreach into/autopilot/{dealId}).
Persistence & replicas
scout_subscriptions— money tier (paid entitlement: survives/sandbox/reset, rehydrates on boot, peer-replicated).scout_requests— FLEET tier: the n8n callback and the operator's poll can land on different replicas, so the full request lifecycle is shared via the Postgres LISTEN/NOTIFY fleet mirror.
Testing
tests/test_location_scout.py (40 cases) covers the full matrix
deterministically: the NL grammar incl. km units, leading-zero ZIPs,
contradictory radii, gibberish and prompt injection; loop convergence with
evidence, zero candidates, impossible constraints (hard-cap termination),
API failures mid-loop, budget exhaustion, cross-iteration dedupe; the
subscription lifecycle incl. 402 gating, idempotent activation and the
concurrent-request cap; callback token+HMAC auth, malformed payloads and
duplicate-callback idempotency; and fleet-tier restart survival + peer-apply.
tests/test_scout_grounding.py covers the grounding upgrade: citations
present and resolvable on every result; revenue-estimate math validation
(recomputed product, input sources, ledger-only peer comparison); verbatim
business extraction from pages (incl. junk-email filtering and E.164);
the no-fabrication guards (uncited email/phone/website stripped by
sanitize_results, fake citations dropped, fabricated dollar figures in
reasoning rejected); and the outreach handoff (confirm gate, new/existing
deal, cited-contact prefill vs needs_contact, guardrails intact).