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.
- Clean & match the domain
one company = one row, no duplicates - Enrich
employees, funding, revenue from each source - Read the website
Jina reader, then direct fetch - Score dev signals
V5 Dev Score: CLI, GitHub, SDK, docs… - AI decides
DevTool / IT Services / Not Relevant - Safety rules
contradictions → Review, policy list → No - Funnel + buckets
5 gates, 8 GTM segments
Tech stack
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
- Create a funnelA funnel is one campaign you keep enriching over time.
- Upload a CSVApollo, Reo DB, Crunchbase, or a plain list of domains.
- App detects the sourceFrom the header row. You confirm or change it.
- Pick the "match on" column + check mappingThe domain column is only used to find rows, never written.
- Companies are matchedExact match → data added to that company. Unsure → Duplicates tab.
- Click "Run classification"Website read → signals → AI → safety rules.
- Funnel updatesStep counts and discard reasons are recomputed.
- ExportExcel download, Google Drive, or CSV.
FLOW B LinkedIn engagement → ICP report
- Upload the post listLinkedIn URL, post name, author, date.
- Launch the Likers + Commenters PhantomsThe estimated cost is shown first, and you confirm.
- People who engaged are savedOne row per person, one link per person + post.
- Profile Scraper finds their companyCompany name + website for each person.
- Name-only employers resolvedMatched against companies we already have. A domain is never guessed.
- "Find Companies"Domains go into the campaign's own funnel.
- "Run ICP Pipeline"The same classifier as Flow A.
- 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
2b · Scraping LinkedIn with PhantomBuster
| Phantom | What it gives us |
|---|---|
| LinkedIn Post Likers Export | Everyone who reacted to a post |
| LinkedIn Post Commenters Export | Everyone who commented, plus the comment |
| LinkedIn Profile Scraper | A 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".
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
Negative signals removes points
- 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.
GTM motion (stored for reference, does not change the score)
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
never No when unknown
Services → Yes · IT Services
The tests it applies
Remove the tech. If the user's job existed before software (fleets, finance, patients), it's not engineering.
Every SaaS has an API. Remove it: do engineers still use the product? a payments API yes, a marketing suite no.
Logistics, ERP, healthcare, legal tech are Not Relevant, unless it's an API for developers.
Software, data, cloud, security. Not mechanical, hardware, chips, CAD or robots.
Always Not Relevant (anti-ICP)
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 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.
ICP fit level
| Fit level | When |
|---|---|
| High | DevTool, high confidence |
| Medium | DevTool with some doubt, or IT Services (a secondary ICP, capped at Medium) |
| Low | Low confidence |
| Review | Couldn't tell. A person checks it. |
| Not a Fit | Confirmed 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.
The higher of Apollo and Crunchbase.
The higher of Apollo and Reo.
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.
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.
Who owns which column
| Source | Columns only it can write |
|---|---|
| Apollo | Apollo employees, total funding, latest funding (round, amount, date), annual revenue, SIC/NAICS codes, LinkedIn URL, sales team count, "is in Apollo" |
| Reo DB | Reo employees, Reo revenue |
| Crunchbase | Crunchbase funding, funding type, Crunchbase employees |
| ICP output | ICP 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)
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 input | Becomes | Rule |
|---|---|---|
http://www.Example.com/en | example.com | Remove protocol, www, path; lowercase |
docs.example.com | example.com | Remove known subdomains (docs, blog…) |
www.team.github.io | team.github.io | Hosting platforms: the tenant is the company |
dept.uni.edu.cn | uni.edu.cn | Country domains with two-part endings |
Step 2 · The 6-level matching ladder
Domain edge cases handled
Junk company names
A source can put placeholders like "Unknown" or "N/A" in the name column.
Junk names are skipped everywhere, so they never become identity keys.
Hosting sites
On a multi-tenant host, every tenant shares the same root.
The tenant name is the identity, not the host root.
Country domains
Two-part endings collapse to a generic root if handled naively.
Endings like edu.cn, org.tw and com.hk are treated as one ending.
www copies
An export can include a www. host that no longer matches the original row.
www. is stripped before matching, even on hosting domains.
LinkedIn substring match
A short slug can match inside a longer one, and an empty slug can match everything.
Exact LinkedIn slug only; empty slugs are ignored.
Giant match groups
Generic keys shared by many companies create fake duplicate groups.
A key shared by too many companies is treated as generic, not identity.
Domains on the LinkedIn side
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.
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
11Outputs
Step counts, discard reasons, Duplicates tab, upload history with rollback. Filter counts always match the table.
Main View · ICP Results · Apollo · Reo DB · Crunchbase · Discarded · Funnel Summary. Built from live data, timestamped per export.
The same workbook, uploaded into one shared folder with a single click.
Per post: who engaged, their company, its classification and their ICP status.
From Company Enrichment: a deduped list of domains ready for an ICP check.
Everything marked Review, each with the AI's reason, for a 30-second human decision.
★The principles behind every rule
When in doubt, the answer is Review, never No.
No invented domains, no automatic merges on a weak match, no guessing between two companies with the same name.
The AI suggests, the rules decide, and the never-ICP list has the final say.
Each column has one owner, every write is logged, and every upload can be undone.
Cost estimates before every launch, daily limits on LinkedIn usage, a cap on weekly AI classifications.
"Seen vs stored" counts, unresolved people reported, failures flagged instead of silently dropped.
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.
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.
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:
syncCampaignIcpToProfilesinsrc/lib/db/comments.ts·countsAsIcpForSocialinsrc/lib/icp-overrides.ts