/voc-profiler — Attach Weight to a Signal
A passing complaint from a customer paying you $500K is not the same input as the same sentence from a free-tier trial that churned in week two. Most VoC tooling flattens that distinction and then produces a ranked list of "top customer pains" that is really a list of loudest pains.
This skill attaches the context that makes weighting possible, and refuses to guess when the context is missing.
Requires
| What | Value |
|---|---|
| CRM | Any CRM with an MCP server or API. HubSpot is the reference implementation; see "Other CRMs" below. |
| Config | config/thresholds.json (tiers, ICP rubric), context/product-context.md (your CRM property names) |
| Cost | CRM API calls only. Free on every major plan. |
Works without a CRM. Every signal comes back tier U (unattributed), the pipeline still
runs, and the synthesis step tells you honestly that nothing was weighted. That is a legitimate
way to start.
Input
One of:
- A quote plus its source (
"the export takes forever" — chat, #wins, 2026-04-22) - A path to a call transcript
- A path to a meeting note
- A review record from
outputs/voc/sources/reviews/ - A survey response or support ticket excerpt
If the source channel is missing, ask once. If the company is missing, set
attribution: anonymous and continue — do not stall the pipeline on one unattributable quote.
Execution
Step 1 — Extract, do not paraphrase
Pull from the source:
| Field | Rule |
|---|---|
verbatim |
Exact. No cleanup, no grammar fixes, no trimming for length. This is the artifact everything downstream depends on. |
company |
From file metadata, note title, participant domain, or an explicit mention |
speaker_role |
Title if available |
speaker_affiliation |
Must be external. If the speaker is internal or unlabelled, stop and flag it. |
date |
ISO |
channel |
call | chat | notes | review | crm_email | survey | ticket |
The affiliation check is not optional. The most common silent failure in VoC work is a pipeline that confidently reports your own sales team's framing back to you as customer voice, because a transcript had no speaker labels and everything got treated as signal.
Step 2 — Resolve to CRM
Search by company name, then by the speaker's email domain if the name misses. On multiple matches, prefer an open deal or an active subscription over a stale record.
Verify before you trust the match. CRM records get mis-associated constantly — deals attached to the wrong company, duplicate records from two import sources. If the matched record's domain does not correspond to the speaker's, treat it as no match rather than accepting it. A wrong match is worse than no match: it produces a confident weighting built on another account's data.
Pull the properties named in context/product-context.md. If no match: crm_match: false,
tier U, continue.
Step 3 — Score ICP fit (0-100)
Apply the rubric in config/thresholds.json. Defaults sum to 100 across vertical fit, company
size, maturity, geography, and product activity.
A dimension you cannot determine scores zero and goes into data_gaps. Never substitute an
average, a midpoint, or a plausible guess. A 40/100 with three gaps listed is honest and useful;
a 65/100 built on two invented values is neither, and it will be repeated downstream as fact.
Step 4 — Assign weight tier
From weight_tiers in the config. Defaults:
| Tier | Meaning |
|---|---|
| A | High ICP fit, meaningful revenue, active or expanding. Counts double toward confirmation. |
| B | Standard. The bulk of healthy signal. |
| C | Low ICP fit, free tier, or churned. Surfaces only when corroborated. |
| U | Unattributed. Quarantined. Never counts toward confirmation until a human resolves it. |
Tier U is the important one. It exists so that anonymous noise cannot quietly manufacture a three-source pattern.
Step 5 — Write output
JSON to outputs/voc/profiled/<date>-<slug>.json, or
outputs/voc/profiled/_quarantine/ for tier U.
{
"verbatim": "...",
"channel": "call",
"date": "2026-04-22",
"speaker": {"role": "...", "affiliation": "external"},
"company": "...",
"crm_match": true,
"crm_record_id": "...",
"segment": {"industry": "...", "employees": "...", "geo": "..."},
"revenue_tier": "...",
"lifecycle": "customer",
"icp_fit": 78,
"weight_tier": "A",
"data_gaps": ["product_activity"],
"sentiment": "complaint",
"source_file": "..."
}
Then print a compact card:
Signal "the export takes forever on anything over 2000 rows"
Source call · 2026-04-22 · external speaker (Ops Director)
Account Northwind Trading · Logistics · 500-1000 · EMEA
CRM matched (deal 40122) · customer · renewal in 94 days
Fit 78/100 Tier A
Gaps product_activity not available
Other CRMs
The skill needs four things: search a company, read its properties, read lifecycle, read revenue. Any CRM can supply them.
| CRM | How |
|---|---|
| HubSpot | Official MCP server. Declared in this skill's allowed-tools. |
| Salesforce | Add a Salesforce MCP server, swap the tool names in allowed-tools, map property names in context/product-context.md |
| Attio / Pipedrive / Close | Same pattern |
| None | Everything is tier U. Documented, supported, honest. |
Property names are never hardcoded in this file. They live in context/product-context.md
under crm_properties because every instance is customized differently.
What this gets wrong
- ICP fit is a rubric, not a measurement. It encodes your assumptions about who a good customer is. If that model is wrong, this scores confidently in the wrong direction.
- CRM data decays. Employee counts and revenue tiers are often years stale. A tier-A weight can rest on a number nobody has updated since the deal closed.
- Revenue is not the only weight that matters. A small account that is a design partner, a logo, or a bellwether for a segment may deserve more weight than its ARR implies. The config cannot know that. Override by hand and leave a note.
- Quarantine grows. If most signals land in tier U, the answer is better speaker labelling upstream, not a lower bar here.