Home / Lensr
Product deep dive · AI search

Lensr: answers shaped
like your question.

A search for "best headphones under $300" should return a comparison of picks with prices, and a search for "3 days in Lisbon" should return an itinerary. Lensr works out which kind of question you asked, researches the live web with an agent built for it, and streams back a structured card with its sources. It runs in production on Azure Container Apps, with Claude on AWS Bedrock.

35intent-specific agents sharing one adaptive pipeline
10result cards: shopping, trip, recipes, movies…
2speed modes: Fast for everyone, Deep with sign-in
0.88cosine threshold for a semantic-cache hit (24h TTL)
The problem

Search returns links. People want answers in a useful shape.

Chat assistants give you a wall of prose, and search engines give you ten links you have to read yourself. Most everyday questions have a natural answer shape. Shopping needs picks and a comparison table. A trip needs a day-by-day plan. A recipe needs ingredients and steps.

Lensr's core bet is that intent should decide both the research strategy and the UI. The shopping agent searches differently from the legal agent, and its answer renders in a different component.

The 35 intents (highlighted ones have their own card)
shoppingprice_historytripmoviesrecipesbooksplaceseventsinstageneral techhealthfinancenewssportshowtolearningjobslocalcomparisongiftlegalgamingdiyfitnesspetsmusicproductivityweatherreal_estateautomotivefoodfashionparentingdating
One pipeline, many agents

Each agent is a small file (about 25–40 lines) that declares its search angles, output schema and CTA rules. They all share one adaptive pipeline (agents/_pipeline.py), so adding an intent is mostly configuration rather than new code.


01 — Architecture

Two containers on Azure, one stream

Lensr runs as two containers in one Azure Container Apps environment. The browser only ever talks to the frontend container (TanStack Start). It handles sign-in, validates each search, attaches a shared secret and streams the backend container's events straight back. Azure PostgreSQL holds users, saved searches and the vector cache, and Key Vault holds every secret. The models themselves still run on AWS Bedrock, so this is a deliberately multi-cloud setup.

← swipe to see the whole diagram →

Lensr architecture: the browser calls a TanStack Start frontend container on Azure Container Apps, which proxies to a FastAPI backend container. The backend calls AWS Bedrock, Serper and web pages. Azure PostgreSQL stores auth data and the vector cache, Blob Storage holds uploads, Key Vault supplies secrets, and telemetry goes to Application Insights and Phoenix. GitHub Actions deploys both containers. Azure Container Apps · Sweden Central HTTPS · SSE ⇅ X-Backend-Secret Browser React 19 · TanStack Router · 10 result cards Frontend container · TanStack Start Better-Auth: email + Google OAuth POST /api/search → stream proxy origin allowlist · 8 KB · 2000 chars image upload → Blob (SAS URL) Backend container · FastAPI + LangGraph POST /search → text/event-stream router_graph classify (Haiku 4.5) 35 agents plan → search → scrape → reflect → synthesize tools/ serper · http2 scraper · pgvector cache _enrichment chart / table after answer telemetry OTel → 2 exporters Azure PostgreSQL Flexible Server + pgvector users · sessions (Better-Auth) saved_searches · user_roles api_keys · uploaded_images search_cache ivfflat cosine Azure Blob Storage user-uploads · time-limited SAS Azure Key Vault secrets injected at deploy time App Insights + Phoenix same spans, two destinations Log Analytics · 30-day logs GitHub Actions on push to production images → GHCR :sha az containerapp update AWS Bedrock Sonnet 4.6 · Haiku 4.5 · Titan Serper.dev Google SERP as JSON Web pages trafilatura · SSRF-guarded

Solid coloured arrows show the live request path. Grey arrows show data access, and dashed arrows show secrets, telemetry and deployment. Each container runs at 0.25 vCPU / 0.5 GiB and scales between 1 and 3 replicas.


02 — Production

From Vercel + Supabase to Azure

Lensr started on managed platforms: Vercel for the web app, Supabase for the database, auth and storage, and Fly.io or Render for the Python backend. The production branch now runs everything I operate on Azure, defined as code in Bicep, while model calls stay on AWS Bedrock. Here is what moved and why.

ConcernBeforeNow (production)What changed in practice
HostingVercel + Fly.io / RenderAzure Container Apps (2 apps, 1 env)Frontend and backend share one environment, one log workspace and one deploy pipeline.
DatabaseSupabase PostgresAzure PostgreSQL Flexible ServerPlain Postgres with pgvector. The cache uses an ivfflat cosine index.
AuthSupabase AuthBetter-AuthEmail/password and Google OAuth, with sessions stored in the same Postgres and an admin plugin for roles.
Access controlRow-level security policiesServer functions + parameterised SQLThe browser no longer queries the database directly. Every read and write goes through server code.
File storageSupabase StorageAzure Blob StorageUploads are type- and size-checked (images only, ≤ 10 MB) and served through time-limited SAS URLs.
SecretsPlatform env varsAzure Key VaultBicep reads each secret from Key Vault at deploy time and injects it as a Container App secret.
ObservabilityArize PhoenixPhoenix + Application InsightsOne OpenTelemetry provider sends every span to both: Phoenix for LLM debugging, App Insights for ops.
ModelsClaude Sonnet 4.5 (EU region)Claude Sonnet 4.6 + Haiku 4.5Called through Bedrock global inference profiles, which route to available capacity across regions.
Deploy pipeline

One push to production, two new revisions

  1. Build & testESLint, tsc --noEmit and a production build for the frontend; ruff lint, ruff format and mypy for the backend.
  2. Build imagesEach container image is tagged with the commit SHA and pushed to GitHub Container Registry.
  3. Roll outaz containerapp update points each app at its new image, and Container Apps starts a new revision.
  4. ServeEach app keeps at least one warm replica and scales out to three under load.
Infrastructure as code

The whole environment in three Bicep files

main.bicep creates the Log Analytics workspace (30-day retention, 1 GB/day cap), Application Insights, the Container Apps environment and Key Vault. It then calls backendApp.bicep and frontendApp.bicep, passing in the Key Vault secrets and each app's internal URL.

Because the environment is code, rebuilding it in another region or subscription is a single deployment rather than an afternoon of clicking through the portal.


03 — Data flow

Follow one query, step by step

We'll trace best noise cancelling headphones under $300 through the system. Click a stage, use the arrow keys, or press Play. Switch between Deep and Fast mode to see which steps drop out when speed matters more than depth.


04 — Streaming

Why the wait doesn't feel like waiting

A deep answer takes several seconds, but the UI updates from the first 100 ms because the backend yields an event at every step. Replay a (simulated) stream below: the event log on the left drives the interface on the right.

text/event-stream
best noise cancelling headphones under $300
intent
Planning research
Searching the web
Reading sources
Checking evidence
Writing answer

Timings and picks here are illustrative. The event names and their order come from the real backend (agents/_pipeline.py).


05 — Design decisions

Trade-offs worth explaining

latency

Search before you know why

A generic web search starts at the same moment as intent classification. By the time Haiku returns "shopping", seed results are already arriving, which removes a full round-trip from the critical path.

cost × quality

Model tier follows the mode

Haiku 4.5 handles intent routing and everything in Fast mode. Deep mode is the paid-for experience (it requires sign-in), so it uses Sonnet 4.6 for planning, reflection and synthesis on every intent. A single user choice decides the cost and quality trade-off.

robustness

Never block on the LLM

Classification has a 5 s timeout that falls back to general. Cache lookup has a 3 s budget. Cache writes run as fire-and-forget tasks, so a slow database never delays an answer.

ux

Guaranteed CTAs and media

LLMs sometimes forget to include links. The pipeline adds the right action anyway (Amazon, Maps, Booking.com, Keepa) and fills in images from Wikipedia or Open Graph tags.

progressive

Enrichment after "final"

Charts, tables and timelines are generated after the answer is delivered. If enrichment fails, the user already has a complete answer, so the extra visuals can never break the main result.

security

Defence in depth

Secrets live only in Azure Key Vault. All SQL is parameterised, and the browser never queries the database directly. Admin rights sit in a separate role table. On top of that: a timing-safe secret check between containers, a private-IP block-list on the scraper, and uploads restricted by type and size and served through expiring SAS URLs.


06 — Learn the concepts

Glossary for the curious

New to some of these terms? Each one is explained here in plain language, with a note on where it appears in Lensr.

Server-Sent Events (SSE) browser ⇠ backend
A long-lived HTTP response with the content type text/event-stream. The server keeps writing small data: {...} messages as work progresses. It is simpler than WebSockets because it only flows one way (server to browser), which is all a progress stream needs, and it passes cleanly through a proxy such as the frontend container.
Intent classification router_graph.py
Deciding what kind of question this is before answering it. Lensr sends a compact prompt that lists all 35 intents and a few rules (for example "vs" → comparison, "how to" → howto) to a small, fast model, and asks for JSON only: {"intent": "shopping"}. Anything it can't parse becomes general.
Semantic cache & cosine similarity tools/cache.py
A normal cache matches exact strings. A semantic cache matches meaning. Each query is turned into a 1024-number vector (an "embedding") with Amazon Titan. Two queries whose vectors point in almost the same direction (cosine similarity ≥ 0.88) count as the same question, so "top ANC headphones below 300" can reuse the answer to "best noise cancelling headphones under $300".
Reflection loop _pipeline.py · Deep mode
After the first round of research, the model is asked: "Do I have enough evidence to answer well?" If not, it writes follow-up queries and runs another round. Lensr caps this at one extra round (MAX_LOOPS = 2), which keeps most of the quality gain while bounding latency and cost.
Structured output per-agent schemas
Instead of asking for prose, each agent asks the model to fill a JSON schema. The shopping agent, for example, uses {tldr, picks[], comparison_table, detail_markdown}. The frontend renders those fields with real components, which is why answers look like product UI rather than chat.
SSRF protection tools/scraper.py
Server-Side Request Forgery happens when an attacker tricks your server into fetching an internal URL (such as a cloud metadata endpoint). Since Lensr fetches URLs that come from search results, the scraper blocks private ranges (10.x, 172.16–31.x, 192.168.x, 169.254.x) and localhost before any request goes out.
Infrastructure as Code (Bicep) infra/*.bicep
Instead of creating cloud resources by hand in a portal, you describe them in files and let Azure make reality match. Lensr's main.bicep declares the log workspace, Application Insights, the Container Apps environment and Key Vault, then two modules for the frontend and backend apps. The files are version-controlled, reviewable and repeatable.
Azure Key Vault infra/main.bicep
A managed store for secrets (API keys, database URLs, OAuth credentials). Lensr's Bicep template reads each value with keyVault.getSecret(...) during deployment and passes it to the container as a Container App secret. Secrets never appear in the repo, the workflow logs or the image.
SAS URLs lib/storage.server.ts
A Shared Access Signature is a URL to one blob with a signed, expiring permission attached, for example "read this image for the next hour". The storage account stays private. The server hands out short-lived links only to the files a user is allowed to see.
Global inference profiles BEDROCK_MODEL_*
On AWS Bedrock, a model ID prefixed with global. (for example global.anthropic.claude-sonnet-4-6) lets Bedrock route each request to whichever region has capacity. That means fewer throttling errors at peak times, at the cost of not pinning traffic to one region.
Timing-safe comparison main.py · api routes
A normal == stops at the first mismatched character, so an attacker can guess a secret one character at a time by measuring response times. hmac.compare_digest and crypto.timingSafeEqual always take the same time, whatever the input.