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.
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.
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.
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 →
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.
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.
| Concern | Before | Now (production) | What changed in practice |
|---|---|---|---|
| Hosting | Vercel + Fly.io / Render | Azure Container Apps (2 apps, 1 env) | Frontend and backend share one environment, one log workspace and one deploy pipeline. |
| Database | Supabase Postgres | Azure PostgreSQL Flexible Server | Plain Postgres with pgvector. The cache uses an ivfflat cosine index. |
| Auth | Supabase Auth | Better-Auth | Email/password and Google OAuth, with sessions stored in the same Postgres and an admin plugin for roles. |
| Access control | Row-level security policies | Server functions + parameterised SQL | The browser no longer queries the database directly. Every read and write goes through server code. |
| File storage | Supabase Storage | Azure Blob Storage | Uploads are type- and size-checked (images only, ≤ 10 MB) and served through time-limited SAS URLs. |
| Secrets | Platform env vars | Azure Key Vault | Bicep reads each secret from Key Vault at deploy time and injects it as a Container App secret. |
| Observability | Arize Phoenix | Phoenix + Application Insights | One OpenTelemetry provider sends every span to both: Phoenix for LLM debugging, App Insights for ops. |
| Models | Claude Sonnet 4.5 (EU region) | Claude Sonnet 4.6 + Haiku 4.5 | Called through Bedrock global inference profiles, which route to available capacity across regions. |
One push to production, two new revisions
- Build & testESLint,
tsc --noEmitand a production build for the frontend; ruff lint, ruff format and mypy for the backend. - Build imagesEach container image is tagged with the commit SHA and pushed to GitHub Container Registry.
- Roll out
az containerapp updatepoints each app at its new image, and Container Apps starts a new revision. - ServeEach app keeps at least one warm replica and scales out to three under load.
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.
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.
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.
Timings and picks here are illustrative. The event names and their order come from the real backend (agents/_pipeline.py).
Trade-offs worth explaining
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.
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.
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.
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.
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.
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.
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
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
{"intent": "shopping"}. Anything it can't parse becomes general.Semantic cache & cosine similarity tools/cache.py
Reflection loop _pipeline.py · Deep mode
MAX_LOOPS = 2), which keeps most of the quality gain while bounding latency and cost.Structured output per-agent schemas
{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
Infrastructure as Code (Bicep) infra/*.bicep
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
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
Global inference profiles BEDROCK_MODEL_*
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
== 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.