Architecture
Mapvest is a three-tier product: iOS client, HTTP API, shared TS packages. Everything else is glue.
┌────────────────────┐ HTTPS ┌────────────────────────┐
│ iOS (Expo/RN) │ ───────────────────► │ apps/api (Bun + Hono) │
│ camera · map · │ ◄─── json + jpegs ─── │ /v1/identify │
│ list · admin │ │ /v1/nearby │
└─────────┬──────────┘ │ /v1/resolve-comparable
│ │ /v1/auth · /v1/admin │
│ └────────────┬───────────┘
│ │
│ ┌───────────────────┼────────────────┐
│ │ │ │
│ ▼ ▼ ▼
│ packages/vision packages/finance packages/search
│ (OpenRouter) (ticker / ETF) (Exa)
│ │ │ │
│ ▼ ▼ ▼
│ OpenRouter Yahoo · SEC Exa Web Search
│ (Gemini 2.5 / Polygon · ETF.com
│ Claude 5 vision)
│
└── landing (Next.js) ── mapvest.app — docs, TestFlight link
Request flow — “what is this?”
- Client sends
POST /v1/identifywithimage+ optionallocation {lat, lng}. - API validates via
packages/corezod, applies auth + rate limit. packages/vision.identifyFromImage(bytes, {location})calls OpenRouter with a multimodal model. Prompt asks for{brand, product, sector, visible_text, confidence}.packages/finance.resolveTicker(brand)looks up a first-party mapping table. On miss, callspackages/search.searchBrand()(Exa) and asks the LLM to extract a ticker with citations.- If the brand is private,
packages/finance.resolveComparable()finds the closest public co and an ETF with meaningful exposure. Sources attached. - API returns a single
IdentifyResponsewithinvestable[]andsources[].
Request flow — “what’s around me?”
- Client sends
GET /v1/nearby?lat=..&lng=..&radius=... - API calls Google Places (server-side, using the server’s Maps key).
- Places results are joined with a brand→ticker table (cached in Redis/Postgres).
- Unknowns are batched to
packages/finance.resolveTicker(Exa + LLM). - Response is a
NearbyResponsewith markers, tickers, and sector tags.
UX mean (client)
Photos → Share → Mapvest is the same identify loop as Camera (/share-intent).
The share extension is native; JS alone cannot appear in the iOS share sheet.
Camera always opens a live shutter (last snap is not the landing state). The
annotator is optional: snap identifies immediately, Refine (circle +
hint) re-runs it, and roi / hint / location now reach the API. The result
card leads with a meaning line (“you can own this” / closest public cousin),
then price, a confidence badge, source chips, and “Added to your finds.”
The product is four layers, in this order: (A) identify anything around
you via camera or map (public ticker, or private → public comparable + ETF)
so the user builds an investable universe from the world; (B) agentic
research on that company or the local economy (regional quirks that make a
street better or worse for a business); (C) a finance agent with tools
that writes briefs and memos, with saved chats and Chat about / Chat with on
the map and everywhere else; (D) analytics and charts (trends, levels,
auction / ridge / regression) so they can think about how and why to own or
trade the name. Mapvest sits on three APIs: location/image → public + private
identity and comparables; a finance agent with stats tools; and a charts
stack. First session still teaches one loop: camera or map → one identity →
one ticker card with sources. Every successful identify is recorded
server-side as a find (user_finds, GET /v1/finds). Home is
discovery-first: snap hero → map link → Your universe (recent finds and
streak; the header row is tappable and opens /universe) → ticker search →
Local Economy Brief → watchlist (+ daily brief / movers / backtest after
saves). Search follows discovery, never precedes it. /universe is the full
finds journal — day-grouped, with Δ since found — reachable from Home’s
universe header, the sidebar, and the camera result card. The loop strip under
the snap/map hero is gone, and so is Home’s top-bar camera icon — the
snap/map hero card remains Home’s camera entry. Map and List are blended (bottom sheet on the map + View as List / View as Map
toggles) rather than duplicated as Home widgets. There is no bottom tab
bar — destinations live in the profile drawer (left-sliding) and a camera
icon sits in the top-right of Map / List. The sidebar is consolidated to
Home, Map, Camera, Your universe, Watchlists, Saved places, Research,
Alerts, Profile — the “Nearby list” and “Find ticker” rows are removed
(list view lives inside Map; search lives on Home). The Local Economy Brief sits
above the watchlist on every Home load (featured chrome, always refreshable)
and is neighborhood-scoped (Nominatim zoom 16 + suburb/neighbourhood,
map viewport center when the user has panned the map — not “New York, New
York”). Opus writes it first. Daily and local briefs both carry “sources
cited · research, not advice” and never name model providers. The client
heartbeats its last location via push prefs so the moved-2km “new
neighborhood” push can fire. Research chat proxies Derivation; if that
service returns a machine error (MODEL_BUDGET_EXHAUSTED) we fall back to
OpenRouter (Grok 4.6 → GPT-5.6 Luna → Opus 4.8) and never show the raw code. Every screen has a
menu burger (shared AppTopBar or the tab header). Map refetch follows
zoom (viewport radius + zoom bucket); Apple/Google POIs are hidden so only
our pins carry tickers; same-brand locations inherit a resolved ticker;
overlapped chips go to the pin closest to the viewport center. The map
renders a My finds layer — small jade camera badges where the user found
things — toggleable from the nearby sheet. Overview
shows a native Yahoo price series; analyzer PNGs live in an Analytics
section with auction / ridge / regression chips. A listed ticker page is
ticker → name → chart → save/research/Robinhood/alert → comps → analytics
→ glance → financials → news → full brief. Detail is progressive
disclosure: Financials, SEC, and Sources collapse by default; Analytics
stays open with a plain-language explainer per chart type; empty
Comparables / ETF / Sources sections are hidden entirely for listed names.
Comparables lead only when the name is actually private. News opens an
in-app reader (Safari is optional).
A one-screen first-open sheet
(mapvest.firstOpen.v1) appears once and routes to Camera or Map — never a
carousel. Mapvest Daily and Local Economy Brief both collapse behind a
chevron. Leading $ cashtags stay on map pins and in prose; list rows show
the ticker without a prefix. Overlapped map tooltips use a two-tap sequence
(reveal → open summary). Home search suggests tickers as you type. Detail
staggers section render so the sheet never hangs on a blank spinner.
Layering rules
apps/*may importpackages/*.apps/*may not import from anotherapps/*.packages/*may import from otherpackages/*only if the DAG stays acyclic.coreis the leaf.
Storage
| Data | Store | Retention |
|---|---|---|
| Sessions | Postgres (Railway) | 30d |
| User photos (opt-in) | Signed S3 bucket keyed by uid | 7d default |
| Brand→ticker cache | Postgres | ∞ (versioned) |
| Request log (admin) | Postgres | 30d |
| Cost telemetry | Logfire | 90d |
Observability
- Logs: Logfire via
pydantic-logfireon the API. Structured spans per request. - Metrics: Railway service metrics + Logfire counters (identify latency p50/p95, hit rate on brand cache).
- Errors: Logfire issue tracking, Slack channel
#mapvest-alerts.
Failure modes
| Mode | Detection | Response |
|---|---|---|
| OpenRouter down | 5xx or timeout | Fall back to Gemini direct via GEMINI_API_KEY. |
| Exa quota exhausted | 429 | Serve cached brand map, mark confidence: "low". |
| Google Places quota | 429 | Serve nearest-cached-tile response, refresh async. |
| Vision returns low confidence | model output | Ask user to reframe; do not return a ticker. |