Logos and names belong to their owners. Public data from a live run on 2026-10-13. No affiliation.
Security notice. Versions 0.1.2 and older had no admin sign-in. Anyone who could reach a deployed instance could start paid runs with a spend cap of their own choosing, rewrite the ICP file, and read the stored enrichment. If you run 0.1.2 or older on a reachable host, upgrade to 0.1.3 and set
LOOOT_SCORE_ADMIN_PASSWORD. See Securing a deployment.
An open-source alternative to MadKudu for lead and ICP fit scoring. MadKudu's own pricing page redirected to HG Insights and could not be read (checked 2026-10-10), so no price is stated here as fact. Third-party listings give an estimate of about $999 a month for the entry plan; treat it as unconfirmed. looot-score enriches a CSV of leads through looot, scores each one against an ICP you edit, and returns a grade A to D with the reasons.
![]() |
![]() |
![]() |
![]() |
![]() |
|
The banner, the cards and the walkthrough are the real app on the data of a live paid run on 2026-10-13 (see Live run). They say "Live run · 2026-10-13". Nothing on them is demo data.
The same walkthrough as MP4: docs/media/demo.mp4.
examples/looot-buyers-icp.json describes who buys looot: GTM teams and AI-agent builders at B2B software and data companies. examples/looot-buyers.csv is 12 company rows with a domain only. Eight are likely buyers (Clay, Apify, HubSpot, Attio, lemlist, n8n, Linear, Notion). Four should not fit (Panera Bread, Greggs, Duke Energy, Mayo Clinic). The ICP gives 30 points to company size, 40 to industry and 30 to tech stack, and none to title or seniority, because there is no person on a company row.
One live run on 2026-10-13 (UTC), in two steps. Clay first as a 1-row check of the parsers, then the other 11.
| Calls | 24 (12 company, 12 tech stack), all completed, none failed or pending |
| Charged | $0.1428 (12 x $0.0019 + 12 x $0.0100), the same as the quote |
| Grades | A 4, B 0, C 2, D 6 |
| Spend caps | $0.05 for the Clay check, $0.20 for the second run |
Run it yourself:
npm run score -- examples/looot-buyers.csv --icp examples/looot-buyers-icp.json --dry-run
LOOOT_TOKEN=... npm run score -- examples/looot-buyers.csv --icp examples/looot-buyers-icp.json --max-spend 0.2What the run showed, good and bad:
- The ranking is mostly sensible. n8n scored 100, and Apify, Attio and Linear scored 85. HubSpot (7,220 employees) lands at D because it is far above the size range. The four non-buyers all graded C or D, and Duke Energy and Mayo Clinic score 0.
- The tech stack call is the useful one. It found Salesforce, Snowflake, Stripe, Slack and HubSpot on the companies that really use them.
- The industry field of the company provider (thecompaniesapi) is the weak spot. It labelled HubSpot "renewable-energy-power-generation", Clay "maritime", Notion "public-policy" and lemlist "b2b-sales-prospecting". Those cost points they should not have lost. The grades for HubSpot, Clay, Notion and lemlist are therefore too low. Check a grade's reasons before you trust it.
- Some headcounts are also wrong or only a range. Linear and Clay came back as "1-10", read as 6 employees. Panera Bread came back as 10.
- Fixed while testing: the provider sends the range ("1-10") next to an exact number (7,220 for HubSpot), and the parser read the range. It now reads the exact number first. Applying the fix to the stored answers cost nothing, because a stored result is replayed.
- The screens show the saved run after that re-score, so the lead table says $0.00 spent: the re-score made no call. The charge is in the budget panel ("This month" $0.14) and in the CLI output above.
Upload a CSV with an email column, or name columns and a domain column, or a domain column alone for company rows (no person call, so no title or seniority). For each lead it fetches the person (title, seniority) and the company (industry, headcount, country, tech stack) from named providers, then scores six criteria from the ICP and grades the lead.
- Every grade lists the criteria behind it, the points each earned, the fact found and the provider that supplied it.
- A fact no provider returned earns nothing and is marked unknown. Nothing is guessed.
- Enrichment is stored. Editing the ICP re-scores every stored lead at no cost and with no looot call.
- The quote comes first. A per-run spend cap is enforced before each call. A company or tech stack is fetched once per domain, whatever the number of leads. A call still running when waiting stops is
pending, never "failed at $0". - Hard rules cap the grade at D: a disqualified industry or title, or a personal mailbox with no company domain.
Three ways to run it: a demo with sample leads, a headless CLI, and a web UI on the same engine.
| Page | Light | Dark |
|---|---|---|
| Upload, quote, grades, lead table with reasons | ![]() |
![]() |
| ICP editor | ![]() |
![]() |
- Node.js 20.9 or later to run the app and the CLI (Next.js 16 requires it). The tests need Node 22.12 or later (vitest 5). The repo has no
enginesfield or.nvmrc; I ran everything on Node 24.18. - Git and npm.
- For real runs only, a looot account and token. Sign up at https://looot.ai/auth/sign-up. Then either run
npm i -g loootandlooot login, which opens a browser approval, or open the looot web app, go to Settings, "Agent tokens" and create one (sign-in docs). With no browser (CI), create the token in Settings > Agent tokens andexport LOOOT_TOKEN=.... - The token needs the scopes
runs.execute,catalog.read,runs.readandusage.read. - A funded balance. The minimum top-up is $5 and signing up adds no credit (money docs).
No other account is used. There is no Supabase, no database and no LLM key.
git clone https://github.com/loootai/looot-score && cd looot-score
npm install --ignore-scripts
npm run demoOpen http://localhost:3272. The demo has no sign-in and shows a "Demo, no sign-in" badge. It refuses to start if LOOOT_TOKEN is set or a state file exists, unless you also set an admin password. The demo scores 40 generated leads on the reserved .example domain, needs no token, makes no network call and costs nothing. Stop it with Ctrl+C.
Prefer the terminal? npm run score -- --demo prints the grade counts and writes the reports to ./out.
The web UI reads .env.local, the CLI does not. For the CLI, export the variables in your shell.
cp .env.example .env.local # web UI only| Variable | Needed | What it does | Where to get it |
|---|---|---|---|
LOOOT_TOKEN |
Required for real runs | Your looot key. Not needed for --demo or --dry-run. |
looot Settings > Agent tokens, or looot login |
LOOOT_API_URL |
Optional | Overrides the API base. Default https://api.looot.ai. |
Leave unset |
LOOOT_SCORE_ADMIN_PASSWORD |
Required outside the demo | Admin password, 12 characters or more. Without it the admin pages and API are closed and /login shows a setup page. |
Choose one |
LOOOT_SCORE_ADMIN_PASSWORD_HASH |
Alternative | scrypt$salt$key instead of the plain password. Wins if both are set. |
npm run hash-password -- 'your password' |
LOOOT_SCORE_SESSION_SECRET |
Optional | Key that signs session cookies. Default: derived from the password. | Leave unset, or a long random string |
LOOOT_SCORE_COOKIE_SECURE |
Optional | Set 0 to drop the Secure flag in production, only for plain-http testing. |
Leave unset |
LOOOT_SCORE_CAP_USD |
Optional | Spend cap per web run, in dollars. Default 5. The browser cannot change it. The CLI takes --max-spend instead. |
Your choice |
LOOOT_SCORE_ICP |
Optional | Path to the ICP file the web UI reads and writes. Default config/icp.json. The CLI takes --icp instead. |
Copy config/icp.json |
LOOOT_SCORE_DIR |
Optional | Folder for the saved enrichment (state.json). Default .looot-score in the working directory. |
Any writable folder |
LOOOT_SCORE_DEMO |
Optional | 1 runs the web UI on sample data. npm run demo sets it. |
Leave unset for real data |
The leads file is a CSV with an email column, or first_name, last_name and domain columns. data/sample-leads.csv is a 40-row example with .example addresses, so real calls on it would find nothing. Swap in your own leads.
Quote first. The CLI reads live prices from the public looot catalog, which needs no token. This is the real output of the dry run:
npm run score -- data/sample-leads.csv --dry-run40 leads.
Person and company by email quickenrich people.enrich.by-email $0.00483 x 38 = $0.18354
Person by name and domain dropleads people.enrich $0.0029 x 2 = $0.0058
Company firmographics thecompaniesapi company.enrich $0.0019 x 12 = $0.0228
Tech stack predictleads company.technographics $0.0100 x 12 = $0.1200
Prices: read from the public looot catalog
Quoted $0.33 at list price ($0.00830 per lead). Cap $5.00.
Dry run: no run was made and nothing was charged.
Then the real run:
export LOOOT_TOKEN=...
npm run score -- leads.csv --icp config/icp.json --max-spend 5Results go to ./out as score-<run>.json, .csv and .md (change the folder with --out, the formats with --format json,csv,md). The enrichment is also saved to .looot-score/state.json, which the web UI reads. Run npm run score -- --help for all flags. Exit codes are 0 done, 1 error, 2 refused (over the cap or no token).
The web UI on a live key: put LOOOT_TOKEN in .env.local and run npm run dev (http://localhost:3000). It has no sign-in. The ICP editor writes config/icp.json, which the CLI reads.
Four limits stand between a request and a charge, checked in this order: the quote, the cap per run, the period budget, then the call.
- The quote is printed first. A real run is refused (exit 2) when the quote is above
--max-spend. Pass--partialto run until the cap instead. --max-spendis in dollars. The default is 5. In the web UI the cap isLOOOT_SCORE_CAP_USDon the server, shown on the page and not editable there. AcapUsdin a request is ignored. A session can start 6 runs per 10 minutes.- The period budget caps all runs together: a daily and a monthly amount (UTC), $10 and $50 until you change them. The owner changes them on the Costs page of the web UI, or sets the starting values with
LOOOT_SCORE_DAILY_USDandLOOOT_SCORE_MONTHLY_USD. A request to/api/runcannot carry a budget: any budget field in it is ignored. The CLI refuses a run whose quote is above what is left, unless you pass--partial. - Before each paid call the app reserves its list price in
budget.json(inLOOOT_SCORE_DIR), under a lock file, and refuses the call if used plus reserved plus the price would pass the daily or the monthly budget. Two requests started together cannot both pass. When the call ends the reservation becomes the real charge. A failed call releases it. A call still running at looot (pending) keeps its reservation: it is never recorded as failed at $0 and the budget is never handed back early. Reserved money counts against the budget until the call ends. - The next run looks up every pending call with
GET /v1/runs/{id}and settles the ones that ended, to their real cost. If looot cannot be reached, or the app died before it got a run id, the reservation stays for 24 hours and is then released. - The sidebar and the Costs page show used, reserved and remaining for today and this month. At the budget the run is refused with a message that names the limit.
- Every call has a stable key made from the call, its normalised input and the freshness window, and a finished call is stored under it. Repeating the same request inside the window is answered from the stored result: no call, no reservation, no charge. If a stored result is missing, looot replays the same key without charging again.
- The looot token balance is the final limit. Fund a dedicated token with only what you are willing to spend, and keep the budget below it.
- At list price 100 leads cost about $0.83 to $1.67 and 1,000 leads $8 to $17, depending on how many leads share a company.
- A failed call is not charged. Run the same command again inside the freshness window (
freshnessWindowin the ICP file,monthin the default) and finished calls replay at no charge. - Editing the ICP and re-scoring stored leads in the web UI makes no looot call.
The budget lives in a file, so run one instance on a disk that persists. Several instances, or a serverless host with a throwaway disk, do not share it.
Versions 0.1.2 and older had no admin authentication. From 0.1.3 every route is either public on purpose or admin, and the admin routes are closed unless you set a password.
Pages and routes were listed from src/app and classified. A test reads the folder and fails if a route is missing from src/lib/auth/routes.ts or if an admin route does not call the gate.
| Kind | Count | Routes |
|---|---|---|
| Public | 4 | /login, /api/auth/login, /api/auth/logout, /api/health |
| Admin pages | 3 | / (upload, quote, run, scores), /icp, /costs (budget and cost) |
| Admin API | 4 | /api/run (paid), /api/quote, /api/icp (read and rewrite the ICP file), /api/budget (read, and change the period budget) |
Any path not listed is treated as admin.
- Set
LOOOT_SCORE_ADMIN_PASSWORD(12 characters or more) orLOOOT_SCORE_ADMIN_PASSWORD_HASH(fromnpm run hash-password -- 'your password'). Without one, admin routes answer 503, admin pages go to/login, and/loginsays what to set. src/proxy.tschecks every request. Each admin page, the root layout and each admin route handler checks again with the same function, so a path the matcher misses is still closed.- Sign-in compares the password in constant time. A session is a signed token in an httpOnly, SameSite=Lax cookie, Secure in production, valid for 8 hours. 5 wrong passwords from one address lock that address for 15 minutes. 40 wrong passwords from anywhere lock sign-in for everyone for 15 minutes.
- Every state-changing admin request needs a same-origin
Originheader and the per-session token inx-score-csrf. The layout puts the token in a meta tag and the app's own calls send it. - A run (
/api/run) needs the session and the CSRF token. The body carries only the CSV. The cap isLOOOT_SCORE_CAP_USDon the server (default 5), the run is refused when the quote is above it, and a session can start 6 runs per 10 minutes. - Writing the ICP (
PUT /api/icp) needs the session and the CSRF token. - The period budget (
PUT /api/budget) is changed only by the signed-in owner, with the CSRF token. A run cannot raise it. See Spending. The looot token balance is the final limit, so give the app a token funded with only what you are willing to spend. - Admin responses carry
Cache-Control: no-store. All responses carry a Content-Security-Policy (frame-ancestors 'none'),X-Frame-Options: DENY,X-Content-Type-Options: nosniffandReferrer-Policy: no-referrer. The CSP allows inline scripts because Next.js needs them. - The demo (
npm run demo) has no sign-in and shows a "Demo, no sign-in" badge. It refuses to start next to aLOOOT_TOKENor a data file unless an admin password is set.
export LOOOT_SCORE_ADMIN_PASSWORD='a password of 12 or more characters'
# or keep it out of the environment as plain text:
npm run hash-password -- 'a password of 12 or more characters' # prints LOOOT_SCORE_ADMIN_PASSWORD_HASH=...
npm run build && npm startA shared password is the floor. For anything people other than you can reach, put the app behind a VPN, a private network or an identity-aware proxy (Cloudflare Access, Tailscale, Google IAP, oauth2-proxy), and leave only the public routes in the table above reachable from the internet. Keep the app's password on as well. The rate limits are in memory, so behind several instances also limit at the proxy.
- The web app asks for the password. Set
LOOOT_SCORE_ADMIN_PASSWORDbefore you start 0.1.3, or the admin pages stay closed. - The spend cap box on the page is gone. Set
LOOOT_SCORE_CAP_USDon the server. AcapUsdin a request to/api/runis ignored. PUT /api/icpneeds the session and the CSRF token, like every other state-changing admin request.
One admin, no per-user accounts or roles. The CLI has no sign-in: it runs as you on your machine and reads the same files.
I have not deployed this to Vercel. The steps below follow the code.
- Push a copy of the repo to your own GitHub account.
- In Vercel choose Add New > Project, import the repo. Next.js is detected, there is no
vercel.json. - Add the environment variables
LOOOT_TOKENandLOOOT_SCORE_ADMIN_PASSWORD(orLOOOT_SCORE_ADMIN_PASSWORD_HASH). AddLOOOT_SCORE_DIRandLOOOT_SCORE_ICPtoo (see below). - Deploy.
Two things to know first.
- Without
LOOOT_SCORE_ADMIN_PASSWORDthe app serves no admin page. With it, every page and API route needs the sign-in. Vercel Deployment Protection in front is still a good second layer. See Securing a deployment. - Live mode writes files. The enrichment goes to
.looot-score/state.jsonafter the calls are paid, and the ICP editor writesconfig/icp.json. A serverless function may not be able to write either. PointLOOOT_SCORE_DIRandLOOOT_SCORE_ICPat writable paths. On Vercel that is/tmp, which does not persist between invocations. For history across runs use the CLI, or a host with a persistent disk.
There is no scheduled workflow in this repo.
config/icp.json, validated on load, edited in the UI or by hand. A bad file stops the run with Invalid <file>: <field>: <problem>.
| Criterion | Default points | What earns points |
|---|---|---|
| Company size | 25 | Headcount in the ideal range, full. In the acceptable range, half. |
| Industry | 20 | A target industry word matches, whole word, any case. |
| Country | 10 | Country code or name is on the list. |
| Job title | 15 | A target title word matches. |
| Seniority | 20 | Share per level (c-suite, vp, head, director, manager, senior, entry), 0 to 1. |
| Tech stack | 10 | Enough target technologies detected. A listed exclusion scores zero. |
Score = points earned over the points of the criteria in use, times 100. A criterion with empty lists is not in use and drops out of both sides. Grade cut-offs default to A from 75, B from 55, C from 35, D below. If both tech lists are empty the tech call is not made and not paid for.
Job ids and prices are from the public looot catalog, read 2026-10-10, per call in USD.
| Fact | Provider | looot job | Endpoint | Price |
|---|---|---|---|---|
| Title, seniority, company, by email | quickenrich | people.enrich.by-email |
quickenrich-api-employees-email-search |
$0.00483 |
| Title, seniority, by name and domain | dropleads | people.enrich |
dropleads-simple-enrich |
$0.0029 |
| Industry, headcount, country | thecompaniesapi | company.enrich |
thecompaniesapi-companies-by-email |
$0.0019 |
| Tech stack | PredictLeads | company.technographics |
predictleads-companies-technology-detections |
$0.0100 |
The company call is sent info@<domain> because the endpoint looks a company up from an email's domain. The tech fetcher is copied from looot-intent, with no dependency between the repos.
One person call per lead, one company call and one tech call per domain. With three leads per company, a lead costs about $0.008. With one lead per company, $0.0167. So 100 leads is about $0.83 to $1.67 at list price, and 1,000 leads $8 to $17.
- No behavioural or engagement scoring, no predictive model trained on your closed-won deals, no CRM sync. MadKudu has these. This tool grades fit only, from rules you can read.
- MadKudu grades run A to E. This one runs A to D, as asked.
- Headcount ranges such as "51-200" are scored at their middle value.
- Demo leads use the reserved
.exampledomain. The data is generated, not real.
Messages are quoted from the code. The CLI prints them as shown. Messages that begin "Stopped:" carry the text looot returned.
| You see | What it means | What to do |
|---|---|---|
LOOOT_TOKEN is not set. Create a key at https://looot.ai, or try --demo or --dry-run. |
No token in the environment. Exit code 2. | export LOOOT_TOKEN=... in the same shell. The CLI does not read .env.local. |
LOOOT_TOKEN is not set on the server. Set it, or start the demo with npm run demo. (HTTP 503) |
The web UI has no token. | Put it in .env.local and restart npm run dev, or add it in Vercel. |
Stopped: <message from looot> after a 401 or code unauthorized, invalid_token, missing_token |
looot rejected the token. The run stops at the first one. | Create a new token in Settings > Agent tokens with the four scopes listed above. |
Stopped: <message from looot> after a 402 or code insufficient_funds, payment_required |
The balance is too low. | Top up (minimum $5) at https://docs.looot.ai/money, then run again. Finished calls replay free. |
Run <id> is blocked. or status failed with the error code looot gave |
A call was blocked or failed. It is counted in "calls failed" and not charged. | Read the code in the report, then retry. |
Run <id> is queued. / running / pending_provider, shown as pending |
The provider had not finished when waiting stopped (60 s). Not a failure. | Run the same command again in the same window. |
| A fact marked unknown, or a call that ends as a parse error | The response did not match what the parser expects, or no provider returned the fact. | See "How to file an issue". |
Live price unreadable for <endpoint>. Those calls will be skipped. |
The public catalog has no readable call price for that endpoint. | Use --offline for the dated prices, or empty the tech lists in the ICP to drop the tech call. |
Refused: the quote is above the cap. Raise --max-spend, score fewer leads, or pass --partial to run until the cap. |
The quote is above --max-spend. Exit code 2. |
Do what it says. |
The CSV needs an email column, a domain column (with name columns for people), or a domain column alone for company rows. Found: ... |
The header row has none of the expected columns. | Rename the columns, see the leads file format above. |
Skipped N rows: line 3: needs an email, or a first and last name and a domain, or a domain alone |
Rows with no usable identity are left out. | Fix those rows. The rest still runs. |
Unknown option --x. Run with --help. |
A flag typo. The CLI stops so a typo never runs with defaults. | Fix the flag. |
How to file an issue. Open one at https://github.com/loootai/looot-score/issues with the run id (printed in the report and shown in the web UI), the command you used, and the raw response body with secrets removed.
One live paid run was made on 2026-10-13: 24 calls, $0.1428, company rows only (see Live run). The company and tech stack parsers read real response bodies and one bug came out of it (fixed). The two person calls (people.enrich.by-email and people.enrich) have not been run against a real response, so shapeVerified stays false for them. Run --dry-run and a 5-lead run first.
If a parser fails on a real response, open an issue with the raw body (see Troubleshooting).
git clone https://github.com/loootai/looot-score && cd looot-score && npm install --ignore-scriptsClaude Code: copy skills/looot-score to ~/.claude/skills/looot-score. The skill runs the CLI with --dry-run first, then the real run with a cap. Other agents: read llms.txt.
npm test && npm run typecheck && npm run lint && npm run build
npm run leak-scan # also runs from .githooks/pre-commit: git config core.hooksPath .githooksLayout: src/engine (client, lead parsing, providers, scoring, run loop, reports, no framework imports), src/cli, src/server and src/app (Next.js App Router), config/icp.json, data/sample-leads.csv.
MIT licence, copyright looot.
Other open-source projects built on looot:
- looot-intent: account intent and buying-stage scoring (6sense)
- looot-reveal: which companies visit your site (RB2B, Clearbit Reveal)
- looot-techstack: website technologies from several providers (BuiltWith, Wappalyzer)
- looot-signals: hiring, funding and news triggers (TheirStack, Harmonic)
- looot-lookalike: lookalike company search (Ocean.io)
- looot-ads: competitor ad board (Foreplay)
- looot-reviews: review themes and weak spots (ReviewTrackers)
- looot-battlecards: competitor battlecards (Klue, Crayon)
- looot-mentions: brand and keyword listening (Brand24)
- looot-warm-leads: warm leads from LinkedIn intent signals (Gojiberry)
- looot-crm: a small CRM with buying signals built in
- looot-seo: self-hosted SEO workspace
- looot-monitor: brand and competitor monitoring with alerts










