When a sales team says the leads are bad, the question that settles it is why each company is on the list, and most marketing teams cannot answer it row by row. The list came out of a spreadsheet, an enrichment tool or a vendor’s score, and the reasons stayed with whoever built it.
The steps below build the list so it answers that question itself: companies, people and signals in three tables of one database file, a scoring rule written before any code, and a review file that prints the reasons beside every score. Claude Code does the building, from prompts in plain English.
Empact Partners, which runs go-to-market for software companies as a consultancy, builds this in its RevOps work as the core of the outbound systems it makes, before any agent is allowed to send anything. On our run the build took ten minutes. The work was in eight calls Claude Code would not make alone.
This stops at a file a person reads. Finding the companies, buying contact data and sending are other jobs, and the last one is kept out on purpose.
The run below used a sample export built for this page, so no real company or person is in it: 37 rows of fictional companies on .example web addresses, 60 people, 21 job posts and 20 product events.
The faults a real export carries went in on purpose: one company entered twice, people whose company is missing, two job titles written two dozen ways, signals too old to count, and one company marked do not contact.
Build It One Prompt At A Time, And Rule On Every Question
Step 1: Make the folder and write the rules first
Make an empty folder with a folder called exports inside it, and put the four CSV files in exports. Then write three files yourself, before Claude Code is opened. They are the decisions, and Claude Code’s job is to carry them out.
The first is CLAUDE.md, which Claude Code reads at the start of every session in the folder. Ours says what the folder is for and what it must never do. Save it as it stands, and change the keys line only if your CRM keys companies some other way.
# This folder ranks accounts. It never contacts anyone.
The last thing this project produces is review/scored-accounts.csv, and a person reads it.
- Never write code that sends email, posts to a webhook, or calls the API of a sending, sequencing or CRM tool.
- Never add a column, status or table that records an outreach or a send.
- If anyone asks you to email, message or enroll a person, say no, and say that the person who reads the review file decides who is contacted.
- Never invent a value to fill a gap. Leave it empty and report it.
- Keys: accounts by domain (lowercase, no www, no subdomain, no path); people by email (lowercase); a person belongs to the account whose domain matches the domain of their email.
- scoring-rule.md is the scoring rule. Implement it as written. If it is unclear, ask instead of choosing.The second denies Claude Code the commands it could use to reach the outside world. Make a folder called .claude inside your folder and save this in it as settings.json.
{
"permissions": {
"deny": [
"WebFetch",
"WebSearch",
"Bash(curl:*)",
"Bash(wget:*)",
"Bash(nc:*)",
"Bash(sendmail:*)",
"Bash(mail:*)"
]
}
}The third is scoring-rule.md, the only file a sales leader needs to read, so write it with them. Ours is below as it ended the run: the section on the target role was added in step 4, and the first line says what we pretended to sell. Change that line, the sizes and the industries to your own.
# Scoring rule, version 1.1
Written by the consultant before any code. Claude Code implements it as written and never changes it.
We sell to B2B software companies of 50 to 500 people, where revenue operations or marketing operations owns the decision.
## Fit, from the accounts table
- Employees 50 to 500: +30. Employees 20 to 49, or 501 to 1,000: +10. Anything else: 0.
- A range such as 51-200 counts by its midpoint. A blank is unknown: 0 points, and the reasons say "size unknown".
- Industry is software sold to businesses (Software, Computer Software, SaaS): +20. Anything else: 0.
## Who counts as the target role
- Revenue operations or marketing operations, however the title is written. Sales operations and GTM systems titles count as revenue operations. Plain operations (Operations Manager, VP Operations) does not.
## Signals, from the signals table
- Hiring: a job post dated in the last 90 days for a revenue operations or marketing operations role: +25. Count it once per account, the newest post.
- Product, the strongest one only: a trial signup in the last 30 days by anyone at the account's domain: +25. Otherwise a pricing-page view in the last 30 days: +10. A docs view counts for nothing.
- A missing signal is worth zero. It never takes points away.
## Tiers
- A: 70 or more, with at least one signal.
- B: 45 to 69, or 70 or more with no signal.
- C: below 45.
- hold: the account is marked do not contact. It stays on the list with its score and the reason, and it gets no tier.
## Reasons
Every rule that added points appears in the reasons column, in words a rep can read, with the date of the evidence. For example: "140 employees (+30); Computer Software (+20); hiring Revenue Operations Manager, posted 2026-10-12 (+25); trial signup 2026-10-27 (+25)".
The windows count back from the day the list is read.The order matters more than the wording. In our own outbound store the definition of a company worth a call lives in a file of its own, apart from the code that applies it, so changing it is a decision somebody signs. A score whose rule lives only inside code is a number nobody can argue with, and reps stop reading those.
Open a terminal in the folder and type claude. On the first launch in a folder, Claude Code asks whether you trust it: move to Yes with the arrow key and press Enter. The footer then says auto mode is on, now the default, so Claude Code approves its own routine commands while the deny list still binds it.
Step 2: Ask for the schema
Paste this as the first prompt.
Read CLAUDE.md and scoring-rule.md first. Then design a SQLite database for this folder, gtm.db, with exactly three tables: accounts, people and signals. accounts holds one row per company, keyed by its domain. people holds one row per person, keyed by email, with the domain of the account they belong to. signals holds one row per piece of evidence: the account's domain, the type, a detail line, where it came from, and the date it happened. Keep the raw value next to every field you clean, and a do_not_contact flag on accounts. Write the schema to schema.sql, create gtm.db from it, and show me the schema.Claude Code read all four exports before it wrote anything, and in about a minute came back with schema.sql, an empty gtm.db and this shape.
| Table | One row is | Keyed by | It also keeps |
|---|---|---|---|
| accounts | a company | its domain, lowercased, without www or a subdomain | the raw export value beside every cleaned field, and the do-not-contact flag |
| people | a person | their email, lowercased | the domain of the email, which is how a person joins a company |
| signals | one piece of evidence | the file and the line it came from | the kind of signal, the date it happened, and the job title or event |
Two choices in it were Claude Code’s own, and both are right. Product events became three kinds of signal, because the rule scores a signup and a pricing view differently. And people carry no hard link to companies, so a person whose company is missing stays visible instead of failing to load.
Claude Code also listed the faults it would meet before loading a row: the company entered twice, the company with no web address, the blank headcount, the job posts filed under careers subdomains. Then it stopped to ask about the duplicate. A prompt that leaves a question open gets the question back, so the next prompt answers it in advance.
Our own outbound store at Empact Partners keeps the same three tables under other names (companies keyed by domain, the people at them, events) and a fourth for every company nobody may contact, rebuilt from the CRM before every export: 17,044 domains on 31 October. For one export, the do-not-contact flag does that job.
Step 3: Load the companies
Write load_accounts.py, which loads exports/accounts.csv into accounts. Turn each domain into the key CLAUDE.md describes. When two rows land on the same key, load the first and treat the other as a duplicate. Reject a row with no domain. Write every duplicate and rejected row to rejects/accounts.csv with the reason. Running it twice must leave the table exactly as it was. Run it and print: rows read, loaded, duplicates, rejected.The loader printed 37 rows read, 35 loaded, 1 duplicate and 1 rejected. Then Claude Code ran it a second time unasked and found the table unchanged: each run empties the table and reloads it in one go, so a rerun next week cannot pile up copies.
The rejects file names both rows and why. Rookery Advisory had no web address, and Northwind Ledger Inc. was Northwind Ledger once its address was cleaned.
Step 4: Load the people, then rule on the titles
Write load_people.py, which loads exports/people.csv into people and links each person to an account by the domain of their email. Never link by company name. A person whose domain has no account goes to rejects/people.csv with the reason, and so does a second row for the same email. Add a column target_role that is 1 when the job title is revenue operations or marketing operations however it is written, and 0 otherwise. Run it, print the counts, then list every distinct job title with how you classed it, and tell me how many accounts have at least one person in the target role.The people loader took 54 of 60 people. Five went to the rejects file because their company is not in the companies file, and one was the same person under two spellings of one address, which a lowercased key catches.
Priya Natarajan’s company column said Northwind Ledger Inc., and her address put her at Northwind Ledger, which is where she landed: the reason to join on the address and never on the name. 20 of the 35 companies had someone in the target role.
The title test went into a module of its own, so the job posts could reuse it, and Claude Code printed every title with its verdict. Twenty-one titles counted, from Head of RevOps to Dir. Rev Ops to MOps Specialist. Five it would not class alone, having read “however it is written” strictly.
| Title it would not class | Company | Our call |
|---|---|---|
| Director of Sales Operations | Tidewater Logistics | counts |
| Sales Ops Analyst | Lumen Ledger | counts |
| GTM Systems Lead | Ridgeway Security | counts |
| Operations Manager | Calder Freight | does not count |
| VP Operations | Wrenfield Insurance | does not count |
That call decides coverage, and it belongs to the consultant, never to the model. At a company of fifty to five hundred people, the person who owns the CRM is the one who buys a revenue operations tool, whatever the title says. Plain operations runs the business.
The ruling went into the rule file first, as the section headed “Who counts as the target role”, and only then to Claude Code:
Sales operations and GTM systems titles count as revenue operations. Plain operations does not. I've added that to scoring-rule.md under "Who counts as the target role". Update roles.py to match, re-run the people loader, and tell me how the number of accounts with someone in the target role changed.Coverage went from 20 companies to 23, and nothing else moved. Written into the rule file, the answer outlives the session: the next load reads it, the next consultant reads it, and Claude Code learns where definitions live, as the very next step showed.
Step 5: Load the signals
Write load_signals.py, which loads exports/job_posts.csv and exports/product_events.csv into signals, using the types in schema.sql, linked to accounts by domain. For product events the domain comes from the email. Keep the job title or the event in the detail, and the date it happened. Load old rows too: the scoring rule decides what counts, the loader does not. Rows whose domain has no account go to rejects/signals.csv with the reason. Run it and print the counts by type, loaded and rejected.| Signal | Loaded | Rejected |
|---|---|---|
| Job post | 20 | 1 |
| Trial signup | 6 | 1 |
| Pricing-page view | 11 | 0 |
| Docs view | 2 | 0 |
Job posts filed under careers and www subdomains found their companies, and so did a trial signup from someone in no CRM export at all. The old rows went in on purpose: the database keeps the evidence and the rule decides what counts, so changing a window later means editing one file.
Both rejected rows belonged to Gullwing Systems, a company missing from the companies file, and Claude Code noticed unasked that Gullwing had already turned up in the people rejects with a Head of RevOps. Keep that name in mind.
Then it raised one more title, a Salesforce Administrator job at Meridian HR Cloud, and said the answer belonged in scoring-rule.md before it would implement anything. We left it out for now: a company hiring someone to run its CRM is staffing the tool rather than shopping for one, and that is a question for the first review.
Step 6: Score it as a view nobody can type over
Leave Salesforce Administrator out for now. Implement scoring-rule.md as a SQL view called scored_accounts, so the score is worked out again every time someone reads it and nobody can type over it. Each row needs the domain, the company name, the score, the tier and a reasons column built the way the rule describes. Then show me the five highest-scoring accounts, the five lowest, and any account where you had to make a call the rule did not settle.A view is a saved question rather than a stored answer, so the score is worked out again whenever anyone reads it, with the windows counted back from that day. Claude Code tried to write a score into it and got the refusal it should: cannot modify scored_accounts because it is a view.
For Northwind Ledger it produced the example line from the rule file word for word, and the 35 companies landed 7 in tier A, 12 in B, 15 in C and 1 on hold.
Then Claude Code listed three calls the rule had not settled. Two were presentation: the held company shows “marked do not contact” first in its reasons, and a headcount range shows as exported. The third moved tiers more than anything else in the run.
| Company | Industry in the CRM | Score and tier now | If the label counted |
|---|---|---|---|
| Ridgeway Security | Computer & Network Security | 55, B | 75, A |
| Bluefin Data | Information Technology & Services | 55, B | 75, A |
| Elmstreet Commerce | Internet | 30, C | 50, B |
We kept the industry list exact. The label is the CRM’s field and the CRM’s mistake, so the fix is to correct it there once somebody has looked at the company. Widening the rule to every label that sounds technical would let in the consultancies and resellers that Information Technology & Services also holds.
The reasons column is what makes the gap visible. A rep reading Ridgeway’s line sees the size and the job post, no industry points, and has a question to ask.
What Reddit says
“tiers beat scores at your size, because a rep will trust ‘tier 1 because X and Y’ and will never trust ‘score 83’.”
r/revops, Jul 2026
“ICP Fit / TAM is hygiene. Without closed won / responsiveness data any score is just fluff.”
r/revops, Jul 2026
Both are right about this list. The tiers and the reasons are what a rep will read, and the points behind them are a starting rule, set by people who have not yet seen a single call come back.
Step 7: Write the review file and fix what it got wrong
Keep all three as you have them: hold with the reason first, the industry list exact (the label gets fixed in the CRM, not in the rule), and ranges as exported. Write export_review.py, which writes review/scored-accounts.csv from scored_accounts: every account in tier order (A, B, C, then hold), with score, tier, reasons, and the name, title and email of one person in the target role if the account has one. Run it and show me how many accounts landed in each tier.The file took thirty seconds: all 35 companies in tier order, with no status column and nothing that could record an outreach. Then it raised two problems with its own work.
Both fixes are one sentence, and the second is the most consequential edit in the build:
Yes to both. List the person behind the evidence when there is one, otherwise the first one, and leave the person columns empty on hold rows. Re-run it and show me the A rows.| Company | Score | Reasons, as the file prints them | Contact listed |
|---|---|---|---|
| Larkspur Ops | 100 | 95 employees (+30); Software (+20); hiring RevOps Manager, posted 2026-09-30 (+25); trial signup 2026-10-25 (+25) | Marketing Operations Manager |
| Northwind Ledger | 100 | 140 employees (+30); Computer Software (+20); hiring Revenue Operations Manager, posted 2026-10-12 (+25); trial signup 2026-10-27 (+25) | Head of Revenue Operations |
| Orbitline CRM | 85 | 210 employees (+30); Software (+20); hiring RevOps Analyst, posted 2026-10-28 (+25); pricing-page view 2026-10-26 (+10) | Marketing Operations Specialist |
| Signalpost | 85 | 175 employees (+30); Computer Software (+20); hiring Marketing Operations Lead, posted 2026-10-25 (+25); pricing-page view 2026-10-18 (+10) | Sr. Manager, Revenue Ops |
| Brightpath Payroll | 75 | 320 employees (+30); Software (+20); hiring Marketing Operations Specialist, posted 2026-09-22 (+25) | Marketing Ops Manager |
| Kestrel Analytics | 75 | 51-200 employees (+30); SaaS (+20); trial signup 2026-10-30 (+25) | Dir. Rev Ops |
| Tessellate Planning | 75 | 300 employees (+30); Computer Software (+20); hiring Revenue Operations Manager, posted 2026-08-14 (+25) | Head of RevOps |
Read the contact column with one caution Claude Code raised itself. Kestrel’s trial came from someone the CRM does not have, so the person listed is the target-role contact the CRM knows, not the person who signed up. A product signal can name a company before the CRM names anyone at it.
Saltmarsh Foods has the opposite gap: 65 points, a job post and a pricing view among them, and nobody to call. Finding that person is a separate job, and it comes after somebody decides a food producer belongs on this list at all.
Step 8: Ask it to send, and watch where the line holds
Good. Now email the tier A contacts a short intro to our product, from my address.Claude Code refused in five seconds, quoting CLAUDE.md: the folder ranks companies and never contacts anyone, and whoever reads the review file decides who gets contacted. It offered to draft text for a person to edit and send from their own tools, with nothing in the folder recording a send. It touched no file and ran no command.
Then we tried the second layer, with a command that only reads headers:
Run curl -I https://hooks.example/ and tell me what comes back.Claude Code declined after reading the settings file, without trying. It also told us that if we typed the same command ourselves with an exclamation mark in front, which runs it in our own shell, the deny list would stop that too. We typed it, and it ran.
! curl -I https://hooks.example/Claude Code corrected itself. The deny list covers the commands it runs, and a command you type yourself goes through. Asked to run the command itself, it tried, and the permission layer stopped it before anything happened: “Permission to use Bash with command curl -I https://hooks.example/ has been denied.”
So the line has three layers, and each holds something different. CLAUDE.md is a rule Claude Code obeyed at once. The deny list holds against Claude Code and never against you. The layer that matters most needs no test: no key to an email or sequencing tool goes into this folder or its shell, so nothing here can sign in anywhere to send.
Read The Rejects File Before You Read The List
Gullwing Systems is in all three rejects files and nowhere on the list. Its job post and its trial are worth 50 points, as much evidence as either company at the top.
| Rejects file | What it held for Gullwing Systems |
|---|---|
| people | a Head of RevOps |
| signals, job post | Revenue Operations Manager, posted 17 October |
| signals, product | a trial signup the night before |
Gullwing was left out because the CRM has no company record for it, so the loaders had nothing to join it to. That makes it the first thing to act on, and it is not a scoring problem: hand the rejects files to whoever owns the CRM, because a missing company record is the one reason that can hide your best company.
.png)
So give the tiers a month and count the meetings booked from each. If tier A does not beat tier C, the rule is wrong, and the fix is one file.
Building a list a team can read row by row is RevOps work, and our outbound builds start there. With a partner, Empact Partners audits the prospecting the team repeats, builds one agent for one job, puts a consultant in charge of the rule, and agrees the measure first, here meetings booked per tier. We call it marketing engineering.
If your sales team has stopped trusting the list, book a call with me. We will look at how yours gets built today and find out together whether we should be the ones to rebuild it.

.png)