Claude Skill

lead-qualification

Score prospects on need, budget, authority, timing, and fit - decide who gets sales effort. Use to prioritize the pipeline honestly.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download navinspire-ia-navin-navin_skills_lead-qualification-e9c73a3.zip · 3 KB
Part of navinspire-ia/navin — 182 skills

Install

skills CLI npx skills add https://github.com/Navinspire-ia/navin/tree/main/navin/skills/lead-qualification
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install navinspire-ia-navin@llmmart
Git git clone https://github.com/Navinspire-ia/navin.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole navinspire-ia/navin collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Lead Qualification

Qualification protects selling time. Score honestly, disqualify fast, document why. Use the deterministic helper for ICP totals and CSV validation.

The live book is Studio #/leads. leads action=rescore writes BANT-F on the desk. The desk loop and leads action=watch re-score locally. Heartbeat never hunts and never sends a sequence.

When to use

  • Ranking a prospect list before outreach
  • Routing A/B/C after enrichment or discovery

When not to use

  • Pure research sheets without scoring (account-research)
  • Inflating scores to hit activity quotas

Scoring model (BANT-F + ICP)

Dimension Questions Signals
Budget can they pay? size, funding, current spend
Authority decider or tourist? role, buying process
Need real pain? trigger, cost of inaction
Timing why now? deadline, renewal, project
Fit / ICP can we serve them? sector, size, geo, stack

Score each dimension 0-5 with evidence. Weighted default: Fit 30%, Need 25%, Timing 20%, Authority 15%, Budget 10%.

Tiers from total 0-100:

Tier Range Action
A ≥70 contact now
B 40-69 nurture
C <40 discard / revisit condition

Disqualification triggers

  • No identifiable pain we solve
  • Budget an order of magnitude off
  • Fit failure (out of ICP)
  • Deciders unreachable after agreed attempts

Log reason + revisit condition ("re-check after FY").

Helper script

# Validate CSV columns / URLs / confidence
python navin/skills/lead-qualification/scripts/score_leads.py sales/prospects.csv --validate-only

# Score rows that already have bant columns (fit,need,timing,authority,budget)
python navin/skills/lead-qualification/scripts/score_leads.py sales/prospects.csv -o sales/prospects-scored.csv

Expected optional columns for scoring: fit,need,timing,authority,budget (0-5 each) or a single icp_score.

Workflow

  1. Input leads + discovery/enrichment notes.
  2. Fill BANT-F; unknowns become next-call questions (do not invent).
  3. Run the script; route A/B/C.
  4. Update after material changes; pipeline-analyst consumes tiers.

Rules

  • Every score cites evidence or stays unknown.
  • Optimism is not a data point.
  • Never mark email verified without enrichment proof / public source.

Anti-patterns

  • Scoring everyone A
  • Dropping disqualified rows without reason codes
Files (navin)
  • scripts
    • score_leads.py 5.9 KB
      #!/usr/bin/env python3
      # Copyright (c) 2026-present Navinspire IA
      # SPDX-License-Identifier: AGPL-3.0-only
      
      """Validate and score lead CSVs for the Leads studio.
      
      Canonical columns (subset required for validate):
        company, website, source, confidence
      Optional scoring columns (0-5): fit, need, timing, authority, budget
      Optional: icp_score (0-100) used as-is when BANT columns absent
      
      Weights: fit 0.30, need 0.25, timing 0.20, authority 0.15, budget 0.10
      Tiers: A >=70, B >=40, else C
      """
      
      from __future__ import annotations
      
      import argparse
      import csv
      import os
      import re
      import sys
      import tempfile
      from pathlib import Path
      from urllib.parse import urlparse
      
      REQUIRED = ("company", "website", "source", "confidence")
      BANT = ("fit", "need", "timing", "authority", "budget")
      WEIGHTS = {
          "fit": 0.30,
          "need": 0.25,
          "timing": 0.20,
          "authority": 0.15,
          "budget": 0.10,
      }
      URL_RE = re.compile(r"^https?://", re.I)
      
      
      def _tier(score: float) -> str:
          if score >= 70:
              return "A"
          if score >= 40:
              return "B"
          return "C"
      
      
      def _valid_url(value: str) -> bool:
          value = (value or "").strip()
          if not value:
              return False
          if not URL_RE.match(value):
              value = "https://" + value
          parsed = urlparse(value)
          return bool(parsed.netloc and "." in parsed.netloc)
      
      
      def validate(rows: list[dict[str, str]]) -> list[str]:
          errors: list[str] = []
          if not rows:
              return ["CSV has no data rows"]
          # rows already normalized to lower keys
          for col in REQUIRED:
              if col not in rows[0]:
                  errors.append(f"missing column: {col}")
          if errors:
              return errors
      
          seen_domains: set[str] = set()
          for i, row in enumerate(rows, start=2):
              company = (row.get("company") or "").strip()
              if not company:
                  errors.append(f"row {i}: empty company")
              website = (row.get("website") or "").strip()
              if not _valid_url(website):
                  errors.append(f"row {i}: invalid website '{website}'")
              else:
                  host = urlparse(
                      website if URL_RE.match(website) else "https://" + website
                  ).netloc.lower().removeprefix("www.")
                  if host in seen_domains:
                      errors.append(f"row {i}: duplicate domain {host}")
                  seen_domains.add(host)
              source = (row.get("source") or "").strip()
              if not source:
                  errors.append(f"row {i}: empty source")
              elif source.lower() != "unverified" and not _valid_url(source):
                  # allow non-URL sources like "Pappers search" but prefer URLs
                  pass
              conf = (row.get("confidence") or "").strip().lower()
              if conf not in {"high", "medium", "med", "low", "unverified", "h", "m", "l"}:
                  errors.append(
                      f"row {i}: confidence should be high|medium|low|unverified (got '{conf}')"
                  )
          return errors
      
      
      def score_row(row: dict[str, str]) -> tuple[float, str]:
          if all((row.get(k) or "").strip() != "" for k in BANT):
              total = 0.0
              for key, weight in WEIGHTS.items():
                  try:
                      val = float(row[key])
                  except ValueError:
                      val = 0.0
                  val = max(0.0, min(5.0, val))
                  total += (val / 5.0) * 100.0 * weight
              return round(total, 1), _tier(total)
          raw = (row.get("icp_score") or "").strip()
          if raw:
              try:
                  total = max(0.0, min(100.0, float(raw)))
              except ValueError:
                  total = 0.0
              return total, _tier(total)
          return 0.0, "C"
      
      
      def _read(path: Path) -> list[dict[str, str]]:
          with path.open(newline="", encoding="utf-8-sig") as fh:
              reader = csv.DictReader(fh)
              if not reader.fieldnames:
                  return []
              rows = []
              for raw in reader:
                  rows.append({(k or "").strip().lower(): (v or "").strip() for k, v in raw.items()})
              return rows
      
      
      def main() -> None:
          parser = argparse.ArgumentParser(description=__doc__)
          parser.add_argument("csv_path", type=Path)
          parser.add_argument("-o", "--output", type=Path, default=None)
          parser.add_argument("--validate-only", action="store_true")
          args = parser.parse_args()
      
          rows = _read(args.csv_path)
          errors = validate(rows)
          if errors:
              print("VALIDATION FAILED", file=sys.stderr)
              for err in errors:
                  print(f"- {err}", file=sys.stderr)
              raise SystemExit(1)
          print(f"VALIDATION OK ({len(rows)} rows)")
      
          if args.validate_only:
              return
      
          out_rows = []
          for row in rows:
              total, tier = score_row(row)
              enriched = dict(row)
              enriched["icp_score"] = str(total)
              enriched["tier"] = tier
              if total >= 70:
                  enriched.setdefault("next_action", "contact_now")
              elif total >= 40:
                  enriched.setdefault("next_action", "nurture")
              else:
                  enriched.setdefault("next_action", "discard_or_revisit")
              out_rows.append(enriched)
      
          out_path = args.output or args.csv_path.with_name(args.csv_path.stem + "-scored.csv")
          fieldnames = list(out_rows[0].keys())
          # Atomic write: never leave a half-written scored CSV on crash.
          out_path.parent.mkdir(parents=True, exist_ok=True)
          fd, tmp_name = tempfile.mkstemp(
              prefix=f".{out_path.name}.", suffix=".tmp", dir=str(out_path.parent)
          )
          try:
              with os.fdopen(fd, "w", newline="", encoding="utf-8") as fh:
                  writer = csv.DictWriter(fh, fieldnames=fieldnames)
                  writer.writeheader()
                  writer.writerows(out_rows)
                  fh.flush()
                  os.fsync(fh.fileno())
              os.replace(tmp_name, out_path)
          except BaseException:
              try:
                  os.unlink(tmp_name)
              except OSError:
                  pass
              raise
      
          a = sum(1 for r in out_rows if r["tier"] == "A")
          b = sum(1 for r in out_rows if r["tier"] == "B")
          c = sum(1 for r in out_rows if r["tier"] == "C")
          print(f"Wrote {out_path} (A={a} B={b} C={c})")
      
      
      if __name__ == "__main__":
          main()
      
  • SKILL.md 2.6 KB
    ---
    name: lead-qualification
    description: Score prospects on need, budget, authority, timing, and fit - decide who gets sales effort. Use to prioritize the pipeline honestly.
    metadata: {"navin":{"emoji":"🎛️","category":"sales"}}
    ---
    
    # Lead Qualification
    
    Qualification protects selling time. Score honestly, disqualify fast, document why. Use the deterministic helper for ICP totals and CSV validation.
    
    The live book is Studio `#/leads`. `leads action=rescore` writes BANT-F on the desk. The desk loop and `leads action=watch` re-score locally. Heartbeat never hunts and never sends a sequence.
    
    ## When to use
    
    - Ranking a prospect list before outreach
    - Routing A/B/C after enrichment or discovery
    
    ## When not to use
    
    - Pure research sheets without scoring (`account-research`)
    - Inflating scores to hit activity quotas
    
    ## Scoring model (BANT-F + ICP)
    
    | Dimension | Questions | Signals |
    |-----------|-----------|---------|
    | **B**udget | can they pay? | size, funding, current spend |
    | **A**uthority | decider or tourist? | role, buying process |
    | **N**eed | real pain? | trigger, cost of inaction |
    | **T**iming | why now? | deadline, renewal, project |
    | **F**it / ICP | can we serve them? | sector, size, geo, stack |
    
    Score each dimension 0-5 with evidence. Weighted default: Fit 30%, Need 25%, Timing 20%, Authority 15%, Budget 10%.
    
    Tiers from total 0-100:
    
    | Tier | Range | Action |
    |------|-------|--------|
    | A | ≥70 | contact now |
    | B | 40-69 | nurture |
    | C | <40 | discard / revisit condition |
    
    ## Disqualification triggers
    
    - No identifiable pain we solve
    - Budget an order of magnitude off
    - Fit failure (out of ICP)
    - Deciders unreachable after agreed attempts
    
    Log reason + revisit condition ("re-check after FY").
    
    ## Helper script
    
    ```bash
    # Validate CSV columns / URLs / confidence
    python navin/skills/lead-qualification/scripts/score_leads.py sales/prospects.csv --validate-only
    
    # Score rows that already have bant columns (fit,need,timing,authority,budget)
    python navin/skills/lead-qualification/scripts/score_leads.py sales/prospects.csv -o sales/prospects-scored.csv
    ```
    
    Expected optional columns for scoring: `fit,need,timing,authority,budget` (0-5 each) or a single `icp_score`.
    
    ## Workflow
    
    1. Input leads + discovery/enrichment notes.
    2. Fill BANT-F; unknowns become next-call questions (do not invent).
    3. Run the script; route A/B/C.
    4. Update after material changes; `pipeline-analyst` consumes tiers.
    
    ## Rules
    
    - Every score cites evidence or stays unknown.
    - Optimism is not a data point.
    - Never mark email `verified` without enrichment proof / public source.
    
    ## Anti-patterns
    
    - Scoring everyone A
    - Dropping disqualified rows without reason codes
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related