01 The thesis
What a PE firm does, for a normal person. That’s the whole product, in one sentence.
Institutional buyers have analysts, diligence checklists, and decades of pattern recognition. First-time buyers have a spreadsheet, a broker, and a nagging feeling that they’re missing something. The existing tools on the market either assume you already know what SDE is, or they hide the math entirely and hand you a grade. Neither helps you learn.
I wanted a tool that shows the number and teaches you how to read it — and lets you turn off the teaching once you don’t need it anymore.
The coinage: dual-mode
The term I want to own for this product is dual-mode. Dual-mode is the property that a single source of truth — one number, one calculation, one piece of data — drives two distinct renderings: a compact expert view and an expanded learner view with definition, benchmark range, analogy, and a sentence interpreting what that number means on this specific deal.
Dual-mode has to be a schema-levelconstraint, not a UI polish pass. If it’s a late-stage design decision, half the metrics end up without benchmark data, the AI memo has only one voice, and the buyer profile has nothing to personalize against. Built from the schema up, every metric has a definition and a benchmark range by the time the first row is written. Built from the UI down, you spend three months backfilling.
The gate is also dual-mode-shaped: Learning Mode is Pro-only. Free tier is three analyses a month, numbers-only, expert memo. Pro is $29/month for unlimited plus the whole teaching surface. The teaching surface is what someone actually pays to get.
02 The pipeline
One listing URL in. A full analysis out. Under a minute, about seven cents, five distinct responsibilities handed to the model best suited to each one.
The naive version is one big prompt to one big model. That’s what I’d have built two years ago. Today the frontier is cheap enough and narrow enough that the right shape is one small prompt per job, routed to the model that does that job best. OpenRouter makes that routing a one-line config.
Firecrawl wraps the scraping layer (BizBuySell, Acquire.com, Flippa, MicroAcquire). Gemini 2.5 Flash reads the scraped HTML and returns a structured listing object — mechanical work, cheap and fast. Fifteen-ish financial calculations run in pure TypeScript, no AI involved; SDE, DSCR, the SBA loan structure, cash-on-cash, IRR, payback, breakeven, working capital, concentration risk. Gemini 2.5 Pro reads the listing plus calculations and scans for red flags across five categories. Claude Sonnet writes the memo — because writing voice matters, and Claude writes.
Why no AI in the math
One anti-pattern is underlined in the CLAUDE.md file at the repo root, where every session starts: never use AI for financial calculations — all math is pure TS functions, fully unit testable.
Every calculation function in lib/calculations/has a co-located test file. Adding a new metric is a two-file PR: the function, the test. Claude can write both — it’s just barred from running the math at inference time.
The cost arithmetic on the routing split is the non-obvious payoff. All-Claude would’ve been simpler to reason about and slightly easier to write. It would also have been about 22% more expensive per analysisand noticeably slower on the extraction step. At seven cents per analysis, the margin on a $29/month Pro subscriber is what underwrites the whole business model. Nine cents wouldn’t have.
03 The rebrand that broke the product
Styling is part of the contract. I treated the rebrand as a visual pass. It wasn’t.
Mid-build I redesigned the whole UI to a warmer palette — amber on brown, a “Warm Terminal” look that matched the product’s craftsman voice better than the original indigo. The design work was satisfying. The commit a week later was not: “fix: 7 post-rebrand fixes — auth, analysis flow, metrics, dashboard cleanup.” Which is a polite way of saying the rebrand shipped broken.
Auth redirects referenced old class names. The analysis processing page lost its loading state because the spinner was styled on a DOM ID that had been renamed. Dashboard filtering rules were tied to old data-attributes. The app looked right on the landing page and was subtly broken from the second screen onwards.
Seven routes referenced old class names, old DOM IDs, old data-attributes. The landing page and the dashboard looked right. Auth, analysis flow, and the metric grid did not. Shipping a visual pass without running the full E2E suite meant the first people to test after the commit were users.
A visual redesign touches the functional layer whether you plan for it or not. The right response is to run the full Playwright suite after any design pass, not to eyeball the landing page and call it done.
fix: resolve 4 bugs breaking the AI analysis pipeline — a commit message that should embarrass me, and does. Three of the four LLM calls had to be debugged after the Phase 5 merge. The root cause was that I’d written the prompt functions in isolation with canned inputs, and never run the full orchestrator end-to-end before merging.
“Calls three LLMs in sequence” is a distinct class of bug risk. Unit tests on each prompt pass and the integration still fails — the bugs live in the hand-offs between steps, not in the steps themselves. Treat the orchestrator as the thing that needs its own integration test, not a glue layer you trust.
The pre-launch audit removed almost 4,000 lines of dead code from a codebase of ~25k total. Most were stubs from design experiments that stayed on disk after I pivoted away. I’d been telling myself I’d clean it up later for three phases.
Delete in the same commit that abandons the file. Dead code accumulated across phases is expensive to audit — both for humans and for the AI collaborator reading the repo at session start. The rule I try to hold now: if I haven’t touched a file in two weeks and it isn’t load-bearing, the next commit deletes it.
learning_mode defaulted to trueLearning Mode is a Pro-only feature — the whole teaching surface. I defaulted it to truefor free users early on, which meant the entire moat was showing to people who hadn’t paid for it. Trained the wrong expectation. Caught in the gate matrix rewrite and now called out by name in the anti-patterns list.
A gate is a default. Whichever value the code ships with is the one that defines what free means. The fix wasn’t a conditional — it was moving every gate check into lib/gates.ts as a single source of truth, with the default explicit in one place instead of implicit across twelve components.
04 The threat model
If your threat model is “me on localhost,” your threat model is wrong.
The first security hardening pass caught eleven vulnerabilities. A second pass caught six more. None would have mattered on day one with ten users. All would have mattered on day one with ten thousand.
The fix wasn’t a library or a scanner. It was a question I started asking of every API route: what happens if a hostile user calls this?Asked once, at the route level, for every route. Most routes passed. The ones that didn’t broke in surprisingly similar shapes.
| Endpoint | Assumed caller | Actual caller | Fix |
|---|---|---|---|
| /api/cron/* | Vercel cron, trusted | Anyone with the URL, unauthenticated | Authorization: Bearer CRON_SECRET on every route; reject on mismatch. |
| pg function · run_usage | Postgres default search_path | Caller-controlled search_path → schema substitution | SET search_path = public, pg_temp pinned on every SECURITY DEFINER function. |
| /api/events · deal_revisited | Fires once per visit | Client loop → metric inflated arbitrarily | Server-side dedupe by (user_id, deal_id, hour); throw away client counts. |
| /api/checkout · return_url | Our own domain | User-supplied → open redirect to phish via Stripe | Allowlist regex on return_url; strip any cross-origin prefix. |
| /api/webhook/stripe | Stripe signed it | Anyone can POST JSON that looks like an event | constructEvent() with the webhook secret; reject before touching the DB. |
| env loader | All vars present in prod | Missing var → undefined silently shipped | Zod schema on lib/env.ts; crash on boot if a required var is missing. |
deal_revisited inflation bugThe deal_revisitedevent fed the buyer-profile personalization loop — it’s how the system learns which deals a user actually comes back to. Fired client-side from a useEffect. Any user viewing their own analysis in a loop could inflate their own signal arbitrarily, and since the learning loop was downstream of that metric, a motivated user could steer their own personalization — or worse, aggregate metrics for cohorts they happened to be in.
Never trust a client-fired analytics event as a metric. Treat events as hints; aggregate and dedupe server-side before anything downstream reads them. The general rule I took from this: write down what each endpoint assumes about the caller, and check whether the assumption is enforced or just implied.
05 What it adds up to
Shipped. Eight phases in main. The remaining work is configuration, not code.
The intelligence loops are live — a quarterly benchmark scrape so multiples stay current, and a passive buyer profile that personalizes the learner memo from behavioral telemetry. The pre-launch audit is done. What’s left is live Stripe keys, DNS, Supabase production auth URLs. Thirty-eight checklist items verified; thirty configuration items remaining.
The product lesson I keep coming back to: the moat isn’t the AI, it’s the dual-mode schema. Anyone can wire up OpenRouter and ship a memo generator in a weekend. What they’d have to rebuild from the schema up is the teaching surface — every metric with its benchmark range, every memo with two registers, every gate with the correct default. That’s not a weekend. That’s the product.
The meta-lesson: I now know what it feels like to hold the full shape of a SaaS app in my head at once. Auth, billing, scraping, multi-model AI, structured-output validation, RLS, rate limiting, cron, email, design system, E2E tests. None of them are hard individually. Holding all of them at once, and knowing which are load-bearing for launch and which can wait, is the thing that’s hard to teach — and the thing AI collaboration made possible for a team of one.
Shipped.
| Layer | In the free skill | In DealLens Pro |
|---|---|---|
| calculations | SDE multiple, DSCR, cash-on-cash, payback, SBA loan amortization — deterministic, unit-tested | Same engine, plus an interactive scenario modeler with debt and down-payment sliders |
| red-flag screen | ~20-flag checklist across financials, ops, legal, market, seller categories | Same checklist, plus AI Research Assistant chat withweb access for industry comps |
| memo | Expert and Learner memo templates — one at a time | Dual-mode rendering side-by-side, plus PDF export with professional formatting |
| benchmarks | Not included — bring your own ranges | Industry SDE multiples, gross margins, growth rates, kept current via a quarterly scrape |
| memory | Stateless — one chat, one analysis | Saved-deal dashboard with revisit history; buyer-profile learning that personalizes future analyses |
| posture | Identical — never advisor, always screening, always route to a CPA | Identical. The language rules are the brand. |
- 01Claude CodeDrop the
deallens-lite/directory in~/.claude/skills/. Auto-loads on next session. - 02Claude.ai · desktopSettings → Skills → Add skill → point to the directory.
- 03Project-scopedPlace under
<project>/.claude/skills/deallens-lite/— loads only for that project.