Work sample
Signup router: who gets a human this week, and what that human does first.
A small Python tool that takes a week of product trial signups, drops anyone who is already a customer or partner, scores the rest from 0 to 100 on usage, company fit, integration path and pain signals, and assigns one next action per account. Every row carries the evidence behind its score.
Built in October 2026 from an exercise I worked on. Company, product and customer names are removed and all data is synthetic. I ran an earlier version and this one on the same 17 accounts: every score and total is identical.
How it works
Run output, 29 October 2026
20 rows in. The gate dropped 3: a customer, a partner and a personal email address. 17 were scored. Four get a custom benchmark and an enterprise conversation, five get a mid tier offer, and eight go to nurture, five of those flagged to a partner owner instead of sales.
| # | Account | Usage /35 | Company /25 | Integration /25 | Pain /15 | Score | Next action |
|---|---|---|---|---|---|---|---|
| 1 | Synthetic Company ABerlin, 120 staff, direct api | 35 | 25 | 25 | 15 | 100 | Custom benchmark, open the enterprise conversation |
| 2 | Synthetic Company GZurich, 450 staff, direct api | 35 | 25 | 25 | 15 | 100 | Custom benchmark, open the enterprise conversation |
| 3 | Synthetic Company JHamburg, 140 staff, open framework | 32 | 25 | 25 | 10 | 92 | Custom benchmark, open the enterprise conversation |
| 4 | Synthetic Company CAustin, 60 staff, self hosted | 32 | 18 | 25 | 5 | 80 | Custom benchmark, open the enterprise conversation |
| 5 | Synthetic Company BAmsterdam, 300 staff, open framework | 16 | 25 | 25 | 5 | 71 | Offer the mid tier, open a shared Slack channel |
| 6 | Synthetic Company KChicago, 35 staff, partner marketplace | 35 | 25 | 6 | 5 | 71 | Nurture, flag to the marketplace partner owner |
| 7 | Synthetic Company NMadrid, 220 staff, direct api | 16 | 18 | 25 | 10 | 69 | Offer the mid tier, open a shared Slack channel |
| 8 | Synthetic Company OStuttgart, 900 staff, direct api | 32 | 4 | 25 | 8 | 69 | Offer the mid tier, open a shared Slack channel |
| 9 | Synthetic Company DMunich, 25 staff, direct api | 9 | 25 | 25 | 0 | 59 | Offer the mid tier, open a shared Slack channel |
| 10 | Synthetic Company QBarcelona, 65 staff, reseller channel | 23 | 25 | 6 | 5 | 59 | Nurture, flag to the reseller channel owner |
| 11 | Synthetic Company LDublin, 48 staff, direct api | 9 | 18 | 25 | 5 | 57 | Offer the mid tier, open a shared Slack channel |
| 12 | Synthetic Company FVienna, 80 staff, reseller channel | 16 | 18 | 6 | 5 | 45 | Nurture, flag to the reseller channel owner |
| 13 | Synthetic Company HParis, 700 staff, direct api | 6 | 11 | 25 | 0 | 42 | Nurture |
| 14 | Synthetic Company MAachen, 12 staff, direct api | 16 | 0 | 25 | 0 | 41 | Nurture |
| 15 | Synthetic Company PToronto, 9 staff, partner marketplace | 18 | 17 | 6 | 0 | 41 | Nurture, flag to the marketplace partner owner |
| 16 | Synthetic Company ELondon, 15 staff, partner marketplace | 22 | 0 | 6 | 0 | 28 | Nurture, flag to the marketplace partner owner |
| 17 | Synthetic Company ILisbon, 2 staff, direct api | 0 | 0 | 25 | 0 | 25 | Nurture |
Synthetic input. Every domain ends in .example except the personal email row that tests the gate. Company names, volumes and signals are invented to exercise the rules and say nothing about real companies. The column names are my assumption of what a product database and a Clay table would hold.
What I would change first
Two results from this run need a conversation before the weights are trusted. A 900 person company scores 69 and gets a mid tier offer, because integration and usage outrun a weak company fit. That is the first weight I would revisit. And two accounts hit 100, so the scale saturates at the top. A tie break on last week's volume is the simplest fix.
How I would judge the tool: flagged accounts should convert to a paid tier within 90 days at a higher rate than unflagged accounts of the same age. If they do not after two weekly cycles, change the weights or drop it. The weekly check is simpler still: does the account owner agree with the top five.
How it was built
- AI did
- Claude Code wrote both scripts from my written description of the gate, weights and thresholds.
- I did
- Set the weights and thresholds, wrote the exclusion rules, read every scored row against the input, and capped pain signals at 15 so no account clears the mid tier line on a keyword match alone.
- Not built
- The Clay enrichment (those columns are typed in for this run) and the n8n step that writes the top rows into HubSpot as tasks, with the evidence in the task body. Both are designed, neither is running.
- Run it
python3 make_synthetic_signups.pythenpython3 signup_router.py data/signups_week_43_synthetic.csv --out output. Standard library only.
Files
Read signup_router.py here
"""
Signup router: decides which product trial signups get a human this week
and what that human does first.
python3 signup_router.py data/signups_week_43_synthetic.csv --out output/
Pipeline
1. Gate (hard, before any scoring): drop existing customers and partners, and
personal email domains. Log the reason.
2. Score 0 to 100: usage 35, company 25, integration 25, pain signals 15.
3. One next action per account from score and trend.
4. Write scored.csv, excluded.csv, top5.md and a summary.
Exclusions are a gate, never a weight: no tuning can send a customer a sales
task. Weights and rules are set by hand at the top of this file.
"""
import argparse
import csv
import json
import os
from datetime import date
# ---- rules set by hand ------------------------------------------------------
# In production this list comes from a CRM export of customers, partners and
# open deals. Here it holds the two synthetic domains the test rows use.
EXCLUDED_DOMAINS = {"customer-one.example", "partner-two.example"}
PERSONAL_DOMAINS = {
"gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "yahoo.com",
"icloud.com", "proton.me", "protonmail.com", "gmx.de", "gmx.net", "web.de",
}
WEIGHTS = {"usage": 35, "company": 25, "integration": 25, "pain": 15}
# Direct opportunity by integration path. Accounts that arrive through a
# marketplace or a reseller already have a partner selling to them, so they
# score a quarter of a direct deal and go to the partner owner instead.
INTEGRATION_POINTS = {
"direct_api": 25, "open_framework": 25, "self_hosted": 25,
"partner_marketplace": 6, "reseller_channel": 6, "unknown": 10,
}
TARGET_CATEGORIES = {"core_segment"}
THRESHOLD_ENTERPRISE = 70
THRESHOLD_MIDTIER = 45
# ---- scoring ----------------------------------------------------------------
def volume(row):
return [int(row[f"volume_w{i}"] or 0) for i in range(1, 5)]
def trend(vols):
"""'rising' if three of three week over week steps are up and w4 >= 1.3 x w1."""
steps = [b > a for a, b in zip(vols, vols[1:])]
if all(steps) and vols[0] and vols[3] >= 1.3 * vols[0]:
return "rising"
if vols[3] < 0.8 * vols[0]:
return "falling"
return "flat"
def score_usage(row):
vols = volume(row)
t = trend(vols)
pts, why = 0, []
if t == "rising":
pts += 15
why.append("volume up four weeks running")
elif t == "flat":
pts += 6
level = vols[-1]
if level >= 25000:
pts += 10
why.append(f"{level:,} units last week")
elif level >= 8000:
pts += 7
why.append(f"{level:,} units last week")
elif level >= 2000:
pts += 3
products = set(row["products_used"].split(";")) if row["products_used"] else set()
if {"addon_x", "addon_y", "addon_z"} & products and {"core_a", "core_b"} & products:
pts += 10
why.append("add on used alongside core product")
return min(pts, WEIGHTS["usage"]), why, t
def score_company(row):
pts, why = 0, []
if row["category"] in TARGET_CATEGORIES:
pts += 10
why.append(row["category"].replace("_", " "))
hc = int(row["headcount"] or 0)
if 20 <= hc <= 500:
pts += 8
why.append(f"{hc} staff")
elif hc > 500:
pts += 4
try:
fund = int(row["last_funding_months_ago"])
except ValueError:
fund = 99
if fund <= 18:
pts += 7
why.append(f"funded {fund} months ago")
return min(pts, WEIGHTS["company"]), why
def score_integration(row):
path = row["integration_path"] or "unknown"
pts = INTEGRATION_POINTS.get(path, INTEGRATION_POINTS["unknown"])
why = [path.replace("_", " ")]
if path in ("partner_marketplace", "reseller_channel"):
why.append("scores a quarter, a partner already sells to this account")
if row["hosting"] == "self":
why.append("self hosted")
return pts, why
def score_pain(row):
signals = [s for s in row["pain_signals"].split(";") if s]
pts = min(5 * len(signals), 15)
why = [s.replace(":", ": ") for s in signals]
if row["existing_tool"] == "incumbent":
pts = min(pts + 5, 15)
why.append("incumbent tool already in product (displacement, warm)")
elif row["existing_tool"] == "inhouse":
pts = min(pts + 3, 15)
why.append("in house build")
return pts, why
def next_action(total, t, path):
if path == "partner_marketplace":
return "Nurture, flag to the marketplace partner owner"
if path == "reseller_channel":
return "Nurture, flag to the reseller channel owner"
if total > THRESHOLD_ENTERPRISE and t == "rising":
return "Custom benchmark, open the enterprise conversation"
if total >= THRESHOLD_MIDTIER:
return "Offer the mid tier, open a shared Slack channel"
return "Nurture"
def gate(row):
domain = (row["domain"] or "").lower().strip()
if not domain:
return "no domain"
if domain in EXCLUDED_DOMAINS:
return "existing customer or partner"
if row["email_type"] == "personal" or domain in PERSONAL_DOMAINS:
return "personal email"
return None
def route(rows):
scored, excluded = [], []
for row in rows:
reason = gate(row)
if reason:
excluded.append({"account_id": row["account_id"], "domain": row["domain"], "reason": reason})
continue
u, u_why, t = score_usage(row)
c, c_why = score_company(row)
s, s_why = score_integration(row)
p, p_why = score_pain(row)
total = u + c + s + p
scored.append({
"account_id": row["account_id"],
"company_name": row["company_name"],
"domain": row["domain"],
"hq_city": row["hq_city"],
"country": row["country"],
"headcount": row["headcount"],
"integration_path": row["integration_path"],
"trend": t,
"usage": u, "company": c, "integration": s, "pain": p,
"score": total,
"next_action": next_action(total, t, row["integration_path"]),
"why": "; ".join(u_why + c_why + s_why + p_why),
})
scored.sort(key=lambda r: (-r["score"], r["company_name"]))
for i, r in enumerate(scored, start=1):
r["rank"] = i
return scored, excluded
def write_outputs(scored, excluded, out_dir, source):
os.makedirs(out_dir, exist_ok=True)
with open(os.path.join(out_dir, "scored.csv"), "w", newline="") as f:
cols = ["rank", "account_id", "company_name", "domain", "hq_city", "country", "headcount",
"integration_path", "trend", "usage", "company", "integration", "pain", "score", "next_action", "why"]
w = csv.DictWriter(f, fieldnames=cols)
w.writeheader()
w.writerows(scored)
with open(os.path.join(out_dir, "excluded.csv"), "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=["account_id", "domain", "reason"])
w.writeheader()
w.writerows(excluded)
top = scored[:5]
lines = [
f"# Signup router, run {date.today().isoformat()}",
f"Input: {source} ({len(scored) + len(excluded)} rows). Gate dropped {len(excluded)}. Scored {len(scored)}.",
"",
"| # | Account | Score | Why | Next action |",
"| --- | --- | --- | --- | --- |",
]
for r in top:
acct = f"{r['company_name']}, {r['hq_city']}, {r['headcount']} staff, {r['integration_path'].replace('_', ' ')}"
lines.append(f"| {r['rank']} | {acct} | {r['score']} | {r['why']} | {r['next_action']} |")
lines.append("")
lines.append("Dropped before scoring: " + "; ".join(f"{e['domain']} ({e['reason']})" for e in excluded))
with open(os.path.join(out_dir, "top5.md"), "w") as f:
f.write("\n".join(lines) + "\n")
summary = {
"run_date": date.today().isoformat(),
"input": source,
"rows_in": len(scored) + len(excluded),
"excluded": excluded,
"scored": len(scored),
"weights": WEIGHTS,
"thresholds": {"enterprise": THRESHOLD_ENTERPRISE, "mid_tier": THRESHOLD_MIDTIER},
"actions": {a: sum(1 for r in scored if r["next_action"] == a) for a in sorted({r["next_action"] for r in scored})},
}
with open(os.path.join(out_dir, "summary.json"), "w") as f:
json.dump(summary, f, indent=2)
print("\n".join(lines))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("csv")
ap.add_argument("--out", default="output")
a = ap.parse_args()
with open(a.csv, newline="") as f:
rows = list(csv.DictReader(f))
scored, excluded = route(rows)
write_outputs(scored, excluded, a.out, os.path.basename(a.csv))
if __name__ == "__main__":
main()