Problem statement
Individual investors own most of America's small rental buildings — the fourplexes, duplexes and six-unit walk-ups — and most of them manage in a spreadsheet: a rent roll with a unit, a tenant, a rent and a lease end date. Nobody looks at that roll and says: here is the money you're leaving on the table, here is which lease to fix first, and here is what to send.
The costs are quiet and they compound. A unit that drifted $200 below market is $2,400 a year, every year, until someone notices. A lease that ends in January re-signs in January, the weakest month of the year. An increase sent to the wrong tenant triggers a move-out that costs more than the increase was worth. Property-management software wants to run the whole business; rent-comparison sites want one address at a time. The landlord with sixteen doors and a spreadsheet has neither the time nor the tooling to do this analysis, so they price by gut.
Who it affects: roughly 11 million individual landlords in the US, owning about seven in ten rental units. On the 16-unit sample portfolio in the demo, the gap is $29,280 a year — about $488,000 of building value at a 6% cap rate.
Solution overview
Drop a rent roll (CSV or Excel, any column names). Five seconds later:
- The number. Every unit is matched to HUD's Small Area Fair Market Rent for its exact ZIP code and bedroom count (FY2027; 38,601 ZIPs, studio to 4+ bedrooms). gap = max(0, benchmark − rent) × 12, summed. A cap slider (default 6%) turns that into what's recoverable this renewal cycle without losing good tenants.
- The plan. Gemini reads the full analysis and writes a short memo: a headline, three to five actions with the units and figures behind each, a "skip" list, and one caution. You can then ask it questions in plain English.
- The whole audit under it. Building grades (A–F on the share of rent left on the table), unit-by-unit bars, twelve months of cash flow under three policies, lease-timing against each ZIP's seasonal curve (Zillow ZORI), expiration concentration, the Housing Choice Voucher option (HUD FMR is the payment-standard basis), a utilities correction, a fairness check against the ZIP's median household income (Census ACS), and move-out risk priced into every increase.
- Renewal letters, written. One per unit: new rent (capped, never above the benchmark), a term chosen so the lease ends at the seasonal peak, notice language. Ten letters, one PDF.
Everything except the model call runs in the browser. There is no account and no upload of your file.
AI usage explanation — what AI does, and what code does
This tool is deliberately not an LLM guessing rents.
Code decides every number. The benchmark, the gap, the cap, the seasonal index, the best month, the renewal term, the grade, the cash-flow projection, the payback on a move-out, the voucher upside, the rent burden — all of it is a deterministic TypeScript engine over three public datasets (HUD FY2027 SAFMR, Zillow ZORI by ZIP, Census ACS 5-year median income). The same roll gives the same numbers every time, and a judge can recompute any of them by hand from the sources listed on the page.
AI does two things code can't:
It writes the plan. After the engine runs, the client builds a brief — a JSON serialisation of everything the engine computed, with tenant names stripped — and sends it to POST /api/plan. A Vercel function holds the Gemini key and calls Gemini Flash in JSON mode against a strict response schema. The system prompt forbids computing, combining or inventing figures: the model's job is judgment and language — what matters most, in what order, said plainly. POST /api/ask answers free-text questions the same way, with a confidence field the model must set to "from the brief", "partly from the brief" or "not in the brief".
It reads exports the parser can't. Heuristic column detection handles clean files. When it fails — a real property-management export with "Mo. Rate", "Apt #", "2x1", no ZIP column — POST /api/map sends the headers and six sample rows to the model and gets back a schema mapping. Missing ZIPs are looked up from street addresses with the U.S. Census geocoder first (exact, keyless, deterministic); only addresses it can't match go to the model, and those are marked "?" in the UI. The header then states exactly what happened: "AI mapped property ← 'Bldg / Street', rent ← 'Mo. Rate', lease end ← 'Exp.' · 16 ZIPs looked up with the U.S. Census geocoder."
And the code checks the AI. verify() in src/lib/ai.ts extracts every dollar amount, percentage and month-count from the model's prose and matches it against the set of numbers in the brief. The UI then says "26 of 26 figures in this memo trace to the engine" — or, when the model does something it shouldn't, flags it. During testing it caught the model adding two gaps into "$4,080", a correct sum the engine never produced; the line read "23 of 24 figures trace to the engine — could not verify: $4,080." That is the intended behaviour: the model is useful and it is watched.
Why this model choice. Gemini Flash is fast and cheap enough for a per-click call, supports JSON-schema output, and has a per-call thinking budget — 512 tokens for the plan, zero for the fast paths. The free tier is 20 requests a day per model, so the proxy chains six Flash models and remembers which are exhausted; the two sample rolls also ship with a plan generated once by the live model (labelled as such, with a "Rewrite live" button). Anything you upload is always live.
How I built it
Data pipeline (scripts/build-data.py): HUD's FY2027 SAFMR workbook (51,872 rows) → JSON keyed by ZIP; Zillow's ZIP-level ZORI (8,543 ZIPs × 139 months) → latest rent, YoY, and a 12-month seasonal profile per ZIP; Census ACS table B19013 (30,618 ZCTAs) → median household income by ZIP. Parsing: PapaParse + SheetJS with fuzzy column detection. Engine (src/lib/analyze.ts): pure, deterministic functions. AI layer: api/_gemini.ts (origin allow-list, per-IP rate limit, body cap, JSON-schema output, thinking budgets, retry on 503, quota-aware model fallback), api/plan.ts, api/ask.ts, api/map.ts; src/lib/ai.ts (buildBrief, verify). UI: React 19 + Vite + Tailwind v4. Deployed on Vercel with a GitHub Pages mirror.
Challenges I ran into
Thinking models and JSON mode: the first plan calls came back truncated — the model spent 1,729 tokens thinking inside an 1,800-token budget. Fix: an explicit per-call thinking budget and an output ceiling that adds it back. The free tier is 20 requests per day per model: the proxy now treats a daily-quota 429 as "next model, no retry" and chains six models. The model was confidently wrong about a ZIP (15201 vs 15206 for 5400 Penn Ave, Pittsburgh) — so the Census geocoder now goes first and model-inferred ZIPs are flagged. ESM on Vercel's Node runtime needs explicit .js import extensions. And keeping the honest claim honest: the page used to say "nothing leaves your browser"; it now says "0 tenant names ever leave your browser," which is exactly true.
Accomplishments I'm proud of
A memo a landlord can act on today, where every figure is traceable to a public dataset and the tool tells you when the model strayed. A messy real-world export that becomes the same clean number.
What I learned
Where to draw the line between the model and the code. Rents are a place where a wrong number gets sent to a real tenant, so the model shouldn't be the source of numbers — but it is very good at reading a page of them and saying what to do. Making that boundary visible ("26 of 26 figures trace to the engine") matters as much as the boundary itself.
What's next
Save rolls and re-run monthly with change alerts; state-specific notice periods and rent-cap rules in the letters; an audit PDF for lenders; letting the advisor call the engine as a tool for "what if" questions.
Attribution
HUD User — FY2027 Small Area Fair Market Rents (public domain). Zillow Research — ZORI, ZIP level (© Zillow, used with attribution). U.S. Census Bureau — ACS 5-year B19013 (public domain); Census Geocoder. Google Gemini via the Gemini API. Photographs via Wikimedia Commons (CC BY-SA 4.0 / CC0). Type: Satoshi (Fontshare), Instrument Serif (OFL). Built solo by Rishik Rontala with AI assistance (Claude) for code and copy; every number on the page comes from the datasets above.
Built With
- gemini
- jspdf
- papaparse
- react
- sheetjs
- tailwindcss
- typescript
- vercel
- vite
Log in or sign up for Devpost to join the conversation.