Goal: Group activity and venue planning with vibe scoring, real-time risk detection, and spatial visualization.
LOCATR is a multi-agent pipeline built on LangGraph. A user types a natural language query; six specialized agents process it in sequence (with three running in parallel), and the system returns ranked venues with plain-English explanations of why each one works — or doesn't.
The core loop:
COMMANDER → SCOUT → [VIBE MATCHER | COST ANALYST | CRITIC] → SYNTHESISER
The three middle agents (vibe, cost, critic) run concurrently via asyncio.gather(). The Commander and Synthesiser always run. Everything else is conditional.
Backend: FastAPI + LangGraph, Python 3.11+
Frontend: Next.js 14, Mapbox GL JS, Tailwind CSS
Auth: Auth0 (Universal Login, Management API, CIBA, Token Vault)
AI: Google Gemini 2.5 Flash (multimodal) + Gemini 1.5 Flash (text)
Discovery: Google Places Text Search API, Yelp Fusion
Actions: Native Gmail API via httpx
Risk Data: OpenWeather API, PredictHQ
Persistence: Snowflake (VENUE_RISK_EVENTS, CAFE_VIBE_VECTORS)
Voice: ElevenLabs TTS
Model: Gemini 1.5 Flash
File: backend/app/agents/commander.py
The Commander is the first node in every request. It does two things: pull the user's stored preferences from Auth0, then send the raw prompt to Gemini to parse structured intent.
Gemini returns a JSON object with:
parsed_intent— activity, group size, budget (low/medium/high), location, vibe keywordcomplexity_tier— tier_1 (simple lookup), tier_2 (multi-factor), tier_3 (strategic/business)active_agents— which downstream agents to runagent_weights— importance scores per agent (0.0–1.0), adjusted by user profilerequires_oauth+oauth_scopes— if the query implies an action (send email, check calendar)
If Gemini is unavailable, a keyword-based fallback extracts intent from the raw prompt using three hardcoded dictionaries (budget keywords, vibe keywords, activity keywords).
User profile preferences from Auth0 (budget_sensitivity, vibe_first, risk_averse) directly influence the weights passed downstream. A student profile bumps cost weight; an explicit vibe request bumps vibe weight.
The Commander never touches OAuth tokens — it only declares that a scope is required. Execution authority sits with Auth0 + the Synthesiser, not here.
Tools: Google Places API, Yelp Fusion
File: backend/app/agents/scout.py
The Scout queries both Google Places and Yelp concurrently (up to 8 results each), then deduplicates by:
- Case-insensitive name match
- Haversine distance < 100m (catches same venue with slightly different names)
When both sources have pricing data for a venue, both values are passed through — the Cost Analyst resolves the conflict later.
After dedup, the Scout calls Snowflake to pull historical risk events for each venue (past floods, marathon route closures, etc.). This gets injected directly into the Critic's context, bypassing LLM hallucination.
Results are capped at 10 candidates.
Output per venue:
{
"venue_id": "gp_abc123",
"name": "Example Cafe",
"address": "123 Main St, Toronto",
"lat": 43.65,
"lng": -79.38,
"rating": 4.4,
"price_range": "$$",
"price_source": "google",
"photos": ["url1", "url2"],
"category": "cafe",
"historical_risks": []
}Model: Gemini 2.5 Flash (multimodal)
File: backend/app/agents/vibe_matcher.py
For each candidate venue, the Vibe Matcher sends venue photos + text metadata to Gemini and gets back a 52-dimension vibe vector. The dimensions cover things like: cozy, minimalist, cyberpunk, dark academia, romantic, lively, rustic, industrial, cottagecore, instagrammable, and 42 others.
All venues are scored in parallel. If a specific vibe was requested (e.g., "dark academia") and a venue scores below 0.4 on that dimension, it's filtered out. The fallback logic ensures at least 3 venues always survive — it re-promotes the highest-scoring rejected venues if the list would otherwise be too small.
Failed API calls for individual venues return {score: null, confidence: 0.0} and the pipeline continues.
Output:
{
"vibe_scores": {
"gp_abc123": {
"score": 0.72,
"vibe_dimensions": [0.8, 0.3, 0.6, ...],
"style": "cozy",
"confidence": 0.85
}
}
}Model: None (pure heuristic)
File: backend/app/agents/cost_analyst.py
No LLM calls here. The Cost Analyst normalizes price signals from Scout and resolves conflicts:
| Scenario | Outcome |
|---|---|
| Google + Yelp agree | Use value, confidence: "high" |
| Only one source | Use value, confidence: "medium" |
| Conflict (e.g., $ vs $$$) | Median, confidence: "low" |
| No data at all | price_range: null, confidence: "none" |
Pricing is display-only. No scraping, no numeric cost computation, no OAuth.
Value scores: $ → 0.8, $$ → 0.6, $$$ → 0.4, $$$$ → 0.2, adjusted slightly downward for low/estimated confidence.
Model: Gemini (adversarial reasoning)
Tools: OpenWeather API, PredictHQ
File: backend/app/agents/critic.py
The Critic runs against the top 3 candidates. For each, it:
- Fetches current weather from OpenWeather (temp, condition, description)
- Fetches upcoming events within a 1-mile radius from PredictHQ
- Injects Snowflake historical risks (from Scout)
- Sends everything to Gemini with an adversarial prompt: "find dealbreakers"
Gemini returns risk flags per venue (with severity: high/medium/low). The veto flag is set but the pipeline doesn't halt on veto — the Synthesiser surfaces the risk in watch_out text and the frontend shows it as a warning.
Risk events are logged back to Snowflake (VENUE_RISK_EVENTS) for future Scout queries to pick up, with deduplication to avoid repeated entries.
Output:
{
"risk_flags": {
"gp_abc123": [
{"description": "Outdoor court, heavy rain forecast Saturday", "severity": "high"}
]
},
"veto": false,
"veto_reason": null
}Model: Gemini 2.5 Flash
File: backend/app/agents/synthesiser.py
The Synthesiser collects all agent outputs and produces the final ranked list.
Composite scoring:
composite = (w_vibe × vibe_score) + (w_cost × value_score) + (w_risk × risk_score)
Default weights: vibe=0.33, cost=0.40, risk=0.27. These are overridden by whatever the Commander set in agent_weights.
After ranking, Gemini generates why and watch_out text for each top-3 venue, plus a global_consensus — a single comparative sentence highlighting the best pick across price, rating, weather, and vibe.
If the Commander flagged an OAuth requirement (e.g., sending an email), the Synthesiser attempts an Auth0 CIBA (Client Initiated Backchannel Authentication) push notification to the user's mobile device.
- If approved, it retrieves the user's Google OAuth2 token from the Auth0 Token Vault.
- It then executes the action directly on the backend (e.g., dispatching an email via the native Gmail API using
httpx). - It returns an
oauth_successpayload to the frontend, which renders an auto-dismissing confirmation UI. If CIBA fails or is unsupported, it falls back to requesting standard frontend browser consent.
Output (per venue):
{
"rank": 1,
"name": "Example Cafe",
"address": "123 Main St, Toronto",
"lat": 43.65,
"lng": -79.38,
"rating": 4.4,
"vibe_score": 0.72,
"price_range": "$$",
"price_confidence": "medium",
"why": "Best vibe match for cozy + affordable. 4.4 stars from 200+ reviews.",
"watch_out": "Gets busy weekend afternoons."
}File: backend/app/models/state.py
class PathfinderState(TypedDict):
raw_prompt: str
parsed_intent: dict
auth_user_id: Optional[str]
user_profile: Optional[dict]
complexity_tier: str # "tier_1" | "tier_2" | "tier_3"
active_agents: List[str]
agent_weights: dict
requires_oauth: bool
oauth_scopes: List[str]
allowed_actions: List[str]
candidate_venues: List[dict]
vibe_scores: dict
cost_profiles: dict
risk_flags: dict
veto: bool
veto_reason: Optional[str]
fast_fail: bool
fast_fail_reason: Optional[str]
ranked_results: List[dict]
global_consensus: Optional[str]
action_request: Optional[dict]
member_locations: List[dict]
chat_history: Optional[List[dict]]
snowflake_context: Optional[dict]{ "status": "ok", "service": "pathfinder" }Runs the full pipeline synchronously. Use this for testing; the frontend uses the WebSocket.
Request:
{
"prompt": "Basketball court for 10 people under $200",
"group_size": 1,
"budget": null,
"location": null,
"vibe": null,
"member_locations": [],
"chat_history": []
}Response:
{
"venues": [ /* VenueResult[] */ ],
"global_consensus": "Example Cafe offers the best value...",
"execution_summary": "Pipeline complete.",
"user_profile": { ... },
"agent_weights": { "scout": 1.0, "vibe_matcher": 0.6, "cost_analyst": 0.9 },
"action_request": null
}The primary frontend endpoint. Streams agent progress as logs, then emits the final result.
Client sends on connect:
{ "prompt": "...", "member_locations": [] }Server streams (one per log line, per agent):
{
"type": "log",
"node": "scout",
"message": "[SCOUT] Found 6 candidates after dedup"
}Server sends once, at the end:
{
"type": "result",
"data": { /* PlanResponse */ }
}?auth_user_id=auth0|abc123
Returns the user's stored preferences from Auth0 app_metadata.
Merges new preference values into Auth0 app_metadata.
?vibe_index=4
Returns all venues from Snowflake's CAFE_VIBE_VECTORS table with their score for the requested vibe dimension (0–51). The frontend uses this to render a heatmap layer on the map.
Calls ElevenLabs to convert text (e.g., a why explanation) to speech.
{ "text": "This venue has great lighting and a cozy atmosphere.", "voice_id": "..." }Returns audio/mpeg.
Two tables:
VENUE_RISK_EVENTS — Critic writes to this. Scout reads from it.
VENUE_ID,VENUE_NAME,RISK_DESCRIPTION,WEATHER_CONTEXT,VETO_TIMESTAMP
CAFE_VIBE_VECTORS — Used for heatmap queries and vibe similarity search.
VENUE_ID,NAME,LATITUDE,LONGITUDE,H3_INDEX,VIBE_VECTOR VECTOR(FLOAT, 50),PRIMARY_VIBE
The VIBE_VECTOR field stores the 52-dimension vibe embedding per venue. The heatmap endpoint queries this to return {lat, lng, score} points for a given vibe dimension.
Pages:
/— Landing page with Auth0 login. Animated SVG trail effect./map— Main interface. Mapbox map + search bar + sidebar.
Map features:
- 3D buildings layer (fill-extrusion at pitch 45°)
- User geolocation tracking
- Ranked pins: gold (#1), silver (#2), bronze (#3), grey (rest)
- Click pin or sidebar card → map flies to venue
- Optional heatmap layer via vibe filter
Sidebar:
- During search: Live agent logs, grouped by node
- After results: Ranked venue cards with vibe score, price, rating, why/watch_out
- Tab toggle between logs and results
- Drag-resizable
Agent Row:
- Bottom bar showing completed agent badges
- Dismisses when results arrive
Preferences Panel:
- Shows active Auth0 preference weights (budget_sensitivity, vibe_first, risk_averse)
Vibe Filter:
- Modal to pick one of 52 vibe dimensions
- Triggers
/api/vibe-heatmapand renders heatmap layer on map
Backend (backend/.env):
GOOGLE_CLOUD_API_KEY= # Google Places + Gemini
YELP_API_KEY=
OPENWEATHER_API_KEY=
PREDICTHQ_API_KEY=
ELEVENLABS_API_KEY=
AUTH0_DOMAIN=
AUTH0_CLIENT_ID=
AUTH0_CLIENT_SECRET=
SNOWFLAKE_ACCOUNT=
SNOWFLAKE_USER=
SNOWFLAKE_PASSWORD=
SNOWFLAKE_DATABASE=PATHFINDER
SNOWFLAKE_WAREHOUSE=COMPUTE_WH
CORS_ORIGINS=["http://localhost:3000"]
Frontend (.env):
NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=
NEXT_PUBLIC_WS_URL=ws://localhost:8000
AUTH0_SECRET=
AUTH0_BASE_URL=http://localhost:3000
AUTH0_DOMAIN=
AUTH0_CLIENT_ID=
AUTH0_CLIENT_SECRET=
Every agent logs structured messages at INFO level, prefixed by agent name. Logs are visible in the CLI runner and streamed to the frontend via WebSocket.
| Prefix | Agent |
|---|---|
[COMMANDER] |
app.agents.commander |
[SCOUT] |
app.agents.scout |
[VIBE] |
app.agents.vibe_matcher |
[COST] |
app.agents.cost_analyst |
[CRITIC] |
app.agents.critic |
[SYNTH] |
app.agents.synthesiser |
[GRAPH] |
app.graph |
Root logger and httpx/httpcore are set to WARNING to suppress noise.
- The system executes real-world actions on behalf of the user. OAuth scope detection is live; transactional actions (like automated email dispatch for bookings) are fully wired up via Auth0 CIBA push notifications and Auth0 Token Vault extraction.
- Local dev supports a mock auth user (
auth0|local_test) that bypasses the Auth0 Management API lookup. - The vibe heatmap only returns data if
CAFE_VIBE_VECTORShas been populated (there's averify_populationutility for this). - Parallel execution (vibe/cost/critic) cuts typical pipeline latency from ~9s to ~2–3s depending on API response times.
- All agent outputs fall back gracefully: keyword heuristics for Commander,
score: nullfor failed Vibe calls,price_range: nullfor missing cost data.