by Intelliverse X

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:

  1. Probe — candidate spots ring the anchor(s): the center plus 6–8 compass points at the current spread.
  2. 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.
  3. 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.
  4. 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):

  1. 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".
  2. 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 R verdicts stay exact.
  3. model — the deterministic demand model, honestly labelled, used when neither live source is available (KIOSKX_SCOUT_OSM=false and 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):

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:

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}

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):

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

Persistence & replicas

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).