account qualificationsignal attribution engine
account qualification · signal attribution engine

one honest icp verdict
for every company.

raw company lists and linkedin engagement go in. one shared company table, one dev-signal score, one ai verdict, and a safety layer that refuses to guess. out comes a funnel, buckets and exports.

the icp in one line: a company whose product or service is mainly used by engineers — the people who build, ship, run, observe or secure software. using software for a business job (marketing, hr, finance, sales) does not count.
read the architecture

architecture only — no dashboards, no screenshots, no vanity metrics.

00The big picture

Data comes in from two places, lands in one shared company table, goes through the same classifier, and comes out as a funnel, buckets and exports.

CSV uploadsApollo, Reo DB, Crunchbase, raw domain lists
LinkedIn postsLikers + commenters via PhantomBuster
Profile list CSVPeople → their company website
The engine
  1. Clean & match the domain
    one company = one row, no duplicates
  2. Enrich
    employees, funding, revenue from each source
  3. Read the website
    Jina reader, then direct fetch
  4. Score dev signals
    V5 Dev Score: CLI, GitHub, SDK, docs…
  5. AI decides
    DevTool / IT Services / Not Relevant
  6. Safety rules
    contradictions → Review, policy list → No
  7. Funnel + buckets
    5 gates, 8 GTM segments
Funnel dashboardstep counts, discard reasons, filters
Excel + Google Drive7-tab workbook
Post-wise ICP reportwho engaged, and are they ICP

Tech stack

Next.js app on Vercel Supabase (PostgreSQL) OpenAI GPT on Azure Jina Reader (website text) PhantomBuster (LinkedIn) Google Drive API Google sign-in

01The two user flows

Both flows end in the same classifier and the same company table, so a company classified in one flow is already known in the other.

FLOW A Company list → Sales funnel

  1. Create a funnelA funnel is one campaign you keep enriching over time.
  2. Upload a CSVApollo, Reo DB, Crunchbase, or a plain list of domains.
  3. App detects the sourceFrom the header row. You confirm or change it.
  4. Pick the "match on" column + check mappingThe domain column is only used to find rows, never written.
  5. Companies are matchedExact match → data added to that company. Unsure → Duplicates tab.
  6. Click "Run classification"Website read → signals → AI → safety rules.
  7. Funnel updatesStep counts and discard reasons are recomputed.
  8. ExportExcel download, Google Drive, or CSV.

FLOW B LinkedIn engagement → ICP report

  1. Upload the post listLinkedIn URL, post name, author, date.
  2. Launch the Likers + Commenters PhantomsThe estimated cost is shown first, and you confirm.
  3. People who engaged are savedOne row per person, one link per person + post.
  4. Profile Scraper finds their companyCompany name + website for each person.
  5. Name-only employers resolvedMatched against companies we already have. A domain is never guessed.
  6. "Find Companies"Domains go into the campaign's own funnel.
  7. "Run ICP Pipeline"The same classifier as Flow A.
  8. Verdict copied to each personPost-wise report: how many ICP people engaged.

02Scraping

We scrape two things: company websites (to understand what a company does) and LinkedIn engagement (to know who interacted with our posts).

2a · Reading a company website

INPUT
Domain
e.g. example.com
TRY 1
Jina Reader
Opens the page in a real browser, so JavaScript sites work. 25s limit.
TRY 2
Direct fetch
Plain download, 10s limit. Must have 200+ real words or it counts as an empty shell.
LABEL
Scrape quality
success blocked failed domain_dead
Why two tries? Most modern sites are JavaScript apps. A plain download returns an empty page, which would look like "no developer signals" and push good companies to No. The scrape quality label is passed to the AI, so it knows when the website evidence is missing.

2b · Scraping LinkedIn with PhantomBuster

PhantomWhat it gives us
LinkedIn Post Likers ExportEveryone who reacted to a post
LinkedIn Post Commenters ExportEveryone who commented, plus the comment
LinkedIn Profile ScraperA person's current company and its website

Spend & safety rules

  • Reading results is free; only launching costs. Every launch shows an estimate and needs a click to confirm.
  • Rolling 24-hour limits: 50 posts per run, 200 a day; 500 profiles per run, 1,500 a day (PhantomBuster's safe limit for one LinkedIn account).
  • A profile is tried at most 2 times. After that it almost certainly has no website on LinkedIn.
  • The LinkedIn cookie comes only from the server's settings. The app never falls back to whichever colleague last saved a cookie in PhantomBuster.

Integrity guards

  • Full scrape on every launch: dedup is off and each run gets a fresh file name, so returning engagers are not silently skipped.
  • Saved results are reused: people already scraped are read from PhantomBuster's saved output instead of being paid for again.
  • Truth check: the Phantom log is compared to what was stored, so a gap is visible.
  • Zero-row requeue: a run that returns nothing puts people back in the queue instead of marking them "no result".
Weekly refresh: each post is re-scraped for 4 weeks after it is published. Only new engagement is added (one row per person + post), and at most 400 companies are classified per weekly run, so AI spend has a ceiling.

In code: src/lib/pipeline/scraper.ts · src/lib/phantombuster.ts · src/lib/engagement-launch.ts · src/lib/phantom-limits.ts · src/lib/weekly-pipeline.ts

03Scoring: signals and the V5 Dev Score

Before the AI sees anything, the website text is scanned for proof that the product is built for developers. This gives a number (the V5 Dev Score) and a list of evidence. The score is evidence for the AI, not the verdict itself.

Positive signals adds points

CLI
+10
Code blocks
+10
GitHub repo
+9
Open source
+8
Package registry
+8
Code samples
+8
Dev community
+7
Self-hosted
+7
DevRel
+7
Dev docs
+6
GitHub org / Dev hub
+5
Playground / StackOverflow / API keys
+4
SDK / API docs
+3
API, webhooks, changelog, status page, versioning
+2
GitHub Actions
+1

Negative signals removes points

Non-dev keywords
−15
Sales/marketing metrics
−15
Non-dev buyer persona
−10
Fake API facade
−10
  • Non-dev keywords: ERP, HRIS, payroll, CRM, marketing automation.
  • Sales metrics: "increase sales", "reduce churn", "close deals".
  • Non-dev persona: the page talks to VP Sales, CMO, CFO, HR, fleet managers.
  • Fake API facade: "API" said more than 15 times, but no SDK, CLI, docs, GitHub or community. Usually a normal SaaS product with an API, not a devtool.
Infra shield: infrastructure / "for developers" companies often mention business words too. When those signals are present, the persona and keyword penalties are removed.

GTM motion (stored for reference, does not change the score)

Self-serve signals
"Start for free", npm/pip install, API key, sign up, sandbox, quickstart
Sales signals
"Book a demo", "Contact sales", custom pricing, waitlist
Result
PLG or PLG + Sales Assist → Bottom-Up
Sales-Led → Top-Down

In code: src/lib/pipeline/signal-extractor.ts · src/lib/pipeline/dev-signal-scanner.ts

04The AI decision

The AI gets everything collected so far and answers three questions in a fixed order.

What the AI is given

  • Domain + scrape quality (how far to trust the website)
  • Company name + description from our uploads
  • First 3,000 characters of the website text
  • Keyword signals, GitHub repos, docs links
  • V5 Dev Score + warnings (non-dev persona, fake API…)

Temperature 0.1 (very consistent) · answer must be JSON · 10 companies at a time

What it returns

  • Classification: DevTool / IT Services & Solutions / Not Relevant
  • ICP: Yes / No / Review, plus confidence High / Medium / Low
  • Category (one of 36, e.g. "Observability & APM") + sub-category
  • Company type: Commercially OSS / OSS Affiliated / Non-OSS
  • GTM motion, pricing, signup, nonprofit, and a 2–3 sentence reason

The 3-step decision

Step 1 · What does the company do?
Uses the website, plus the model's own knowledge if the site could not be read.
Don't know → Review
never No when unknown
↓ known
Step 2 · Who uses it every day?
Are they building, running or securing software, or just using software for a business job?
Not engineers → No · Not Relevant
↓ engineers
Step 3 · Product or services?
Pricing / signup / docs = product. Only consulting/outsourcing = services. Both = product wins.
Product → Yes · DevTool
Services → Yes · IT Services

The tests it applies

Strip test

Remove the tech. If the user's job existed before software (fleets, finance, patients), it's not engineering.

API ≠ DevTool

Every SaaS has an API. Remove it: do engineers still use the product? a payments API yes, a marketing suite no.

Industry software

Logistics, ERP, healthcare, legal tech are Not Relevant, unless it's an API for developers.

Digital only

Software, data, cloud, security. Not mechanical, hardware, chips, CAD or robots.

Always Not Relevant (anti-ICP)

Sales / marketing / HR / finance / legal tools RevOps & GTM platforms Compliance & GRC BI for business analysts IT staffing & recruitment eCommerce / POS / consumer apps ITSM helpdesk Logistics & fleet Telecom operators ERP & procurement Healthcare & pharma Hardware & robotics Dev bootcamps & education Basic web agencies AI agents for business teams Personal productivity apps
Reliability: work runs in 30-second slices, so it fits serverless limits and Stop works immediately. If OpenAI says "too busy", the app waits the time OpenAI asks for and retries up to 3 times. If a key runs out of credit, the next backup key is used. If a company still fails, it is flagged for review and never retried forever.

In code: src/lib/pipeline/classifier.ts · src/lib/pipeline/runner.ts

05Safety rules after the AI

The AI's answer is never used as-is. It goes through these checks in order. The last one, the policy list, always wins.

The golden rule: a wrong No silently deletes a real prospect. A Review costs a person 30 seconds. So when the evidence is weak, the answer is Review, not No.
Hard rules
The classification decides the ICP answer: DevTool and IT Services are always Yes, Not Relevant is always No.
Yes / No
Net 1 · Contradiction
AI said "DevTool" but also said ICP = No. It disagrees with itself.
Review
Net 2 · Odd answer
Low confidence + No + a classification outside the 3 allowed values.
Review
Net 3 · Bad scrape
Website unreadable + Low confidence + No → not enough proof to reject. High confidence is lowered to Medium.
Review
Net 4 · Memory only
Website unreadable and "IT Services" came only from the model's memory, without High confidence.
Review
Net 5 · Policy list
A fixed list of companies that are never ICP, whatever the AI says. More can be added through a setting, without a deploy.
No (final)

The policy "never ICP" list

Asking the model nicely is not enforcement. A fixed, admin-editable list has the final say: it overrides the AI, applies backwards to already-classified rows, and can be changed without a deploy. The list is data, not prompt text.

The order of authority: the model suggests, the safety rules decide, and the policy list has the last word.

ICP fit level

Fit levelWhen
HighDevTool, high confidence
MediumDevTool with some doubt, or IT Services (a secondary ICP, capped at Medium)
LowLow confidence
ReviewCouldn't tell. A person checks it.
Not a FitConfirmed Not Relevant

Also: the AI may only fill an empty company name. It can never overwrite a name from an upload, and junk like "Review", "Unknown" or "N/A" is never saved as a name.

In code: src/lib/pipeline/output-parser.ts · src/lib/icp-overrides.ts · src/lib/db/icp-policy.ts

06The funnel: 5 gates

Every company in a funnel either passes all 5 gates (qualified) or is dropped at the first gate it fails, with a reason. The same definition drives the step counts and the table filter, so a number you click always matches the rows you see.

1 · All companieseverything uploaded into the funnel
dead_domainwebsite doesn't exist
2 · Enrichedhas data from Apollo, Crunchbase or Reo
not_enrichedno source knows it
3 · Has employeesReo > 0, or Apollo / Crunchbase > 1
low_employees1-person or empty
4 · ICP = Yesfrom the classifier
not_icpNo or Review
5 · Funded or earningfunding or revenue > $100k
low_fundingtoo small to buy
Funding

The higher of Apollo and Crunchbase.

Revenue

The higher of Apollo and Reo.

Manual override

A person marking a company ICP = Yes clears its discard reason.

Discard reasons are recomputed at the end of every classification run, so the Discarded view never shows stale upload-time state.

In code: FUNNEL_STEP_GATES and computeDiscardReasons in src/lib/db/companies.ts

07GTM buckets

Once a company is classified and enriched, it's placed in one go-to-market segment. Size comes from employees, and smaller companies are split by sales team size and money raised.

Not a DevTool, no developer link
Irrelevant
IT Services, or an API/SDK company
Relevant later, not a direct buyer today
Future ICP
DevTool · 500+ employees
Enterprise
DevTool · 200–499 employees
Commercial
DevTool · under 200 employees · 2+ in sales
SMB
DevTool · 1 in sales, OR 0 in sales but funding > $5M or revenue > $3M
Startup
DevTool · 0 in sales, small funding and revenue
Immature
Not enough data
If sales team size is unknown: 50+ employees → SMB, under 50 but funded → Startup, otherwise here
Unclassified

Qualified buckets: Enterprise, Commercial, SMB, Startup. The same rules power the categorization board, the Excel export and the Raw Classifier tool.

In code: src/lib/bucketing.ts

08Data enrichment & column mapping

Several sources describe the same company. The rule that keeps them from overwriting each other: every data column has exactly one owner source.

1
Upload CSV
Any of the supported sources
2
Detect source
From the header row. User confirms.
3
Match column
Domain column = lookup key only (like an XLOOKUP)
4
Map columns
Only fields this source may write are offered
5
Write + log
Each change saved with old → new value
6
Undo anytime
Roll back one upload

Who owns which column

SourceColumns only it can write
ApolloApollo employees, total funding, latest funding (round, amount, date), annual revenue, SIC/NAICS codes, LinkedIn URL, sales team count, "is in Apollo"
Reo DBReo employees, Reo revenue
CrunchbaseCrunchbase funding, funding type, Crunchbase employees
ICP outputICP decision, classification, category, fit level, confidence, reason, manual ICP…
Shared (any source)Company name, country, founded year, website, description, "subsidiary of": filled only if empty, never overwritten

Why ownership?

A Reo DB upload was overwriting Apollo's revenue, founded year and country. With ownership, a write from the wrong source is dropped and counted ("skipped fields"). Manual mapping can't get around it either, because the dropdown only offers allowed fields.

How rollback stays safe

  • Deletes companies the upload created, unless they've been classified since.
  • Reverts a field only if it still holds the value that upload wrote. A newer upload's value is kept and reported.
  • Discard reasons are recomputed afterwards.

Company Enrichment (profile CSV → company domains)

Upload profile CSV
Profile URL column found by looking at the data, not the header name
Dedupe + cache check
Costs nothing. Already-known people are filled from cache.
Launch Profile Scraper
Shares the same 1,500/day limit as campaigns
Export domains
Deduped file, ready for the ICP check

In code: src/lib/source-policy.ts · src/lib/csv-detect.ts · src/lib/csv-parser.ts · src/lib/db/uploads.ts · src/lib/profile-list-parser.ts

09Domain matching

The domain is the company's identity. One wrong match puts one company's data on another and breaks the dashboard, so matching is strict.

Step 1 · Clean the domain

Raw inputBecomesRule
http://www.Example.com/enexample.comRemove protocol, www, path; lowercase
docs.example.comexample.comRemove known subdomains (docs, blog…)
www.team.github.ioteam.github.ioHosting platforms: the tenant is the company
dept.uni.edu.cnuni.edu.cnCountry domains with two-part endings

Step 2 · The 6-level matching ladder

P1
Exact domain
example.com = example.com
auto-enrich
P2
Known alias
a domain already linked to this company
auto-enrich
P3
Same root name
acme.io ↔ acme.ai
human review
P4
Core root
getexample.com ↔ example.com (strips try/get/use…)
human review
P5
LinkedIn page
same LinkedIn company slug, exact match
human review
P6
Company name
"Acme, Inc" ↔ "Acme" (junk names ignored)
human review
The rule: only P1 and P2 update an existing company automatically. Anything less certain is saved as a separate company and sent to the Duplicates tab, where a person approves or declines it. Nothing uncertain is merged automatically. Acquired companies stay separate and are still sellable.

Domain edge cases handled

Junk company names

edge case

A source can put placeholders like "Unknown" or "N/A" in the name column.

handling

Junk names are skipped everywhere, so they never become identity keys.

Hosting sites

edge case

On a multi-tenant host, every tenant shares the same root.

handling

The tenant name is the identity, not the host root.

Country domains

edge case

Two-part endings collapse to a generic root if handled naively.

handling

Endings like edu.cn, org.tw and com.hk are treated as one ending.

www copies

edge case

An export can include a www. host that no longer matches the original row.

handling

www. is stripped before matching, even on hosting domains.

LinkedIn substring match

edge case

A short slug can match inside a longer one, and an empty slug can match everything.

handling

Exact LinkedIn slug only; empty slugs are ignored.

Giant match groups

edge case

Generic keys shared by many companies create fake duplicate groups.

handling

A key shared by too many companies is treated as generic, not identity.

Domains on the LinkedIn side

Never invent a domain

A company name is not a domain. If the employer has no website on LinkedIn, the person stays honestly unresolved. If the name itself is a domain, it is used as one. No fallback invents a URL.

Resolve name-only employers from our own data

Tries in order: LinkedIn company slug → exact name → cleaned name ("Acme Inc." ≈ "Acme"). Each step needs exactly one match. A name shared by two companies is left unresolved rather than guessed. No network, no AI, no cost.

In code: src/lib/domain-utils.ts · findCompanyByDomainSmart in src/lib/db/merges.ts · src/lib/db/company-resolve.ts · scripts/fix-duplicate-roots.mjs

10LinkedIn ICP labelling

Classification happens at the company level. The LinkedIn report counts people, so every verdict has to be copied onto each person who works there.

Company verdict
e.g. example.com = Yes · DevTool
Copy to every person
Runs on the server at the end of each classification run (and on Stop)
Social rule
IT Services counts as No for engagement
Post-wise report
ICP people per post
Why IT Services counts on one side only

Large IT-services firms are a real ICP for the sales funnel. On LinkedIn, though, that engagement is mostly agencies and outsourcing shops liking devtool posts for reach, and counting them would inflate every post's ICP number. So the funnel keeps them, and the social report doesn't count them.

Why the copy moved to the server

The copy runs on the server at the end of each classification run (and on Stop), not in the browser. Closing a tab cannot leave a campaign classified but with every person unlabelled.

In code: syncCampaignIcpToProfiles in src/lib/db/comments.ts · countsAsIcpForSocial in src/lib/icp-overrides.ts

11Outputs

Funnel dashboard

Step counts, discard reasons, Duplicates tab, upload history with rollback. Filter counts always match the table.

Excel workbook (7 tabs)

Main View · ICP Results · Apollo · Reo DB · Crunchbase · Discarded · Funnel Summary. Built from live data, timestamped per export.

Google Drive sync

The same workbook, uploaded into one shared folder with a single click.

Campaign export

Per post: who engaged, their company, its classification and their ICP status.

Domains file

From Company Enrichment: a deduped list of domains ready for an ICP check.

Review queue

Everything marked Review, each with the AI's reason, for a 30-second human decision.

★The principles behind every rule

1 · A false No is the worst mistake.

When in doubt, the answer is Review, never No.

2 · Never guess identity.

No invented domains, no automatic merges on a weak match, no guessing between two companies with the same name.

3 · Policy lives in code, not in the prompt.

The AI suggests, the rules decide, and the never-ICP list has the final say.

4 · Every source stays in its lane.

Each column has one owner, every write is logged, and every upload can be undone.

5 · Spend is always visible.

Cost estimates before every launch, daily limits on LinkedIn usage, a cap on weekly AI classifications.

6 · Gaps must be visible.

"Seen vs stored" counts, unresolved people reported, failures flagged instead of silently dropped.