anfloy.AcademyBook a call

Sales · 01 · Your GTM data machineLesson 4 of 4

Scoring, dedup & CRM sync

75 min working time · Weeks 5-6

By the end of this lesson you can
  • Score every lead with your ICP rubric automatically
  • Route each step to the cheapest model that is honest for it
  • Sync clean data to HubSpot/Attio/Salesforce

Scoring at scale: code for math, Claude for judgment

This lesson leaves you with your enriched list scored by code, deduped against your CRM, synced as clean upserts, and a /crm-hygiene skill keeping it that way every week.

In lesson 1 you scored leads conversationally. That's fine for 25 rows and wrong for 2,000. The production pattern, drawn from systems running at scale: deterministic code applies the firmographic math, and Claude is only called for the fuzzy fields code can't judge.

  1. Ask Claude to write score.py: "Implement icp-rubric.md as a scoring function. Headcount bands, geo, industry keywords, signal recency - all in code. For title-to-persona mapping and the peer-or-buyer call, call Haiku 4.5 with the rubric as a cached system prompt, ONLY for rows the explicit rules can't settle, with a strict JSON schema for the answer. Cache every answer in state/title_cache.json so each unique title is judged once. Compact the input first: pass the fields the rubric reads, not the whole profile."
  2. Run it over your verified 50 rows from lesson 3 and spot-check 10 scores against the rubric by hand.
  3. Diff against your lesson-1 conversational scores. Disagreements mean the rubric has ambiguity the code surfaced - tighten the rubric.

Output: every row carries icp_score, icp_band, and icp_reason, plus score_model and score_cost for any row a model touched. The band drives routing - A-band goes to outbound now, B-band to nurture, C and below stays in the file and out of your CRM.

Model routing for bulk scoring: the honest rule

Scoring is the first place in the track where the model bill is a real line item, so this is where to learn the routing rule you'll reuse everywhere. Inside a Claude Code session, the work runs on your subscription and the pick is simple: Opus 5.5 (the default on every plan) for judgment and architecture, Sonnet 5 as the fast daily driver, Fable 5.1 (/model fable) for the hardest long autonomous runs, Haiku 4.5 for bulk mechanical work. Skills and subagents can pin a model in their frontmatter, which is how icp-qualify stays on Sonnet while a bulk-classification skill pins haiku. In your own scripts, the metered API key pays per token and the routing question gets sharper.

  • As of September 2026, per Anthropic's pricing page (input / output per million tokens): Fable 5.1 $10 / $50, Opus 5.5 $4 / $20, Sonnet 5 $2 / $10, Haiku 4.5 $1 / $5. Batch is 50% off everything (Haiku $0.50 / $2.50). Cached input reads cost 0.1x on Haiku and Sonnet.
  • Cheap open models through OpenRouter (DeepSeek, GLM-5.3, MiniMax and others behind one OpenAI-compatible key) are legitimate for exactly one class of work: bulk, stateless, schema-checked steps with no tools and nothing a prospect or client will read. ICP tagging, field extraction, title normalization, dedup hints, internal summaries. Enforce a JSON schema, validate every row, fail the row loudly on a parse error, and A/B 50-100 real rows against Claude before switching.
  • Keep on Claude: every agent loop with tools (Claude Code and the Agent SDK are supported on Claude models only; Anthropic's docs say Claude Code does not support non-Claude models through any gateway, so never teach or build that), anything a prospect reads, the judgment calls that decide money or relationships (the competitor gate), and the verify or critic pass.
  • Compare against the cheapest Claude option, not the default one. GLM-5.3 lists at $1.40 / $4.40 plus OpenRouter's 5.5% fee, which is about the price of live Haiku 4.5 and more than Batch Haiku. Our own qualifier moved from Sonnet 4.6 to GLM 5.2 via OpenRouter in June and saved a lot; on today's price cards, Haiku 4.5 with a cached prompt, or a Flash-class model at $0.15-0.30 input (DeepSeek Flash, MiniMax-M3 at $0.30 / $1.20), would be cheaper still. Re-price quarterly; the cards move.
  • Price on measured output tokens, not the rate card. Output costs 5x input, and reasoning-heavy models write more of it. Moving one of our pipelines from Opus 5 to Sonnet 5 was estimated at 40% cheaper from the rate card and measured at 10% cheaper per company ($0.177 vs $0.196) because the smaller model wrote far more output. Turn reasoning off for classification ("we want clean JSON, not thinking tokens"), then run the same 100 inputs through both and compare the bill.
  • Price the whole pipeline first. On our LinkedIn signals engine the LLM step was about $0.06 per run against about $60 a month of scraping: 0.1% of the bill. Downgrading the model there optimizes noise and spends judgment quality to do it.
  • Privacy is part of the routing decision. DeepSeek's first-party API stores personal data in the PRC and uses it for training with an opt-out (policy dated 2026-02-10). If a row carries a person's name and employer, that is personal data. Use a US host of the same open weights, or OpenRouter with zdr: true and an only: [...] provider allowlist, and check the client's contract; regulated and government clients get extra care.

Dedupe before anything touches the CRM

Duplicates are how outbound teams embarrass themselves: two reps emailing the same prospect, or a cold sequence hitting an active customer. The dedupe pass runs on every list, every time, with no exceptions - against itself and against the CRM.

Dedupe keys - email first, then company domain
import pandas as pd

df = pd.read_csv("output/leads_scored.csv")

# Normalize before matching - casing and free-mail noise
# break naive joins
df["email_key"] = df["email"].str.lower().str.strip()
df["domain_key"] = (
    df["email_key"].str.split("@").str[1]
)

# 1. Self-dedupe on email
df = df.drop_duplicates(subset="email_key", keep="first")

# 2. Against CRM export: drop anyone already known,
#    and drop ALL leads at domains with an open deal
crm = pd.read_csv("input/crm_contacts.csv")
known_emails = set(crm["email"].str.lower())
open_deal_domains = set(
    crm.loc[crm["has_open_deal"], "domain"].str.lower()
)

df = df[~df["email_key"].isin(known_emails)]
df = df[~df["domain_key"].isin(open_deal_domains)]

df.to_csv("output/leads_clean.csv", index=False)
  • Normalize LinkedIn URLs before matching: www and no www, trailing slash and none, uppercase slugs. An exact-match unique index turns a spelling difference into a double spend and a double email. Probe every spelling on read; store one canonical form on write.
  • Run the blocklist twice: before enrichment (so you never pay for a blocked person) and again immediately before the push to the sender (a lead can flip to blocked in between: a manual add, a CRM sync, a reply that closed a deal).
  • Fail closed. Our relationship guard, which every push script calls, holds back anyone with an open deal matched by email, domain, profile URL or name, and REFUSES to run if the blocklist cannot be read or comes back empty. An empty blocklist is far more likely to be a broken export than a company with no customers.

Connect your CRM - whichever one it is

This is the lesson where the track's one move pays off again: your CRM already has an API, and as of September 2026 the major CRMs ship official hosted MCP servers. Which one you run doesn't change the build - it only changes whether the connection is one OAuth flow or a small script.

  • If your CRM ships an official MCP server: connect it once with claude mcp add, OAuth in the browser, done. As of September 2026: HubSpot at https://mcp.hubspot.com (GA since April 2026; reads and writes on contacts, companies, deals, tickets and activities), Attio at https://mcp.attio.com/mcp (reads auto-approved, writes ask for confirmation), and Salesforce hosted MCP servers (GA April 2026, Enterprise Edition and above). Check your vendor's developer docs or the claude.ai connectors directory - the list grows monthly.
  • If it doesn't: the plain REST API driven by your scripts is just as good, and for bulk work it's what you'd use anyway. Every CRM worth the name has one.
  • Either way, the capabilities you need are the same four: read contacts, write contacts, write notes, write tasks. Confirm those exist and the rest of this lesson runs unchanged.
  1. Connect yours: claude mcp add --transport http crm <your CRM's MCP URL from its docs>, then /mcp to OAuth. No MCP? Add the API key to .env and skip to the scripted route in the sync section.
  2. Test read access: "List 5 contacts created this month" - confirm the data looks right.
  3. Test write access on a sandbox or a clearly-labeled test record before any bulk operation. Always.

Sync: upsert, log, route

The sync pass moves your clean A-band and B-band rows into the CRM as an upsert: update if the contact exists, create if not, and never blind-insert. Every synced lead gets an activity note recording where it came from and what it scored - future-you will thank present-you.

  1. Define the field mapping once, in a markdown file: which CSV column lands in which CRM property, including icp_score and email_source as custom fields.
  2. For interactive syncs of <100 rows, the CRM MCP is enough: "Upsert these contacts from leads_clean.csv using field-mapping.md, and add a note 'Imported from waterfall run 2026-09-27, score: N, source: apollo, email via prospeo' to each."
  3. For recurring syncs, have Claude write sync.py against your CRM's REST API with the same mapping file. Mind the rate limits - on most CRMs the write limit is a fraction of the read limit, so the script batches and paces writes.
  4. Route on band: A-band gets assigned an owner (territory rules in the mapping file) and lands in the outbound list; B-band goes to the nurture list unassigned.

The hygiene agent: keeping it clean forever

Data quality is not a one-time cleanup, it's a recurring job. The hygiene agent is a scheduled session that audits the CRM weekly using your house rules, flags what's broken, and fixes the safe subset automatically.

  1. Write the rules as a skill: /crm-hygiene contains your pipeline-stage definitions and what "healthy" means (every open deal has a next step dated in the future, every contact has an owner, no duplicates by email).
  2. Run it conversationally first: "Run /crm-hygiene and show me the violations." Questions like "which deals have no next step?" now answer in house vocabulary.
  3. Let it auto-fix only the mechanical class (formatting, dead values, obvious dupes) - stage changes and owner reassignment stay human-approved.
  4. Once it's boring, schedule it headless - module 3, lesson 3 covers scheduling; the skill you wrote today is what gets scheduled.

Module 1 is done. You can now go from nothing to a clean, scored, synced lead list - on your real ICP, in your real CRM - without renting a single per-seat data tool. Module 2 turns the data machine into sends.

Do this now

Sources and further reading

We set it up with you

Want us to set it up with you, end to end?

Three one-on-one sessions. We train you on your real stack and build your first agents together, until you can run it yourself. You keep everything.