| name | sumble-account-scoring |
| description | Build an account-scoring AND/OR whitespace web app powered by Sumble data and optional first-party data. One skill, three objectives: score the accounts in your CRM, find whitespace (high-ICP-fit orgs NOT in your CRM), or both in one ranked sheet. Interviews the user about their ICP, pulls data from internal systems and possibly Sumble MCP, and generates a self-contained, zero-dependency Python + HTML/JS app at account_scoring/{company}/ with real-time slider re-weighting, an account-category column + filters (customer / allocated / unallocated / whitespace), and an evaluation mechanism to tune the score. Outputs a config file describing the scoring method and a Python script that applies it across all accounts. |
Account Scoring
This skill produces two things:
- A zero-dependency Python web app (stdlib
http.server) that ranks the
user's accounts (and/or whitespace) and lets them tune signal weights with
sliders. Data comes from the Sumble MCP plus optional 1P.
- A portable config file account-scoring-weights.json that describes everything
required to replicate the score in a production system, plus
score_accounts.py to apply it to a larger list later.
How much to score is the user's call, asked in the interview โ not a forced
sample. By default we score everything they hand us: an account list of
< 100,000 is scored whole (the recommended default โ credits are rarely the
binding constraint, and one full pass makes the app and calibration complete).
Only when the list is very large (โฅ 100,000) do we ask whether to spend the
credits to score it all or to score a representative subset/sample (Q5.1) โ the
tradeoff is theirs, surfaced with the credit cost. Whitespace is ranked for free
and only the chosen pool is enriched. For a one-off score of an even larger book
later (or daily refresh), hand account-scoring-weights.json to
score_accounts.py.
Follow the stages closely โ input (interview) and output should be
consistent between runs, more deterministic than most skills.
When to use
Trigger on /sumble-account-scoring, or on any of: "score my accounts",
"build an account score", "find whitespace accounts", "rank net-new accounts
I'm not selling to", "who looks like our ICP but isn't in our CRM?", or "score
my CRM and find whitespace". This is the single, consolidated skill for
account scoring AND whitespace โ Stage 1 Q5 asks the objective (score /
whitespace / both) and the rest of the run adapts.
Required tools
-
Sumble MCP (for the ICP interview only):
SearchTechnologies โ fuzzy tech discovery when you don't yet have a term
GetMyCompanyProfile โ pre-fill ICP (personas, technologies, projects)
RunSqlQuery โ only the Stage 2a tech-category roll-up aggregation
(coverage %); it has no endpoint equivalent. Name/slug resolution for
technologies, job functions, and projects now uses the v6 lookup endpoints
via _build/lookup.py (NOT RunSqlQuery).
-
Sumble public API key โ SUMBLE_API_KEY (from sumble.com/account). All
Stage-2 data now comes from one REST endpoint,
POST https://api.sumble.com/v6/organizations (match + enrich + select in a
single call). The MCP has no wrapper for it, so the _build/fetch_data.py
script calls it directly.
Check before prompting โ the key is usually already set. BEFORE you
surface the link or ask the user to do anything about the key, check whether it
already resolves. Run this one simple command and read the result:
ls ~/.config/sumble/api_key
If that file exists (or SUMBLE_API_KEY is exported in the env), the key is
configured โ say so in one line and move on. Do NOT prompt for it, and do NOT
surface the setup link. Only when the check comes up empty do you fall through
to the setup flow below.
Set the key once (only if the check above came up empty). First tell the
user where to get it: their key is at
https://sumble.com/account (Account โ API key) โ surface this link before
prompting. Then have them run the helper, which prints that link, reads the
key without echoing (so it never lands in the chat transcript or shell
history), and saves it to ~/.config/sumble/api_key (chmod 0600):
! python3 {skill_dir}/template/_build/set_api_key.py
Always python3, never bare python โ stock macOS (and many Linux
boxes) ship python3 only, and python ... fails with "command not found".
If python3 itself is missing or broken, use the zero-dependency POSIX
shell version (identical behavior โ hidden input, ~/.config/sumble/api_key,
chmod 600):
! sh {skill_dir}/template/_build/set_api_key.sh
Use a hidden-input helper as the default โ don't ask the user to paste the
key into the chat (it would be logged). fetch_data.py and
score_accounts.py then find it automatically. Power-user alternatives the
resolver also accepts: export SUMBLE_API_KEY=... (this session only) or a
gitignored .env file passed via --env-file path/to/.env.
If the Sumble MCP isn't available in the session, stop and tell the user
how to install it: https://docs.sumble.com/api/mcp.
Shell-command discipline
This runs unattended AND is shipped to non-technical users โ and it may run in
Claude Code, Codex, Cursor, or any other coding agent. They all gate shell
commands behind a command-approval / permission system that interrupts the run
on anything complex (compound, redirected, cd-prefixed, or
substitution-bearing commands โ Claude Code, for instance, blocks "compound
command contains cd with output redirection" and "brace with quote"). Each
interruption stalls the run, in every agent. Keeping commands trivially simple is
the portable way to avoid that everywhere. Follow these rules exactly:
- One simple command per shell call. No
&& / ; / | chains, no cd, no
output redirection (>, >>, 2>&1), no backgrounding (&, nohup), and no
command substitution ($(โฆ) or backticks).
- Use absolute paths, never
cd. Every _build/* script takes its directory
as an argument, so run e.g.
python3 {skill_dir}/template/_build/fetch_data.py --raw {output_root}/_raw
from anywhere โ no cd, no relative _raw.
- Run the pipeline in the foreground. The scripts stream progress to stdout
(the agent shows it live) and finish in a few minutes. NEVER background a step
(
nohup โฆ &) and poll it with ps aux | grep โฆ / tail โ every poll is a
fresh approval prompt, and the redirection trips the guard.
- No inline Python, no heredocs. Any multi-line Python, JSON shaping, counting,
or snapshotting โ write a
.py to {output_root}/_raw/ (with your agent's file
tool) and run it as a single python3 {abs}.py. Never python3 -c "โฆ{โฆ}โฆ" (the
{+" trips the prompt).
- Inspect with your agent's file tools, not the shell. Use the native
file-read / glob / grep tools (ReadยทGlobยทGrep in Claude Code; the equivalents in
Codex / Cursor) โ never
cat / tail / head / ls / wc / grep.
The only shell this skill needs is mkdir -p {abs}, cp {abs} {abs}, and
python3 {abs}/script.py [args] (the _build/* pipeline + Stage 4
python3 app.py) โ each as one standalone command. Everything else is a tool.
Running a command in the user's own terminal. A couple of steps (the API-key
helper, launching app.py) are best run by the user so they're interactive / stay
up. In Claude Code prefix the command with ! to run it in the user's
terminal (e.g. ! python3 {skill_dir}/template/_build/set_api_key.py). In
Codex / Cursor (no ! syntax), just tell the user to paste that same command
(without the !) into a terminal themselves. Always write python3, never bare
python (absent on stock macOS); when Python is unavailable entirely, every
set_api_key.py usage has a shell twin: sh {skill_dir}/template/_build/set_api_key.sh.
Output
account_scoring/{company}/
app.py stdlib http.server (copied from template/, unchanged โ no deps)
account-scoring-weights.json weights + scoring formula + data-source
metadata + derived columns + table layout
+ per-signal p99 + api_supported; the app
opens from this file, and a separate
scoring skill reproduces the score from it
score_sheet.py GENERATOR SCRIPT (not a CSV) โ regenerates score.csv from data.csv + weights on Save/startup (copied from template/, unchanged)
data.csv IMMUTABLE raw API pull (all raw signal columns); the re-score source, never rewritten by the app
score.csv THE one file you use โ a SUPERSET of data.csv: rank โ all data columns โ score โ base_score โ profile_adjustment (+detail) โ per-signal contribution columns โ deep links, sorted by rank (zero-contribution signals drop only their contribution column)
static/ UI: sliders, table, per-row breakdown
README.md
Only two CSVs, with one clear job each (score_sheet.py is the generator
script, not a third CSV):
data.csv โ the immutable raw API pull (every signal's raw count). The
app never rewrites it; it's the source the app re-scores from. You can ignore
it unless you want the untouched raw archive.
score.csv โ the one file you use. A superset of data.csv: it carries
every data column PLUS score, rank, base_score, profile_adjustment
(+ a profile_adjustment_detail column naming each applied boost/penalty,
e.g. digital_native +15%; it_services -35%), one per-signal contribution
column (norm ร effective_weight ร 100, ordered most-impactful-first,
summing exactly to base_score), and deep links (sumble_url + one per
signal) โ sorted by rank. The boost/penalty multipliers are NOT folded into
the contributions; they're explicit:
score = base_score ร profile_adjustment. So you never join score.csv back
to data.csv. The app regenerates it from data.csv + the tuned weights
on every Save and at startup (score_sheet.build_score_sheet); a signal with
0 total contribution drops only its contribution column (its raw column
stays). The Download score sheet button produces the same sheet from the
current sliders, and the per-account breakdown panel shows the same story:
signal contributions โ base score โ each boost/penalty line โ final score.
Two score-sheet non-negotiables (NEVER strip these โ the template enforces
both with fallbacks):
- Deep links, always. Every Sumble count/growth signal carries a
sumble_link spec in the weights config (build_weights.py writes them โ do
NOT drop them if you edit the config by hand). The app's per-row breakdown,
score.csv, the in-app Download score sheet, and score_accounts.py
output all emit per-signal deep links: they prefer the API's canonical
{column}_link from data.csv and fall back to building the URL from the
sumble_link spec, so links appear even when one source is missing.
sumble_url (the org's Sumble page) is always present. Concentration
signals are the one deliberate exception (no per-signal deep link).
- HQ country, always.
headquarters_country is fetched for every org and
score.csv, the in-app download, and score_accounts.py output always
carry the column (blank when unknown). Don't drop it from data.csv.
Before handing over (Stage 4), spot-check the score.csv header with your file
tools: it must contain headquarters_country, sumble_url, and at least one
{signal} link column. If any is missing, the config or data was built wrong โ
fix it before telling the user the app is ready.
Zero-dependency rule: app.py uses only the stdlib (csv, json,
math, http.server) โ no requirements.txt, no third-party imports,
so any teammate can python3 app.py on the first try.
account-scoring-weights.json is the only file the app reads โ the
structural spec (sections/categories/signals/multipliers) AND the current
weights, in the default_pct/default_within fields; Save mutates them
in place. It's also the hand-off to the separate scoring skill, so keep
it self-contained (per-signal source, p99, api_supported). Do NOT
emit a branding block โ template CSS defaults (slate + green) apply.
data.csv column schema
One row per Sumble org_id. Column naming convention:
{signal_slug}_{measure} (snake_case). The same column names are
referenced from account-scoring-weights.json so app.py is generic.
Identity (all rows):
org_id (int, required) โ Sumble organization id (attributes.id), primary key
slug (str) โ attributes.slug
name (str) โ attributes.name
url (str) โ bare website domain (attributes.url; no scheme prefix). The default table_columns shown in the app are Sumble-only: name, url, employee_count_int (+ score). headquarters_country, industry, and the CRM identity columns are NOT shown โ they stay in data.csv for the full export. The app dedups rows to one per Sumble org_id (duplicate CRM records collapse; gold preferred), so the table shows unique Sumble matches. sumble_score is NOT pulled or shown.
headquarters_country (str, ISO-2) โ attributes.headquarters_country
industry (str) โ attributes.industry
employee_count_int (int) โ the org's exact headcount, read from the attributes.employee_count attribute, which the endpoint now returns as an exact integer (e.g. 2615). A legacy band string ("1,001 - 5,000") maps to its midpoint via sumble_v6.emp_band_to_int; missing โ 0.
jobs_count (int) โ org-total job-post count (attributes.jobs_count); firmographic.
teams_count (int) โ org-total team count (attributes.teams_count); firmographic only. It is record-scoped (the matched org alone, not its subsidiaries) so it is NOT used as a concentration denominator โ see the concentration note under "ICP signal columns".
CRM linkage:
crm_account_id (str, nullable) โ internal CRM / Salesforce id, carried through from _raw/sample.csv (Branch A only). Kept in data.csv (NOT shown in the app, since duplicated CRM records would surface the same Sumble org multiple times).
crm_account_name / crm_url (str, nullable) โ the CRM's OWN account name + domain (from _raw/sample.csv), carried through alongside the Sumble-matched name/url so a bad match is visible in the full data.csv export (Branch A only; not shown in the app).
Saving (manual): the app does NOT auto-save. The Save button POSTs the current slider weights + the client-computed score/rank to /api/save-weights; the server writes BOTH account-scoring-weights.json (weights) AND data.csv (adds/updates score + rank columns, one score per unique org_id, full data preserved). Scores come from the client (what you see), not a server recompute, so data.csv matches the app exactly.
The unified endpoint does the matching: each input {name,url} resolves to a
Sumble org and its attributes/entities come back in the same response.
Unmatched inputs come back with empty attributes and are dropped by
merge_data.py.
Account category (always present; blank in pure Branch B):
account_category (str) โ one of customer (closed-won), allocated
(CRM, rep-assigned), unallocated (CRM, no rep), whitespace (high-fit
org NOT in the CRM; label "Whitespace โ new account"), whitespace_subsidiary
(whitespace whose parent IS a CRM account โ land-and-expand; label
"Whitespace โ parent in CRM"; detected from BOTH parent_id and the inverse
direction, membership in a CRM org's subsidiary_ids, since the endpoint
omits parent_id on some rows), or "" when no CRM was provided. The two
whitespace kinds are separate filter chips so net-new accounts and
parent-in-CRM subsidiaries can be viewed independently. Single
source of truth: is_icp_gold = (account_category == "customer") and
in_crm = account_category in {customer, allocated, unallocated}. The app
renders a Category column + filter chips only when โฅ2 distinct
non-blank categories are present, and the Evaluation tab shows a
rank-distribution-by-category table.
Evaluation flag (always present, default false):
is_icp_gold (bool) โ true for closed-won / strong-ICP rows
(account_category == "customer")
Penalty flags (always present, 0/1):
is_it_services โ array_contains(tags, 'it_services') (IT services
shops are typically partners, not customers)
is_professional_services โ array_contains(tags, 'professional_services')
(consultancies, accounting/legal firms, etc.; same partner-not-customer
argument. A native Sumble org tag, exactly like it_services.)
Org attributes (always present, pipe-delimited tags column + 0/1
flags). The template surfaces these in the toolbar as filter chips
and in the tag-multiplier widget, so they need to be in data.csv
even when they're not in multipliers[]. The chips light up via either
a flag column (above) OR membership in the tags column.
tags (str, pipe-delimited) โ the full Sumble tag list for the org,
joined with | (e.g. b2b|digital_native|is_ai_native).
Source: organizations.tags array, verbatim โ professional_services is a
native Sumble org tag (no industry-based synthesis; it arrives like
it_services). Industries are NOT synthesized into tags โ calibration is
purely over the org's Sumble tags. Drives the tag-multiplier picker and the
attribute-chip filters.
is_b2b / is_b2c / is_digital_native / is_ai_native (0/1) โ
convenience flags derived from tags (array_contains(tags, '{slug}')
on the canonical Sumble slugs b2b, b2c, digital_native,
is_ai_native). Optional โ the chip falls back to a tags-membership
check if these aren't emitted. b2b and b2c are independent tags,
not mutually exclusive โ a hybrid org gets both flags set to 1
(e.g. JPMorgan Chase, Google: both consumer-facing AND
business-facing). Treat them as separate facets, not as a binary
choice.
ICP signal columns (one set per ICP element from Stage 2a; example
slugs):
-
Persona count {jf}_people (int); concentration {jf}_pct (0โ100);
YoY % growth {jf}_growth_yoy (float, e.g. 0.20 = +20%).
-
Tech team counts {tech}_teams (int, any tech kind); tech job posts
{tech}_jobs (int); tech hiring concentration {tech}_job_pct (0โ100).
Concentration comes from the endpoint's NATIVE metrics, not from dividing by
an org attribute. {jf}_pct is built on people_concentration (job_function
entities) and {tech}_job_pct on job_post_concentration (advanced_query
entities). Both sides of those ratios are hierarchy rollups, so the scope
mismatch that used to break parent orgs cannot arise: Advance returns 252
Design teams / 645 Design job posts across its subsidiaries against a
record-scoped teams_count of 6, which as a naive ratio read "4200% of teams".
Note this makes tech concentration a share of hiring, not of teams โ
job_post_concentration is the only concentration metric advanced_query
exposes, and it is the same measure as the product's own concentration sort.
Reported as the Wilson 95% lower confidence bound (sumble_v6._share,
CONFIDENCE_Z=1.96) โ the lowest share that could plausibly explain what was
observed. A raw ratio scores 1/1 and 900/900 identically at "100%", so orgs we
know almost nothing about saturate the signal: on a 4,884-row pull 192 orgs
read โฅ99% design share with a MEDIAN denominator of 1. The p99 normaliser
then fits to those and a well-measured org's genuine 7% share normalises to
~0.07 โ the signal ends up ranking how LITTLE data an org has. The bound
shrinks thin evidence toward 0 (1/1 โ 21%, 9/9 โ 70%) while leaving
well-measured orgs essentially alone (266/3630 โ 6.5%, raw 7.3%). Don't
recommend "raise the Concentration slider" without checking the denominator
distribution first.
Denominator recovery is a known WORKAROUND (sumble_v6.recover_total,
mirrored in score_accounts.recover_totals). The concentration metrics return
a ratio only, but the Wilson bound needs the sample size, so the denominator is
inverted out: denominator = count / ratio. The ratio is rounded to 4dp, so
precision degrades as the share shrinks (at a 0.02% share the recovered total
is ~25% off; below ~0.005% the ratio rounds to 0.0 and is unrecoverable).
Every persona shares ONE org-wide denominator and every tech shares ONE, so it
is recovered a single time per org from the LARGEST count available and reused
โ a tech with 500 posts at 0.0250 pins the total to 20,000 ยฑ0.2%. The
recovered totals are kept as people_total_est / jobs_total_est so any share
is auditable against the counts beside it. Delete this once
/v6/organizations exposes hierarchy-scoped counts as attributes
(hp_jobs_count / hp_people_count / hp_teams_count already exist on the
organizations table; flagged to the API team) โ then numerator and
denominator both arrive directly, at full precision.
Buying-window columns (always present) โ one per key project:
{project}_x_relevant_tech_jobposts (Template C5). The relevant tech set
follows the Stage 1 eligibility rules (tech = competitors + complementary
across key+other; projects key only). Project ร persona is intentionally
NOT produced.
Funding columns (only when spec.include_funding is true โ OFF by
default; the Q2 interview asks about it with a venture-backed-bias warning:
funding only covers venture-backed companies, so scoring it artificially boosts
them; see the Stage 1 Q2 funding question. Enable only when the user opts in,
with the bias warning) โ pulled
from the /v6/organizations funding attributes (1 credit each per matched
org). Three scoring signals (all api_supported:true, reproduced by
score_accounts.py):
funding_total_raised (int USD, log) โ "Funding (total raised)"
category in the Size segment โ overall firepower.
funding_last_round_raised (int USD, log) โ "Funding momentum"
category in the Growth & momentum segment โ recent capital.
funding_days_since_last_round (int days, recency transform) โ
"Funding momentum" / Growth & momentum โ recency of the latest round.
Derived from funding_last_round_date (whole days to today, min 1; "" when
never financed โ 0). The recency transform inverts the usual normaliser
(norm = clamp(1 โ log1p(days) / log1p(p99), 0, 1)): fewer days โ higher
score, beyond the p99-of-days โ 0, never-financed โ 0.
funding_last_round_type/_date are also carried as context (not scored).
The Growth & momentum segment always exists (persona growth), so funding
momentum lands there regardless of whether the spec has projects. Set
"include_funding": true in spec.json (Stage 2a) to enable โ again, only
when the user opted in at the Q2 funding question. (These default segment placements move if
spec.section_plan reassigns the funding / funding_momentum categories.)
Non-Sumble (1P) columns โ anything the user can join by account, one
{slug}_{measure} column + one signal each, grouped into categories
(e.g. "Product usage", "Marketing engagement", "Third-party intent").
Inspect the attached MCPs and suggest what's available; null when missing.
Default weights + calibration
Two-step weighting: thoughtful priors, then a regularized fit to gold.
build_weights.py lays down the policy-default weights (the priors).
fit_weights.py (Stage 3) then runs a small, deliberately-conservative solver
that nudges those weights toward separating the gold (closed-won) accounts โ
without overfitting. The design:
- Low DOF. Only the segment blend (Size / Concentration / Growth &
momentum, or whatever segments the spec defines) and the per-segment category
weights are fit. The within-category signal weights (geometric decay) are
FROZEN โ that's where overfitting would otherwise live.
- Shrinkage to the priors. Objective is
AUC(gold) โ ฮปยทโw โ w_defaultโยฒ, so a weight moves only when the gold
evidence overcomes the prior.
- Box bounds. No category weight drifts more than ยฑ20 points, and the
segment blend ยฑ25 points, from its default โ the model stays recognizable.
- K-fold CV picks ฮป on held-out gold via the 1-SE rule over paired
per-fold gains vs the defaults (among ฮปs within one standard error of the
best mean gain, the largest โ most shrinkage โ wins); the optimizer is
derivative-free coordinate ascent (a few sweeps).
- Adopt-only-if-it-generalizes. A paired per-fold test: the fit replaces
the defaults only when the mean held-out AUC gain clears both an absolute
floor (0.002) and one standard error of the per-fold gains โ the bar scales
with the evidence, so a consistent small gain on a large gold set is adopted
while same-size noise on a tiny one is not.
- Small-gold guard. Fewer than ~20 gold rows โ skip the fit, keep priors.
- Speed/balance. The fit ranks gold against a deterministic systematic
subsample of โค5,000 non-gold rows; reported full-sample metrics use all rows.
It's a warm start, not an autopilot: the fitted weights are the app's initial
slider positions, still fully tunable, and the Evaluation tab shows the
gold-lift you're tuning against. Deterministic โ stratified round-robin folds
(no RNG) + a derivative-free optimizer mean same config + same data.csv โ
same fitted weights. Constants live at the top of fit_weights.py.
Encoded as policy constants in template/_build/build_weights.py and
template/_build/merge_data.py โ same spec.json + same endpoint
responses โ same weights. The agent does NOT pick decay constants,
threshold rules, or default percentages per run. See
template/_build/README.md for the constants.
Pipeline
Execute these stages in order. Surface progress between stages.
Stage 1 โ Interview
Goal: collect the input required to produce a first version of the score. Please follow these interview questions very closely and do not go off script. I want a deterministic interview that is the same between different runs of this skill.
Numbering rule: the interview has 5 main questions. Start every
interview message with a progress marker โ **Question N of 5** (sub-steps
as Question 5 of 5 โ part 5.1, etc.) โ so the user always knows where they
are and how much interview remains. When you combine questions into one
message, label it with the range (e.g. **Questions 2โ5 of 5**).
Confirmation rule (applies to EVERY confirm step in this interview): when
you ask the user to confirm something โ the ICP, the segments, the
buying-window combos, the universe filters โ the COMPLETE thing being
confirmed MUST be rendered in full, as markdown, in the SAME message as the
question. Never ask "confirm the proposed ICP/filters?" while the details
live only in an earlier message, in a tool result, or inside multiple-choice
option labels (interactive question widgets truncate labels and descriptions
โ they are NOT a substitute for printing the list). If anything changed since
the last time it was shown, re-print the whole updated list, not a diff.
-
Please ask the company's name and their URL. You can prefill if you know it and ask the user to confirm. Also ask them where they want the account scoring to be stored. Can default to ./tmp/account_scoring/{company} directory.
-
Confirm the ICP. Call GetMyCompanyProfile for the URL and propose
personas + technologies (+ projects), showing both key and other
tiers. (If the lookup fails, propose with an LLM and snap to slugs via
SearchTechnologies / RunSqlQuery on job_functions/projects.)
Resolve slugs and roll up into categories BEFORE you present โ so the
user confirms the FINAL shape, not raw techs that change in Stage 2a.
First snap every proposed tech to its slug (SearchTechnologies), then run
the category roll-up (the procedure + SQL in Stage 2a โ "Roll individual
techs up into predefined categories"), then the grouping pass (Stage 2a โ
"Group every tech"). Present the ICP with categories in place of their
absorbed individual techs (showing each category's coverage% and the techs it
absorbs). This is just early invocation of the Stage 2a logic; Stage 2a then
only persists the already-confirmed result to spec.json.
Present GROUPS, not a pile of individual techs. Every proposed tech
should arrive inside a predefined or synthetic category; a standalone tech is
the exception you justify, not the default (Stage 2a โ "Group every tech"
has the ordering). Sparse niche techs scored one-by-one mostly read 0 and
double-count the same team across signals.
When no predefined category fits, author a SYNTHETIC one (Stage 2a โ
"Synthetic categories") โ an ICP-specific grouping scored as one deduped
team_count. This is the common case for niche ICPs, where the
competitor/practitioner tools are individually sparse and Sumble has no
category for them. A synthetic category can absorb a whole predefined
category (member_categories) and extend it with extra slugs (members) โ
the right move when a predefined category is close but incomplete. It may
also pull in relevant techs the original ICP list never mentioned (that's
the point โ you're defining the capability, not just regrouping) โ so
discover them first (SearchTechnologies / a technologies query for
mention volume), then obey the guardrails in Stage 2a: every member
defensibly ICP, no dominating member, each member in exactly one category,
syn- slug prefix. Print the FULL membership with per-member mention
volume in the confirmation message โ the user approves membership before a
single credit is spent.
Present as ONE compact markdown summary + a single yes/edit prompt
(no multi-selects, no per-category questions); loop on edits until
accepted. The summary must be COMPLETE โ every element of the ICP the
pipeline will fetch, with nothing elided: all personas with key/other
tiers; every predefined technology category with its coverage% and the full
list of absorbed techs; every synthetic category with its COMPLETE
membership (absorbed categories AND member slugs); every standalone tech
with its tier and why it was left standalone; every project; and
whether funding attributes are scored (Funding: on/off, per the funding
question below). Render it in the message itself (see the Confirmation rule above
โ never rely on question-widget option labels to carry the list). Example:
Proposed ICP for {company}:
โข Personas (key): Sales, RevOps ยท (other): Marketing
โข Technology categories (predefined): Inference & Serving (31% โ absorbs
vllm, baseten, modal, replicate)
โข Technology categories (synthetic): GTM Data Enrichment (key) โ absorbs
category Sales Intelligence, plus clay (12K), common-room (3K),
apollo-io (9K)
โข Technologies (standalone): acme-cloud (key) โ our own product, kept
separate so you can weight existing-usage accounts on their own
โข Projects: Generative AI, Digital Transformation
Reply "yes", or describe changes (e.g. "drop marketing, add SDR",
"split out vllm from Inference & Serving", "drop zoominfo from GTM Data
Enrichment โ it dominates the category").
Also confirm the buying-window combinations
โ does each KEY project ร the relevant tech set signal intent-to-buy-now?
Show them as project AND (techโ OR techโ โฆ). Eligibility for the
relevant set:
- Projects:
tier: key only (the trigger).
- Technologies: modern + legacy competitors + complementary,
across
tier: key AND other.
(Project ร persona is intentionally NOT produced โ only project ร tech.)
If no project genuinely signals intent, skip this sub-step.
Also ask whether to include FUNDING signals (part of confirming the ICP).
Ask ONE yes/no: should funding attributes โ total raised (Size), latest-round
size + recency (Growth & momentum) โ be scored? Always pair the question
with the bias warning, in the message itself: funding data only exists for
venture-backed companies (everyone else reads 0), so including it creates an
implicit bias toward VC-backed companies. It can be reasonable when the
universe is overwhelmingly venture-backed (e.g. an AI-native / startup ICP);
otherwise leave it off. Default OFF โ set spec.include_funding: true
only if the user opts in, and reflect the choice in the ICP summary
(Funding: on/off).
-
Internal (1P) data โ what can they bring? (optional, any mode). Ask ONE
question: beyond Sumble's data, is there internal data they'd like folded
into the score? Give concrete examples so the user recognizes what
qualifies:
- Product / PLG adoption โ self-serve signups, free-tier teams, active
seats/workspaces at an account.
- Marketing engagement โ attended events or webinars, campaign
responses.
- Website activity โ visited the website / pricing page (de-anonymized
visits).
- Anything else joinable by account (name/domain): meeting activity,
third-party intent, support tickets, โฆ
Each source becomes one
{slug}_{measure} column + one signal (null when
missing), grouped into categories (e.g. "Product usage", "Marketing
engagement", "Third-party intent"). Inspect the session's MCPs and surface
relevant ones by name as candidate sources; else ask for a CSV path. At
this point only capture WHAT to include and WHERE it lives โ the
source-confirmation flow + sample reads run later with the other pulls
(Q5 / Stage 2b). The answer feeds Q4: each 1P category is woven into an
existing segment or gets a segment of its own.
-
Segments (the score's top-level breakdown). The score is a weighted
blend of segments (the config's sections). The default is three
orthogonal segments โ don't re-derive them, just propose them and let the
user adjust:
- Size (50%) โ how big is the opportunity: persona headcount, tech
team counts, recent projectรtech job posts.
- Growth & momentum (30%) โ is now the time: persona YoY growth.
- Concentration (20%) โ how strong / focused the fit: persona
concentration (% of company) and tech-team concentration (% of teams).
These three are the default. If Q3 surfaced 1P data, propose where it
lands in the SAME message โ woven into an existing segment or, when the
data is substantial (e.g. product usage), as an ADDITIONAL segment (e.g.
Product usage, Marketing engagement) with a starting weight carved
out of the blend; present the adjusted blend as the proposal, not as an
afterthought.
Funding is decided in Q2, not here. Whether to score funding attributes
โ with the venture-backed-bias warning โ is asked during the ICP
confirmation (Q2, alongside the buying windows). Don't re-ask it here; just
honor the user's Q2 choice (spec.include_funding) and let the funding
categories land in their default segments (total raised โ Size; latest-round
size + recency โ Growth & momentum). Rationale for the warning: funding data
only exists for venture-backed companies โ everyone else reads 0 โ so scoring
it creates an implicit bias toward VC-backed companies (the partial-coverage
principle in articles/01); it's reasonable only when the universe is
overwhelmingly venture-backed.
Present the proposed blend with one yes/edit prompt. The user can
rename, reweight, drop, or add segments, and may repeat a signal in
more than one segment (e.g. projectรtech under both Size and Concentration).
For inspiration, offer a business-segment breakdown when the company
sells distinct product lines โ e.g. for Oracle a segment scoring OCI fit and
a separate segment scoring Apps fit, each with its own techs/personas.
Encode the user's choices in spec.section_plan (schema in
template/_build/README.md): {sections:[{key,label,default_pct}], category_section:{ {category}:{segment} }, category_meta:{...}}. Omit
section_plan to take the three defaults verbatim. Don't write any
objective field. (The buying-window confirmation still happens in Q2.)
-
Objective โ score, whitespace, or both? Ask this FIRST; it drives the
rest. Record as spec.mode:
- (A) Score CRM accounts (
score) โ rank the accounts already in their
CRM.
- (B) Whitespace (
whitespace) โ rank high-ICP-fit orgs NOT in their
CRM.
- (C) Both (
both) โ score the CRM and surface whitespace, in one sheet.
All three build the same app โ one ranked sheet with the
account-category column + chips and the Evaluation tab; they differ only in
which accounts fill the pool. This is the consolidated home for scoring AND
whitespace.
Inputs โ ask only what the objective needs. Up to three things, in
either shape (one table with flag columns, OR separate lists โ leave both
open). Actively encourage the user to share all three of (a), (b), and (c)
whenever the objective scores the CRM (modes A/C) โ they are complementary,
not redundant: (a) is the overall universe, and it's the combination of (a)
and (b) that lets the app distinguish unallocated accounts (in the
universe but not rep-assigned) from allocated ones. Without (a) you lose
the universe denominator; without (b) every CRM account collapses to one
bucket and the unallocated view disappears. Pitch all three together up front
rather than settling for whatever the user volunteers first.
- (a) Whole CRM universe โ every account in their CRM. The scored set in
A/C; in B/C used to exclude owned accounts from the whitespace pool
(optional in B โ without it, whitespace is just an ICP ranking of Sumble's
universe). The overall universe โ the denominator that (b) is subtracted
from to reveal unallocated accounts.
- (b) Rep-allocated accounts โ assigned to a rep / owner (e.g.
Owner.Name/hubspot_owner_id present, is_owned). Only relevant when
scoring the CRM (A/C); flags allocated vs unallocated. Pair it with
(a) so the app can surface which universe accounts are unallocated โ
high-fit accounts nobody is working yet.
- (c) Closed-won / customers โ closed-won flag (
IsCustomer__c,
lifecyclestage='customer', a closed-won Opportunity). Strongly
encouraged in ALL modes. When asking for this input, explain it in
plain language โ internally it's called the "gold set", which confuses
users, so spell out what it is and how it's used. Say something like:
"Your existing customers act as the answer key for the score.
They're used in three ways: (1) the Evaluation tab checks that
known customers rank near the top โ if they don't, the weights are
wrong; (2) the weight fit nudges the starting sliders toward
whatever separates your customers from everyone else; (3) the
industry/attribute boosts are calibrated from which segments your
customers concentrate in. Customers are never filtered out or treated
as prospects โ they're the measuring stick."
In B (whitespace-only) they are also scored and shown as customer rows
alongside whitespace so the user can confirm known-good accounts still
rank highly. Avoid the bare term "gold set" in user-facing messages;
if you use it, define it inline ("gold set = your closed-won customers,
the known-good examples the score is checked against").
Resolve overlaps so each account lands in exactly one account_category,
precedence customer > allocated > unallocated:
customer โ closed-won (also sets is_icp_gold)
allocated โ in CRM, rep-assigned, not closed-won
unallocated โ in CRM, not rep-assigned, not closed-won (surfaced so the
team can spot accounts that should be allocated)
whitespace โ high-fit org NOT in the CRM (modes B/C)
If they have NO lists at all (and don't want whitespace): score Sumble's
universe (--mode filter, Stage 2c) โ no categories, no gold, no
calibration, account_category left blank (the app hides the category column
- chips). Closed-won (c) is the one input worth pushing for in any mode โ
without it the Evaluation tab can't check ICP recovery.
Source-confirmation flow (run before reading anything) โ never
assume. Inspect the session's MCPs and surface relevant ones by name
(Salesforce, HubSpot, Snowflake/BigQuery/Databricks/Postgres,
Sheets/Drive, Gong/Fireflies/Granola); else ask for a CSV path. Then:
list tables โ hypothesise the source (universe โ SF Account /
HubSpot companies / warehouse dim_accounts; allocated โ a non-null
Account.OwnerId / Owner.Name / hubspot_owner_id / account_owner;
closed-won โ Account IsCustomer__c=true or closed-won
Opportunity.AccountId / HubSpot lifecyclestage='customer' / warehouse
customers) โ confirm in one prompt โ sample-read LIMIT 10 and verify
join keys (name, URL/domain) before the full pull. Don't ask about
IT-services / Professional-services penalties separately โ those are
tags handled by the same lift calibration as the other attributes
(Step 5a).
5.1 โ How big is the scored CRM universe? (modes A/C only.) We score the
set the user chooses here; a larger book can be scored later by
score_accounts.py.
- < 100,000 accounts โ score them all (the recommended default; the
sample = the whole provided universe). Credits are rarely the binding
constraint and a full pass keeps the app + calibration complete.
- โฅ 100,000 accounts โ ask which to score (a credit / time tradeoff):
- All accounts in CRM โ most complete, costs the most credits and
runs longest.
- Only rep-allocated accounts (b) โ the accounts the team works.
- A subset they specify โ they tell us how to narrow it (a
segment, a saved view, a filter).
- A stratified sample we draw (recommended at this scale) โ target
~30% closed-won, ~40% rep-allocated, ~30% unallocated, sized to
50,000. A representative non-gold distribution + steadier p99 at
bounded credit cost. If a stratum's pool is smaller than its target,
take all of it and let the others fill the remainder.
Surface the credit cost of the chosen size upfront ((1 + paid-attrs + ฮฃ entity-metrics)/org). Tell the user the full CRM gets scored later by
score_accounts.py โ no bulk MCP run.
5.2 โ Whitespace pool size (modes B/C). Ask how many net-new
candidates to rank โ recommend 10,000 (default). Surface the credit cost
first: candidate ranking is FREE (id/name/url selects cost no credits); only
the final pool is enriched, so cost โ pool ร (1 + paid-attrs + ฮฃ entity-metrics)/org. We resolve the whole CRM universe (a) to Sumble org_ids
and exclude them, then preselect ICP-fit candidates; they get
account_category = whitespace in the same sheet, filterable.
Preselection is stratified (spec.universe_filters.preselect,
default auto): auto uses the caller's Sumble account score when their
domain has one, else a stratified pool that avoids size-bias โ 20% by key
technology job posts, 20% by key project job posts, 20% by key persona
concentration (size-neutral density), 40% by fastest-growing key
personas (split evenly across them). The persona strata use the v6 endpoint's
per-job-function sorts โ order_by_column: people_concentration and
people_count_growth_1y with order_by_job_function: {persona} โ so they
rank by the persona's true share / YoY people growth, not org-total proxies.
Each stratum is gated on a min-employee floor (min_employees, default 50)
and deduped against the CRM and prior strata. A candidate whose parent is a
CRM account is land-and-expand: merge_data.py relabels it
account_category = whitespace_subsidiary (label "Whitespace โ parent in
CRM"; plain whitespace is "Whitespace โ new account") โ not a separate
tab, just another category chip in the one sheet, separate from the new-
account chip so the user can filter either kind in or out independently. In whitespace-only (B),
the scored sample.csv holds just the closed-won customers (calibration +
eval) โ no CRM scored set.
Universe filters (modes B/C) โ spec.universe_filters. Set the bounds on
the whitespace pool (whitespace rows only โ your CRM accounts are never
filtered). Before asking the user to confirm, ALWAYS print the complete
proposed filter block in the message โ min_employees, the country
whitelist (or "global"), every hard-exclude tag by name, the
professional-services switch, and every excluded industry by display name.
This applies even when reusing a prior run's filters ("same as last time"
is not a confirmation โ re-list them). Two kinds โ handle them differently:
-
Cheap, near-universal org-type / firmographic bounds (the ranker pushes
these into the query, so they cost nothing): min_employees (default
50); hq_country_whitelist (default none โ global); hard_exclude_tags
โ the four org-type tags org_type_k12_school, org_type_university,
org_type_hospital, org_type_government are sensible proposed defaults
for B2B (offer them, let the user keep/drop any), plus it_services as a
common extra. (Steer users away from hard-excluding b2b/b2c โ many good
accounts are hybrid; calibrate those instead.)
ALWAYS ask about PE firms explicitly: should private-equity firms be
hard-excluded from the whitespace pool (is_private_equity_firm in
hard_exclude_tags)? PE/buyout firms often rank high on tech-hiring
signals via their portfolio yet are rarely a sellable account themselves โ
recommend excluding unless the user actually sells to PE firms (fintech,
fund-admin, deal tooling). Their portfolio companies are separate orgs and
stay rankable either way.
-
Industry HARD EXCLUDEs โ DERIVED PER COMPANY, never a fixed list. Do
NOT exclude the same industries every run. Instead suggest the
industries to exclude from the BLEND of:
- the gold / customer set โ industries with zero (or near-zero)
customer presence (read the
industries block of
_raw/_calibration_audit.json, or reason over the customer list before
the run), and
- your knowledge of the company (+
GetMyCompanyProfile / its website /
parallel-search) โ verticals it obviously can't or won't sell to
(e.g. a US-only SaaS โ exclude Defense, Government, Miningโฆ).
Present the tailored list, get the user's confirmation, and write the
display names to universe_filters.exclude_industries
(e.g. ["Defense & Space", "Mining & Metals"]); merge_data.py drops those
rows. NOTE: the endpoint can't filter industries in the rank query, so
excluded-industry orgs are enriched then dropped (a small credit cost) โ
alternatively, lean on the auto-applied industry penalties (Step 5a) to
sink them instead of excluding, and tell the user which you chose.
professional_services is a native org TAG, so the preferred way to
exclude it is via hard_exclude_tags (free, rank-time); the legacy
exclude_professional_services_industry: true switch still works
(tag-based, applied at merge).
Free preview (optional). Before spending credits on a 10K pool, you can
show the user who'd be in it: fetch_data.py โฆ --whitespace 10000 --rank-only
does the FREE rank and writes _raw/_preselection.json (candidate names +
stratum composition), no enrichment. Eyeball it, adjust the ICP / filters,
then run the real fetch.
5.3 โ Confirm the 1P sources from Q3 (only if any). For each internal
source the user named in Q3 (product/PLG usage, marketing engagement,
website activity, โฆ), run the same source-confirmation flow per source
before reading (one {slug}_{measure} column each).
The closed-won set (c) drives Step 5(a) tag-lift calibration โ
boost/penalty defaults for the six org-tag attributes (b2b, b2c,
digital_native, is_ai_native, it_services, professional_services).
Calibration is purely over the org's Sumble tags (professional_services
is one of them, natively); whole industries are not synthesized into
industry__{slug} tags or calibrated. These are applied by default
(written to tag_multipliers, not just suggested) โ the app opens with
them active and tunable. The audit lands in
_raw/_calibration_audit.json (attrs).
Agent's role โ blend the gold-lift data with knowledge of the world.
The gold set is often small, so don't rely on it alone. Combine the
_calibration_audit.json lifts with what you can learn about the company from
the MCP servers + the wider world:
GetMyCompanyProfile (already pulled in Q2) and Sumble GetCompanyProfile
โ what the company sells and to whom.
- the company's website / URL and
parallel-search web_search โ research
their actual customers, target verticals, and obvious non-buyers.
- your own world knowledge of the business and its industry.
Use these to propose industry/attribute boosts or penalties the gold set is
too small (or too biased) to surface โ e.g. "you clearly don't sell to
Defense/Government โ penalize"; "Fintech is a core vertical they name on their
site โ boost". Where the data and world-knowledge AGREE, apply with
confidence; where the gold set is thin, lean on world knowledge; where they
CONFLICT (data says penalize an industry the company actively targets),
surface the conflict to the user rather than silently trusting the small
sample. Apply confirmed org-tag boosts/penalties to
account-scoring-weights.json.tag_multipliers
({tag: "{attr}", pct, direction}). Industries are not calibrated as
tags, so act on industry decisions via universe_filters.exclude_industries
(drop them) instead โ professional_services needs no special handling:
it's a native org tag, calibrated and excludable like any other tag.
Whitespace hard-exclude recommendations (modes B/C). Suggest excludes
per company, never a fixed list โ see Q5.2's "Universe filters" for the
full mechanics. In short: org-type tags (org_type_*, it_services) go in
hard_exclude_tags (cheap, rank-time) โ and always ask the PE-firm
question (is_private_equity_firm, see Q5.2) โ while industries are
DERIVED from
the gold-lift audit (zero/near-zero-customer industries) + world/MCP knowledge
of the company and written to universe_filters.exclude_industries (display
names), confirmed with the user. Industry excludes drop post-fetch (no rank
filter), so either accept the small credit cost or lean on the auto-applied
industry penalties โ tell the user which you did.
Per-tag multiplier widget is in the template (up/down-weight any tag
at scoring time) โ surface it in the app; don't enumerate tags in the
interview.
Stage 2 โ Fetch data from the unified endpoint
No SQL signal pulls. Every column comes from one REST call,
POST https://api.sumble.com/v6/organizations (match + enrich + select). The
_build/ scripts build the request from spec.json, POST it, and merge the
JSON into data.csv. Confirm SUMBLE_API_KEY is set first.
Stage 2a โ Resolve ICP slugs/names (the only "ID resolution" step)
Resolve every ICP term to its canonical Sumble slug/name in ONE call to the
lookup helper (it uses the v6 lookup endpoints; needs SUMBLE_API_KEY):
python3 {skill_dir}/template/_build/lookup.py --technologies clay,common-room,zoominfo --projects "generative ai,digital transformation" --titles "Machine Learning,AI Engineer,Revenue Operations"
It prints {technologies, projects, job_functions}, each item {input, slug, name} (technologies also list their categories). Use:
- Technologies โ
slug. Keep the user's input string + resolved slug; show
both back before fetching. (SearchTechnologies is still handy for fuzzy
discovery when you don't yet have a term.)
- Job functions โ the endpoint's
job_function term is the display
name (e.g. Revenue Operations, Sales). --titles maps each
job-function name to its canonical function.
- Projects โ
slug.
Parent / aggregate personas. A persona name may be a parent job function
(e.g. Sales over its SDR / AE children, or Sales, Marketing, Customer Support & Business Development over Sales + Marketing). The /v6/organizations endpoint
aggregates the whole subtree โ people_count, growth, and concentration for
a parent return the summed figures across every descendant function โ and
lookup.py resolves a parent term straight to its node, so a parent persona
needs no kind: "category" flag (unlike techs).
GetMyCompanyProfile still pre-fills the ICP; lookup.py just canonicalises
the names. The only remaining RunSqlQuery is the tech-category roll-up below.
Roll individual techs up into predefined categories
This procedure is invoked during Q2 (ICP confirmation), before the user
confirms โ see Stage 1 Q2. It lives here for the mechanics; by Stage 2a the
roll-up is already confirmed and you only persist it to spec.json. Re-run it
here only if the ICP techs changed after Q2.
Before finalizing the tech list, check whether groups of the ICP techs are
better expressed as a single predefined Sumble technology category (one
deduped team_count per category, kind: "category" in spec.techs). Fewer,
less-sparse signals that match how Sumble already groups the stack โ e.g. AWS
Redshift + BigQuery + Snowflake โ the Cloud Data Warehouse category.
A category signal counts the WHOLE category, not just the absorbed ICP
techs. When you adopt {"slug": {cat}, "kind": "category"}, the enrichment
(technology_category + granularity: aggregate) counts teams using ANY
technology in that predefined Sumble category โ including techs that were never
in the ICP. This is the tradeoff icp_coverage_pct quantifies (below): it is
the share of the category's signal that comes from the ICP techs, so
100% โ coverage is how much extra, non-ICP breadth you take on by rolling up.
Make this explicit to the user at Q2 confirmation, and prefer individual techs
when coverage is low (the category would mostly measure things outside the ICP).
Run ONE RunSqlQuery with the resolved ICP tech slugs filled into icp:
WITH icp(tech_slug) AS (VALUES ('aws-redshift'),('bigquery'),('snowflake') )
SELECT tc.slug AS category, tc.name,
ROUND(100.0 * SUM(t.total_mentions)
FILTER (WHERE tc.tech_slug IN (SELECT tech_slug FROM icp))
/ NULLIF(SUM(t.total_mentions), 0), 1) AS icp_coverage_pct,
STRING_AGG(CASE WHEN tc.tech_slug IN (SELECT tech_slug FROM icp)
THEN tc.tech_slug END, ', ') AS icp_techs_in_cat
FROM technology_categories tc
JOIN technologies t ON t.slug = tc.tech_slug
GROUP BY 1, 2
HAVING SUM(t.total_mentions)
FILTER (WHERE tc.tech_slug IN (SELECT tech_slug FROM icp)) > 0
AND icp_coverage_pct >= 2.0
ORDER BY icp_coverage_pct DESC;
icp_coverage_pct = share of that category's total job-post mentions accounted
for by the ICP's own techs. High coverage = the category is a faithful proxy
(rolling them up barely broadens the signal); low coverage = the category is far
broader than the ICP, so prefer the individual techs. Apply the rules:
- Drop any category with
icp_coverage_pct < 2% (HAVING already does this).
- Techs sit in overlapping categories. Walk categories highest coverage
first; let each absorb its ICP techs not already taken by a higher-coverage
category (no double-counting).
- Suggest a category (replace its absorbed individual techs with one
{"slug": {cat}, "kind": "category"} entry) only when it absorbs โฅ2 ICP
techs.
- Then sweep up whatever is left โ do NOT stop here with a pile of orphan
individual techs. Unabsorbed techs are the input to the grouping pass
below, not the output. Run "Group every tech" before presenting.
- Present the proposed rollups as part of the Q2 ICP confirmation (category,
coverage%, the techs it absorbs) and loop on the user's edits โ the user
confirms categories, not the raw pre-rollup techs.
Validate each suggested category slug exists in technology_categories. The
pipeline handles kind: "category" end to end โ the endpoint enrichment
(technology_category + granularity: aggregate), the whitespace ranking
(technology_category IN), the score signal, and score_accounts.py.
Synthetic categories โ when no predefined category fits
Sumble's predefined categories are general-purpose; a specific ICP often has no
category that expresses it (there is no "Offensive Security Tooling" category,
no "Autonomous Pentest Competitors" category). When the roll-up above yields
nothing usable โ every candidate is below the 2% bar, absorbs <2 ICP techs, or
is far broader than the ICP โ author a synthetic category instead:
{"slug": "syn-offensive-security", "label": "Offensive Security Tooling",
"tier": "key", "kind": "synthetic",
"members": ["burp-suite", "metasploit", "nmap", "kali-linux"]}
It is sent as an advanced_query entity, technology IN (members), read as
team_count โ the SAME counter the endpoint uses for a predefined category
aggregate, so a team using three members still counts once. (Verified: for
Deloitte the four members above sum to 187 team-counts individually but the
synthetic category returns 79.)
Why reach for one. Individual competitor/niche techs are sparse โ most orgs
score 0 on each, so N sparse signals mostly contribute noise and, worse,
double-count the same team across members. One synthetic category is denser,
deduped, and reads as the capability you actually care about.
Synthetic vs predefined โ the tradeoff flips. A predefined category counts
the whole category including techs never in the ICP, which is what
icp_coverage_pct measures. A synthetic category is 100% ICP by
construction (you authored every member), so there is no coverage leakage โ
but the breadth judgment moves from Sumble to you. That shifts the risk rather
than removing it, so:
- Every member must be defensibly ICP on its own. If you would not have put
the tech in the ICP individually, it does not belong in the category.
- Watch for a dominating member โ then ask WHY it dominates. The signal is a
deduped union, so a member far more common than the rest largely determines
it. Pull
total_mentions per member; when the largest is โณ10ร the median,
stop and judge:
- Conceptually broader than the category โ drop it. It drags the signal
onto a different population. Putting
sonarqube (~123K) in a pentest
category alongside pentesting (~1K) makes the signal track code-quality
tooling; wireshark (~83K) makes it track network engineering.
- Core to the category โ keep it, and say so.
burp-suite (~26K) towers
over pentesting (~1K) but IS the definitive pentest tool โ the ratio just
reflects how practitioners talk. Note in the confirmation that the category
will read roughly as "burp-suite plus a margin", so the user is not
surprised when it correlates with that one tech.
Volume ratio is the flag; conceptual breadth is the test.
- Reject ambiguous slugs. A synthetic category is a bare
technology IN
list with no disambiguation, so a slug whose name collides with something
outside the ICP silently imports the wrong orgs. Check every member's name in
isolation and drop the ambiguous ones โ e.g. hydra (pentest brute-forcer,
but also Meta's Python config framework), nuclei, responder, sliver,
intruder, cobalt (the PtaaS vendor vs. the metal/cobalt-strike). Prefer
the unambiguous long name when one exists (cobalt-strike over cobalt).
- Assign each member to exactly ONE synthetic category โ same
no-double-counting discipline as the predefined roll-up. Overlapping
categories double-weight the same teams.
- Aim for 3โ15 members. Fewer than 3 is just individual techs with extra
indirection; a very long tail adds breadth you cannot defend member by member.
- Prefix the slug
syn- so it cannot collide with a real technology slug
(the slug names the {slug}_teams / {slug}_jobs / {slug}_job_pct columns).
- Show the FULL member list at Q2 confirmation โ the Confirmation rule
applies: print every member of every synthetic category, with each member's
mention volume, and let the user add/remove before anything is fetched. The
member list is also written into the config's
source.why, so the score stays
auditable afterwards.
Group every tech โ a standalone individual tech is the LAST resort
Default to grouping. Do not present a list of orphan individual techs. A
lone niche tech is a sparse signal: most orgs read 0, so it contributes mostly
noise, it burns a slider on something that rarely moves, and several such techs
silently double-count the same team across signals. Grouping fixes all three at
once. After the predefined roll-up, take every still-ungrouped tech and resolve
it in this order โ stop at the first that applies:
- Fold it into a predefined category you already selected, if it is a
member. Free, maintained by Sumble, no judgment required.
- Extend a selected predefined category with a synthetic one. When the
category is right but incomplete โ it covers four of your competitors and
misses three โ author a synthetic category that absorbs the whole category
via
member_categories and adds the missing slugs via members. This is
strictly better than hand-expanding the category's members: the signal stays
in sync when Sumble adds a technology to that category.
{"slug": "syn-gen-image", "label": "Generative Image Generation (extended)",
"tier": "key", "kind": "synthetic",
"member_categories": ["generative-ai-tools"],
"members": ["ideogram-ai", "black-forest-labs", "comfyui", "krea-ai"]}
- Author a new synthetic category grouping the leftovers by the capability
they represent. Actively recruit additional techs that belong to that
capability even if the ICP never named them โ you are defining a capability,
not just regrouping a list. Discover them with
SearchTechnologies or a
technologies mention-volume query, then apply the guardrails above.
- Leave it standalone only when it genuinely stands alone โ a tech that is
both central to the ICP and not a member of any defensible group. Your own
product is the usual legitimate case (e.g.
ideogram-ai for Ideogram):
accounts already mentioning you are a distinct signal you may want to see and
weight on its own rather than blended into a competitor set. When you leave a
tech standalone, say why in the confirmation message so the user can
overrule it.
Two grouping errors to avoid. Don't force unlike things together to satisfy
the rule โ a group must name a capability a buyer would recognize, not "the
remaining techs". And don't group away a signal the user wants to steer on:
if a tech is one they will plausibly want to up- or down-weight by itself, ask
before absorbing it.
Prefer a predefined category when one genuinely fits (it is maintained by Sumble
and needs no judgment); reach for synthetic when none does, or when a predefined
one is close but incomplete. All three kinds can coexist in one spec.
The pipeline handles kind: "synthetic" end to end โ enrichment
(advanced_query), the whitespace ranking and the projectรtech buying-window
query (members expanded into the technology IN list, member_categories
into the technology_category IN list), the /teams deep link (one OR group
over both), and score_accounts.py.
Write _raw/spec.json (schema: template/_build/README.md). Personas carry
{slug, name, tier, label} (name is the endpoint term), techs
{slug, label, tier[, kind:"category"|"synthetic", members:[โฆ], member_categories:[โฆ]]}, projects
{slug,โฆ}. Show the resolved set back to the user before fetching.
Stage 2b โ Write the input list
Both sample.csv and crm.csv are optional; write whichever the objective
needs. sample.csv is the scored sample; crm.csv is the exclusion list.
_raw/sample.csv (crm_account_id,name,domain,is_gold,is_owned) โ the
scored sample. merge_data.py derives account_category from
is_gold/is_owned (customer > allocated > unallocated).
- Score / Both (A/C): the scored CRM set sized + composed per Q5.1
(everything if โค5K; otherwise the user's choice โ all / allocated / subset /
stratified sample).
is_gold=1 closed-won, is_owned=1 rep-allocated.
- Whitespace-only (B): just the closed-won customers (
is_gold=1), for
calibration + eval. Omit entirely if they have no closed-won list.
_raw/crm.csv (name,domain) โ the whole CRM universe (a), written
in modes B/C to exclude owned org_ids from the whitespace pool. Optional even
in B: without it --whitespace ranks Sumble's universe with nothing excluded.
- No lists at all: skip both โ Stage 2c runs
--mode filter (the endpoint
ranks Sumble's universe by an ICP advanced query). Put any audience filters in
spec.json.universe_filters.
The endpoint matches by name/url โ no separate MatchOrganizations step.
Stage 2c โ Fetch + merge
Run each as ONE command with absolute paths โ no cd, no chaining (see
Shell-command discipline):
python3 {skill_dir}/template/_build/fetch_data.py --raw {output_root}/_raw
python3 {skill_dir}/template/_build/fetch_data.py --raw {output_root}/_raw --whitespace 10000
python3 {skill_dir}/template/_build/fetch_data.py --raw {output_root}/_raw --whitespace 10000
python3 {skill_dir}/template/_build/fetch_data.py --raw {output_root}/_raw --mode filter --pool 1000
python3 {skill_dir}/template/_build/merge_data.py --raw {output_root}/_raw
fetch_data.py POSTs to /v6/organizations in โค100-org batches (or paginated
filter pages) โ any batch that still 500s or truncates after retries is
adaptively halved (down to a 20-org floor) so one bad batch can't sink the run โ
saving _raw/responses/resp_*.json and a per-org
_raw/fetch_index.json carrying account_category; with --whitespace it also
resolves crm.csv โ crm_matches.json and appends enriched whitespace
candidates. merge_data.py parses them into data.csv (account_category +
is_icp_gold set) + _raw/_calibration_audit.json (tag-lift, auto-computed).
Same spec.json + same responses โ byte-identical output; policy constants
live in the scripts.
Credit cost โ (1 + paid-attributes + ฮฃ entity-metrics) per matched org
(~16 for a typical ICP). Surface the estimate before running a large sample.
Timing โ enrichment runs as blocking โค100-org POST batches, so wall-clock
scales with the org count. Budget a broad ~1โ2 minutes per 1,000 enriched
orgs (a typical CRM + 10k-whitespace pull is ~10โ20 minutes). Give the user
this rough time estimate alongside the credit estimate before any bulk
enrichment run, so a multi-minute fetch isn't a surprise โ and run it in the
background, surfacing progress between stages rather than blocking.
If the key isn't set yet, run ! python3 {skill_dir}/template/_build/set_api_key.py
(no Python? ! sh {skill_dir}/template/_build/set_api_key.sh does the same)
first (prompts + saves it), or run the two python commands themselves via
! python โฆ so they execute in the user's terminal.
Stage 3 โ Generate the app
mkdir -p {output_root}/static
cp {skill_dir}/template/app.py {output_root}/app.py
cp {skill_dir}/template/score_sheet.py {output_root}/score_sheet.py
cp {skill_dir}/template/score_accounts.py {output_root}/score_accounts.py
cp {skill_dir}/template/README.md {output_root}/README.md
cp {skill_dir}/template/static/* {output_root}/static/
cp {skill_dir}/template/Dockerfile {output_root}/Dockerfile
cp {skill_dir}/template/.dockerignore {output_root}/.dockerignore
python3 {skill_dir}/template/_build/build_weights.py --raw {output_root}/_raw
python3 {skill_dir}/template/_build/fit_weights.py --raw {output_root}/_raw
โ {output_root}/account-scoring-weights.json. build_weights.py renders
the spec + audit into the live config with the policy-default weights;
fit_weights.py then nudges those weights toward the gold set (see "Default
weights + calibration" below) and rewrites the config in place. Surface the
_raw/_weight_fit_report.json summary to the user (default vs fitted held-out
AUC, and whether the fit was adopted) so they know how the sliders were set.
The Dockerfile + .dockerignore are copied in so any demo is
fly-deploy-ready (/fly-deploy) with no extra steps โ they're inert for
demos you only run locally. The app's HTTP Basic Auth is env-gated: it
activates only when BASIC_AUTH_PASS is set (a deploy secret), so
python3 app.py stays open with no login locally.
Write {output_root}/.gitignore via Write:
__pycache__/
*.csv
Stage 4 โ Run instructions
Print this to the user. Don't try to run the server inside Claude Code โ
let the user start it in a terminal where it stays up.
cd ./tmp/account_scoring/{company}
python3 app.py
No pip install, no virtualenv, no extra setup โ app.py is stdlib-only
and runs on any Python 3.10+. Override the port via python3 app.py 9001
or PORT=9001 python3 app.py.
To score a larger list once weights are tuned, hand
account-scoring-weights.json to the separate scoring skill.
Stage 5 โ Whitespace filtering (optional; ALWAYS offer it after handover)
Right after printing the Stage 4 run instructions (modes B/C only โ skip when
there is no whitespace), follow up with ONE question about filtering the
whitespace down. Lead by encouraging the user to LOOK FIRST: open the app,
eyeball the top whitespace (filter chips โ Whitespace), and only then decide
whether a filter is needed โ the ranked list is often good enough as-is, and
seeing it tells them WHICH failure mode to filter (wrong industry? too small?
services shops?). Offer three paths:
- Look first (recommended default) โ revisit filtering after they've
browsed the sheet; remind them it's available any time.
- Criteria filter (free, instant) โ rules over columns already in
data.csv (employee count, HQ country, industry, tags, any signal). Write a
rules JSON and run --mode criteria. Good for crisp cuts the data already
expresses.
- LLM / web-research filter โ one call per whitespace row answering "does
this company fit the ICP?" against a ~200-word ICP prompt. Output per row:
boolean + one-sentence reason.
The LLM path is provider-agnostic โ template/_build/filter_whitespace.py
takes --provider anthropic | openai | gemini | parallel | exa, each behind the
same interface (the user brings their OWN API key for whichever they have):
| Provider | Default engine | ~cost / 1K rows | Notes |
|---|
anthropic | claude-sonnet-4-6 + web search | ~$35 | $10/1K searches + tokens |
openai | gpt-5-mini + web_search | ~$17 | $10/1K calls + tokens |
gemini | gemini-2.5-flash + grounding | ~$38 | 1,500 grounded/day free, then $35/1K; Google ToS limits storing grounded output |
parallel | Task API base processor | ~$10 | research + structured output in one call; lite/core swap quality/cost |
exa | /answer | ~$5 | cheapest; answer quality below the LLM+search options |
Mechanics (the script handles all of it):
- Targets
account_category == "whitespace" (NEW accounts) by default, ordered
by current rank โ whitespace_subsidiary rows (parent already in the CRM) are
land-and-expand, not net-new, so postprocessing spend skips them unless
--include-subsidiaries is passed. Recommend --top 2000 (or the user's
working depth) โ filtering the tail of 19K rows is wasted spend.
- Keys (portable โ assume nothing about the user's machine). Resolution
order:
--api-key โ --key-cmd "{command}" (stdout is the key; works with
any secret manager: gcloud secrets versions access, op read,
aws secretsmanager get-secret-value, pass show) โ the provider's
standard env var โ --env-file โ ~/.config/sumble/{provider}_api_key.
Default flow for users without a secret manager: have them run
python3 {skill_dir}/template/_build/filter_whitespace.py --provider {p} --set-key ONCE in their terminal โ hidden input (getpass), saved chmod 600,
found automatically on every later run. Never ask the user to paste a key
into chat. If no key resolves, the script itself prints where to get one
and the --set-key one-liner.
--estimate-only prints cost/time; the run confirms before spending unless
--yes. Surface the estimate to the user BEFORE running.
- Checkpointed to
_raw/ws_filter_results.jsonl โ interrupt-safe, re-run
resumes, errors retry on re-run.
- Output:
ws_fit / ws_fit_reason / ws_filter_provider columns in
data.csv + score.csv (rows are FLAGGED in the data, never dropped), plus
llm_fit / llm_unfit appended to tags. The app reacts automatically
once classified rows exist:
- The Whitespace chip splits into THREE chips โ "WS ยท unprocessed"
(whitespace rows the filter never judged, e.g. below the --top depth; ON
by default), "WS ยท ICP fit" (ON by default, green) and "WS ยท not a fit"
(OFF by default, red) โ so after the filter runs the sheet opens
PRE-PRUNED: rows the LLM judged not-a-fit are hidden until that chip is
toggled on. The three are ordinary category chips (same multi-select
mechanics, counts, and "All" reset); CRM categories are untouched.
- Breakdown verdict box โ clicking a classified row shows
"โ ICP fit / โ Not an ICP fit", the provider, and the model's
one-sentence reason, so a rep sees WHY.
Restart
app.py to pick up new columns.
- The fit definition is the USER'S, not yours โ never run the LLM filter on
a prompt the user hasn't approved. Mandatory sequence before any paid run:
- Ask the user, in their own words: "What makes a company a clear FIT
for this filter, and what makes one a clear NON-fit?" (one question; give
2-3 example criteria from their spec as a starting point).
- Draft the ~200-word prompt from their answer + the spec (what the
company sells, explicit fit rules, explicit non-fit rules) and write it
to
_raw/ws_filter_prompt.txt.
- Print the ENTIRE prompt verbatim in the message and get an explicit
yes/edit (the Stage 1 Confirmation rule applies here too โ never
summarize it or hide it behind "as drafted"). Loop on edits.
- Only then show the cost/time estimate and run.
The script records a hash of the prompt next to the checkpoint: if the
prompt later changes, old verdicts are auto-archived
(
ws_filter_results.jsonl.stale) and the run reclassifies from scratch โ
verdicts from two different fit definitions never mix.
python3 {skill_dir}/template/_build/filter_whitespace.py --dir {output_root} \
--provider openai --top 2000 --prompt-file {output_root}/_raw/ws_filter_prompt.txt \
--estimate-only
python3 {skill_dir}/template/_build/filter_whitespace.py --dir {output_root} \
--provider openai --top 2000 --prompt-file {output_root}/_raw/ws_filter_prompt.txt --yes
python3 {skill_dir}/template/_build/filter_whitespace.py --dir {output_root} \
--mode criteria --rules {output_root}/_raw/ws_filter_rules.json
Quality guidance when the user asks which provider: evidence beats model โ the
script already packs the row's firmographics (name, domain, industry, size,
tags) into the prompt and tells the model to trust the DOMAIN (avoids
same-name-company errors). anthropic/openai give the best
search-and-reason quality; parallel is the best no-LLM-key option
(structured research API); gemini is cheapest at scale if paced under the
free grounding tier and its storage ToS is acceptable; exa is the budget
sanity-check. Calibrate before trusting: if gold/customer rows exist, spot-run
the filter on ~20 of them โ they should nearly all come back fit.