Turn a public company's filings, earnings calls and peer set into concrete M&A options — then force-rank them with pairwise voting instead of arguing about a scoring rubric.
See a finished session → — a complete run against Hamilton Beach Brands: 22 briefing cards, four benchmarked peers, 76 options across three voting rounds and 300 votes. No key, no signup, no network calls.
A room of people cannot reliably rate fifty acquisition ideas on a 1–10 scale. The same room can always answer "which of these two matters more". This app asks that question a few hundred times, fits a Bradley–Terry model to the answers, and returns a ranked list with confidence intervals, a written briefing, and a deck outline.
- Read the company —
analyze-companypulls financial statements, four quarters of earnings-call transcripts, analyst coverage and M&A history from Financial Modeling Prep, then computes the deterministic KPI cards (revenue, margin, leverage, acquisition firepower). - Pick a peer set — SIC-based peer discovery, then a benchmark table across margin, leverage, goodwill intensity and acquisition spend.
- Force the choices — three rounds of pairwise voting: strategic posture, then market segments and product categories, then named acquisition targets. New options are injected every few votes based on what is winning.
- Get the ranking — Bradley–Terry MLE scores on an Elo-equivalent scale (median 1500) with Fisher-information confidence intervals, plus a strategic narrative.
Sessions run solo, or live with a room full of people via a share code.
npm install
cp .env.example .env # then fill in the keys below
npx netlify dev # serves the app and the functions together on :8888Use netlify dev, not npm run dev — the app calls Netlify Functions for everything. On WSL,
Vite's startup can exceed the Netlify CLI's detection window: start Vite first (npx vite &),
wait for port 5173, then run npx netlify dev.
| Variable | Required | What it is |
|---|---|---|
ANTHROPIC_API_KEY |
yes | Claude API key. CLAUDE_API_KEY also works. |
FMP_API_KEY |
yes | Financial Modeling Prep — all company data. |
VITE_SUPABASE_URL |
no | Enables live multi-user sessions and the AI response cache. |
VITE_SUPABASE_ANON_KEY |
no | Public key for the browser client. |
SUPABASE_SERVICE_KEY |
no | Server-only; lets functions write the FMP cache. |
BRIGHTDATA_API_TOKEN |
no | Enables Crunchbase-backed private-company sourcing. |
APIFY_TOKEN |
no | Enables Apify actor sourcing. |
CLAY_API_KEY |
no | Enables Clay enrichment of sourced candidates. |
VITE_SITE_ORIGIN |
no | Canonical origin for canonical links, Open Graph and sitemap.xml. Set this if you host it publicly, or the sitemap advertises someone else's domain. |
VITE_GTM_ID |
no | Google Tag Manager container. Unset means no analytics loads at all — the right default for a self-hosted copy. |
RESEND_API_KEY |
no | Contact form delivery. Without it the form returns a clear "not configured" error rather than dropping messages. |
CONTACT_TO_EMAIL |
no | Where contact submissions go. |
Without the optional keys the app still runs end to end: sessions are solo, target sourcing falls back to the public-company screener, and the marketing pages render with analytics disabled.
The Supabase schema lives in supabase/schema.sql — run it in the SQL editor of a fresh project.
Read the SECURITY MODEL comment block in that file before changing any policy.
npm run build # vitest + tsc + vite build + prerender the marketing routes
npm test # unit tests
npm run test:e2e # Playwright (mocks all functions)
npm run lint # eslint
npm run check:tokens # fails on a raw hex literal or default-Tailwind palette classThe app is a React SPA plus a handful of Netlify Functions. There is no database to migrate, no queue and no container — Supabase is optional and only needed for live multi-user sessions.
npm install
cp .env.example .env # ANTHROPIC_API_KEY and FMP_API_KEY are the only required ones
npm run build
npx netlify deploy --prod # or point any static host at dist/ and run the functions elsewhereTwo things worth knowing before you put it on the internet:
- Every session spends real money on the Anthropic and FMP APIs. The functions are origin-checked and rate-limited per instance, but that is a speed bump, not a budget. If your deployment is not meant to be public, put Netlify password protection in front of it.
npm run buildprerenders the marketing pages to real HTML at/,/method,/contact,/privacyand/terms, and writessitemap.xmlfrom the same route list. The SPA shell is preserved asapp.html, whichnetlify.tomlserves as the fallback for every app route. If you swap in a different host, that fallback rule has to come with you.
src/pages/ one page per phase
src/components/ voting/, dashboard/, session/, ui/
src/lib/ bradleyTerry, pairingEngine, storage, supabase sync, api client
src/context/ useReducer game state + localStorage persistence
netlify/functions/ all backend endpoints (.mts)
netlify/functions/lib/ guard, claude, promptData, sourcing, schemas
supabase/schema.sql tables, RLS policies, RPCs
The visual system is ported from the SG&A Benchmarking diagnostic: navy structure, a single hot magenta accent used only where the reader should act, warm paper ground with white panels, hairline rules, 4px corners, Archivo throughout with tabular figures on every number.
All of it lives in src/index.css as semantic tokens — surface-card, heading, edge,
accent, series-1..8. Components reference the token names, never a color value, so the whole
app re-skins from that one file. npm run check:tokens fails the build on a raw hex literal or a
default-Tailwind palette class (bg-amber-500 and friends), which is how the previous palette
drift went unnoticed.
Utilities worth knowing: kicker (uppercase eyebrow), num (tabular figures), page (full-width
shell), panel.
- All model access goes through
netlify/functions/lib/claude.ts. Model choice, retries, the prompt-cache layout and JSON output live there, so changing models is a one-line edit. - Generated quotes are verified.
promptData.tschecks every quoted span against the source transcript and regenerates once if a quote cannot be found. Attributing an invented sentence to a named executive is the worst failure this tool could have. - Sourced companies carry provenance. Every candidate records where it came from
(
fmp_screener,crunchbase,apify,static_pool,model_generated) and the UI shows it. Model-generated names are suggestions, not verified companies. - The worked example is generated, not hand-written.
scripts/build-demo-session.mjsruns the real pipeline against a deployed URL and fits Bradley–Terry between rounds, so each round's options come from the previous round's actual winners. Rebuild it after any prompt change that would alter the output.
Issues and pull requests are welcome — see CONTRIBUTING.md for the working agreements, and SECURITY.md if you have found something that should not be reported in public.
It does not value anything, check whether a target is for sale, or replace diligence. The output is a prioritized agenda for the next conversation. Portions are generated by a language model: quotes are verified against their source transcript and the headline metrics are computed in code, but interpretation is inference and any company name labelled Unverified came from the model rather than a data source.
MIT — see LICENSE.