Inspiration

Every app ships one frozen interface. Someone designed it months ago for an "average user" who doesn't exist. So a student opening their inbox wants to know am I about to get charged?, a manager opening the same inbox wants what's blocked on me?, and both get the identical screen. They fight it with filters, hidden columns, and CSV exports just to see what they came for.

We ran five user personas: a student, a professional, a casual user, a manager, a freelancer. We asked each what they'd actually type into an inbox tool. The finding that changed the project: all four who engaged ranked a verdict as their top request, not a number. "Am I screwed this week?" beat any dashboard metric. Nobody wanted a chart first. They wanted an answer.

That told us the problem isn't missing features. It's that the interface can't adapt to the question.

What it does

Lensmaker is an inbox you query in sentences. There are no filter dropdowns, no sort buttons, and no column pickers anywhere in the app. They don't exist, and they aren't hidden.

You type what you care about, and Gemini composes the screen that answers it: choosing from 15 hand-built block types, configuring each one, and returning that choice as validated JSON. Ask something different and you get a structurally different screen, such as a verdict, a calendar grid, a live countdown, or an age-of-inaction histogram. Not the same table re-filtered.

Then you can keep going. "Adjust this view" lets you change the screen in a sentence: add a chart, drop the chart, only subscriptions. Every number opens the emails behind it, so you can always check its work.

How we built it

Gemini as an I/O module, on both ends. That's the whole bet. Remove the model and there's no interface, just a Postgres table and 15 orphaned React components.

Pipeline A, the model as input. 85 raw, messy emails went in. Gemini read every one and extracted structure: amounts, deadlines, urgency, and a phishing judgement naming the specific signals it saw. Nobody typed a single structured field. There's no import form, no CSV, no admin panel.

Pipeline B, the model as output. Your sentence, a digest of the 12 allowed field names, and the block catalog go to Gemini. It returns a ViewSpec: JSON naming 1 to 8 blocks and their configuration. That JSON is validated, pruned, then handed to a switch over block.type.

The model never writes code, never writes SQL, and never touches the database. It picks from a fixed catalog and fills typed fields. Filters are a restricted DSL of {field, op, value} that our own code compiles into a query.

Stack: Next.js, TypeScript, Prisma with Postgres, Zod, Tailwind, Recharts, Docker. A provider abstraction means one environment variable switches between Gemini and a Bedrock fallback, with the same prompt and the same validator on both sides.

Challenges we ran into

Our own schema kept destroying good answers. Three separate times, a length cap rejected correct model output. A 300 character limit on the phishing explanation threw away 3 of 4 detections because the model explained itself too well. A 400 character cap on a cosmetic notes field failed entire specs, discarding four perfectly good blocks. Prose fields now truncate instead of rejecting.

One bad block sank the whole screen. We validated the entire ViewSpec at once, so a single malformed block discarded every good block with it and every question returned the same fallback. The core claim of the project was silently not happening. Now each block is validated individually and only the failures get dropped.

The model invented vocabulary we hadn't defined. Asked "am I screwed this week", it emitted now+7d as a filter value. We only supported now, so it parsed as a literal string and all four blocks rendered empty. We added date arithmetic and documented the vocabulary.

A regex with one character too many. Our fix to force upcoming deadlines into the future had a trailing word boundary, so deadlines never matched deadline. Our single most important demo question was never time bound, and kept showing items 176 days overdue.

Scam money in the spending total. A phishing email demanding a fake $3.20 customs fee was inside the spending figure. Small, but the principle is wrong, and one wrong number destroys trust in every other number on screen.

Accomplishments that we're proud of

The model caught a phishing email we never planted. We wrote a pushy recruiter demanding compensation history, work authorization status, and references, and labelled it as ordinary noise. Gemini flagged it on its own for requesting sensitive personal information. That is the single best evidence that the judgement is real reasoning rather than a keyword match.

15 block types, and the model picks the right one unprompted. "Show me a calendar of my deadlines" returns a calendar. "What have I been ignoring the longest" returns an age histogram. "Who am I paying the most" returns a vendor rollup. We never mentioned block names in any of those questions.

We shipped the switch that turns our own headline feature off. An ablation toggle renders the hand built dashboard instead. Almost nobody ships the control that lets you check whether their AI is load bearing or decorative. It's also the fastest answer to "couldn't you just hardcode this?"

It cannot crash from a model failure. The view endpoint never returns an error. Cache, then model, then a deterministic fallback, verified with no credentials present at all.

Zero tokens to run the demo. Extraction is incremental and cached by content hash, so re-running costs nothing for emails already processed.

What we learned

Prompt rules are not enforcement. Every rule we wrote in the prompt, the model eventually violated: it used prose fields as badges, capped rows at 10 and hid most of the data, used one word labels as row content, skipped the time filter. Everything that actually matters is now enforced in code, with the prompt as a hint rather than a guarantee.

A never fail guarantee hides bugs. Because the endpoint always returns a valid screen, an expired token, a missing dependency, and a rejected spec all looked identical. That cost real debugging time twice. We now distinguish "the model wasn't reached" from "the model's answer was rejected", because a confidently wrong diagnostic is worse than none.

Duplicated logic diverges silently. Our filter DSL exists on the server and mirrored on the client. A disagreement about what null means broke the deadline view in a way that looked like it was working.

Users want a verdict, not a dashboard. Every persona asked for a word before a number. That insight produced our most distinctive block, and it's the one we'd have never designed from engineering instinct alone.

What's next for Lensmaker

Real inboxes. The corpus is persona authored so the demo is reproducible. Gmail OAuth was a deliberate non goal because restricted scope verification takes weeks, but the extraction pipeline is already provider agnostic and ready for it.

Move filtering to the server. Every row currently reaches the browser regardless of what a block asks for. Fine at 85 rows, wrong at 50,000. The Prisma compiler is already written and needs wiring in, which also removes the duplicated DSL.

Learn a user's shape over time. If someone asks about deadlines every Monday morning, the app should already know. Saved views are the foundation for that.

Retry on transient failures. A single provider hiccup currently produces a fallback screen. A warm cache is protection, not a fix.

Multiplayer views. A manager sharing a composed screen with their team, where the same question produces each person's own version of it.

Built With

Share this project:

Updates

Submission history