pragma-gtm.com

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

InputWeekly signup export with usage volume per week, products used and integration path, plus the columns Clay would add: company, size, funding and pain signals.
GateExisting customers, partners and personal email domains are dropped before any scoring and logged with the reason.
ScoreUsage 35, company 25, integration 25, pain 15. Accounts that arrive through a marketplace or a reseller score a quarter on integration, because a partner already sells to them.
RouteAbove 70 and rising: custom benchmark and an enterprise conversation. 45 to 70: mid tier offer and a shared Slack channel. Below 45: nurture. Partner accounts go to the partner owner.

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.

#AccountUsage /35Company /25Integration /25Pain /15ScoreNext action
1Synthetic Company ABerlin, 120 staff, direct api35252515100Custom benchmark, open the enterprise conversation
2Synthetic Company GZurich, 450 staff, direct api35252515100Custom benchmark, open the enterprise conversation
3Synthetic Company JHamburg, 140 staff, open framework3225251092Custom benchmark, open the enterprise conversation
4Synthetic Company CAustin, 60 staff, self hosted321825580Custom benchmark, open the enterprise conversation
5Synthetic Company BAmsterdam, 300 staff, open framework162525571Offer the mid tier, open a shared Slack channel
6Synthetic Company KChicago, 35 staff, partner marketplace35256571Nurture, flag to the marketplace partner owner
7Synthetic Company NMadrid, 220 staff, direct api1618251069Offer the mid tier, open a shared Slack channel
8Synthetic Company OStuttgart, 900 staff, direct api32425869Offer the mid tier, open a shared Slack channel
9Synthetic Company DMunich, 25 staff, direct api92525059Offer the mid tier, open a shared Slack channel
10Synthetic Company QBarcelona, 65 staff, reseller channel23256559Nurture, flag to the reseller channel owner
11Synthetic Company LDublin, 48 staff, direct api91825557Offer the mid tier, open a shared Slack channel
12Synthetic Company FVienna, 80 staff, reseller channel16186545Nurture, flag to the reseller channel owner
13Synthetic Company HParis, 700 staff, direct api61125042Nurture
14Synthetic Company MAachen, 12 staff, direct api16025041Nurture
15Synthetic Company PToronto, 9 staff, partner marketplace18176041Nurture, flag to the marketplace partner owner
16Synthetic Company ELondon, 15 staff, partner marketplace2206028Nurture, flag to the marketplace partner owner
17Synthetic Company ILisbon, 2 staff, direct api0025025Nurture

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.py then python3 signup_router.py data/signups_week_43_synthetic.csv --out output. Standard library only.

Files

signup_router.pyGate, scoring, routing and outputs.
make_synthetic_signups.pyBuilds the labelled synthetic input.
signups_week_43_synthetic.csvThe 20 input rows.
scored.csvAll 17 scored accounts with the evidence string per row.
excluded.csvWhat the gate dropped and why.
summary.jsonWeights, thresholds and action counts.
README.mdHow to run it and what is real.
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()