Customer Feedback Analysis skill for Claude Code
From ticket noise to ranked, revenue-aware problems. Claude reads a sample first, drafts a codebook of problems rather than requested solutions, and tags every row with a bundled script that reports distinct accounts, segment share, revenue and monthly trend per theme. Keyword tags are validated against a hand-coded random sample before any number is reported, and top themes become problem statements cross-checked against usage data.
- name
customer-feedback-analysis - size181 lines · ≈2,000 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Mines customer feedback at volume — support tickets, app store and G2 reviews, NPS and CSAT comments, sales call notes, and feature request boards — into a coded theme taxonomy with counts by distinct account, segment, revenue, and trend, using a bundled codebook tagging script plus manual review for accuracy, and turns the result into prioritized, evidence-backed problems. Use when analyzing support tickets or reviews for themes, making sense of NPS comments, building a feature request tally, finding what drives churn or detractors, or reporting the voice of the customer to the product team. Try asking
- Find the top themes in last quarter's Zendesk export and weight them by ARR
- What are detractors complaining about in our NPS comments, and is it getting worse?
- Turn our feature request board into a list of underlying problems with account counts
Use it when
- Analyzing support tickets, app reviews or NPS comments for themes
- Building a feature request tally that counts customers, not votes
- Looking for what drives churn or detractor scores
- Reporting voice of the customer to product leadership
Skip it when
- You have a handful of in-depth interviews to synthesize, use user-research-synthesis instead
- You need to rank the resulting problems against effort, use feature-prioritization instead
How to check the result
Every reported theme shows distinct accounts, segment share and trend, and a hand-coded random sample confirms at least 80 percent precision for the keyword tags.
The common mistake it prevents
Counting ticket rows instead of distinct accounts, so one customer who files a dozen tickets looks like a trend.
Feedback analysis is done well when the team can see which problems affect how many distinct customers, which segments and how much revenue, whether each problem is growing, and what customers were actually trying to do. Raw request counts are where the analysis starts, not where it ends.
Ground rules
- Code problems, not requested solutions. “Add CSV export” and “need data in Excel” are one problem: getting data into another tool.
- Count distinct accounts (or users for consumer products) per theme, not rows. One angry customer filing twelve tickets is one account.
- Weight by segment and revenue where the business cares, but always show the unweighted count too. Revenue weighting alone over-serves a few large customers.
- Know the source bias: support tickets over-represent confusion and bugs; reviews over-represent extremes; NPS comments come from people who answer surveys; sales notes over-represent prospects’ checklists.
- Keyword tagging is a first pass. Validate on a random sample and fix the codebook before reporting numbers.
- Strip PII before sharing excerpts: names, emails, phone numbers, order IDs.
Workflow
- [ ] 1. Define the question (what decision?) and the time window
- [ ] 2. Pull sources; normalize to one CSV: id, date, source, account, segment, value, text
- [ ] 3. Read a random sample of 50–100 rows before coding anything
- [ ] 4. Draft the codebook (6–15 themes) from the sample
- [ ] 5. Run the tagging script; review uncoded rows; refine the codebook
- [ ] 6. Validate accuracy on a fresh random sample
- [ ] 7. Quantify: accounts, segment share, revenue, trend; cross-check with usage data
- [ ] 8. Write problem statements and recommendations
Codebook
One theme per underlying problem, with a definition, inclusion and exclusion rules, and patterns. Keep a separate dimension for the type of feedback.
theme,patterns
Export failures,export|csv|download.*(fail|never|stuck)|times? out.*export
Performance,slow|lag|takes forever|spinning|loading
SSO / security,\bsso\b|saml|okta|security review|scim
Pricing and billing,pric|charged|per seat|invoice|refund|billing
Mobile app,mobile|ios|android|iphone|app store
Secondary dimensions to tag where possible:
| Dimension | Values |
|---|---|
| Type | bug, how-to/confusion, missing capability, pricing, praise |
| Sentiment | negative, neutral, positive |
| Severity | blocks work, workaround exists, annoyance |
| Lifecycle | trial, onboarding (first 30 days), established, renewal/churn |
Rules for a good codebook:
- 6–15 themes at the top level; nest subthemes rather than adding a 30th top-level theme.
- Each theme has a one-line definition so two people code the same row the same way.
- Keep an “Other / uncoded” bucket under 15–25%; above that the codebook is missing themes.
- Version it; when a theme is split or merged, re-run the whole dataset.
Tagging script
python3 scripts/theme_tally.py feedback.csv codebook.csv --uncoded-out uncoded.csv
Columns: text is required; account, segment, arr, and date are used when
present (rename with --text-col, --account-col, --segment-col, --value-col,
--date-col). For each theme it prints rows, distinct accounts, value summed over
distinct accounts, share within each segment, counts by month, and sample quotes. It
reports the uncoded share and writes uncoded rows for review. Rows can match several
themes.
Keyword matching has known failure modes: “times out” can mean an export or general performance; “billing email” is a how-to, not a pricing complaint; negation (“export works great now”) still matches. Hence step 6.
Validation
- Draw a fresh random sample of 50 coded rows (more for small themes that drive a decision).
- Code them by hand blind to the script’s tags.
- Compute per-theme precision (script tag correct) and note misses.
- Target ≥ 80% precision on the themes you will report. Below that, tighten patterns, add exclusions, or code that theme manually.
- If an LLM or a second person codes, compare on 30–50 rows and resolve disagreements by sharpening definitions.
Quantify and prioritize
| Column | Why |
|---|---|
| Distinct accounts | Breadth, resistant to repeat filers |
| % of segment | Whether it is a segment-specific problem |
| ARR of affected accounts | Business exposure |
| Trend (month over month) | Growing problems beat stable ones at equal size |
| Severity mix | Blocks work vs. annoyance |
| Churn/renewal linkage | Share of churned or at-risk accounts mentioning it |
Worked summary (Aug–Sep, 1,840 tickets, 412 reviews, 960 NPS comments):
| Theme | Accounts | % of mid-market | ARR affected | Trend | Severity |
|---|---|---|---|---|---|
| Export failures | 118 | 21% | $1.9M | rising (+34% MoM) | blocks month-end |
| Performance (boards) | 240 | 12% | $1.1M | flat | annoyance |
| SSO / security | 19 | 2% (enterprise 48%) | $2.3M | flat | blocks rollout |
| Pricing (guest seats) | 77 | 6% | $0.4M | new since Sep price change | churn risk |
Reading it: performance has the most accounts but is mostly annoyance; export failures are rising and block a critical workflow; SSO is tiny by count and large by revenue and belongs in a cost-of-delay conversation (see feature-prioritization). The guest-seat pricing theme appeared after a change, so check churn and downgrades by cohort before concluding.
Cross-check each top theme against behavioral data (export job failure rate, page load p75) before presenting. Feedback tells you why; data tells you how much.
NPS comments
- Analyze detractor (0–6), passive (7–8), and promoter (9–10) comments separately. Detractor themes explain churn risk; promoter themes explain what to protect.
- NPS from fewer than ~100 responses moves by ±10 points from noise; report the response count and do not chase small movements.
- Look at the score distribution, not just the net number.
Problem statements
Turn each top theme into: who (segment) + problem + evidence (accounts, ARR, trend, quotes) + impact (churn, expansion, support cost) + confidence.
Mid-market finance admins cannot export a full month of invoices; exports above ~5k rows time out (118 accounts, $1.9M ARR, +34% month over month, 0.92 precision on 50 sampled rows). 9 of 31 churned mid-market accounts in Q3 mentioned exports. Support cost: ~140 agent hours/month.
Gotchas
- Counting rows instead of accounts makes noisy customers look like trends.
- Solution coding (“wants dark mode”) hides the problem and blocks better solutions.
- Treating feature-vote boards as demand. Votes skew to vocal power users; normalize by active accounts in the segment.
- Ignoring silent churn. Churned customers often never filed a ticket; add exit survey and cancellation reasons.
- Reporting untested keyword counts. Always state the validation precision.
- Comparing periods across a codebook change. Re-run history with the new codebook.
- Leaking PII in quote excerpts; scrub before sharing.
Verification
- Random-sample precision ≥ 80% for every reported theme, stated in the report.
- Uncoded share under 25%, or the remaining uncoded rows are explained.
- Every top theme shows distinct accounts, segment share, and trend, not only row counts.
- Top themes are cross-checked against a behavioral or business metric.
- Quotes are anonymized.
Output contract
# Voice of the customer: <window>
Sources: <tickets n, reviews n, NPS n, calls n> Codebook: v<x> Validation: <precision>
## Top problems
| Theme | Accounts | Segment share | ARR | Trend | Severity | Confidence |
### <theme>: problem statement, evidence, quotes, related metric
## Emerging themes (small but rising)
## What customers praise (protect these)
## Recommendations and owners
## Method: sources, codebook, validation, limitations Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/theme_tally.py
#!/usr/bin/env python3
"""Tag feedback rows with a keyword codebook and tally themes.
Usage:
python3 theme_tally.py feedback.csv codebook.csv [--text-col text]
[--account-col account] [--segment-col segment] [--value-col arr]
[--date-col date] [--quotes 2] [--uncoded-out uncoded.csv]
feedback.csv : one row per ticket/review/survey answer. Only the text column is
required; account, segment, value (e.g. ARR) and date are optional.
codebook.csv : columns theme,patterns where patterns is a "|"-separated list of
case-insensitive keywords or regular expressions, e.g.
Export problems,export|csv|download.*(fail|timeout)
A row can match several themes. Prints per theme: rows, distinct accounts,
summed value of distinct accounts, share by segment, month-over-month counts
(if dates exist), and sample quotes. Reports the uncoded share; above 25% the
codebook is missing themes, so read the uncoded rows (optionally written with
--uncoded-out) and extend it. Standard library only. Exits 1 on bad input.
"""
from __future__ import annotations
import argparse
import csv
import re
import sys
from collections import Counter, defaultdict
def fail(msg: str) -> None:
print(f"error: {msg}", file=sys.stderr)
sys.exit(1)
def read_csv(path: str) -> tuple[list[str], list[dict]]:
try:
with open(path, newline="", encoding="utf-8-sig") as fh:
r = csv.DictReader(fh)
return list(r.fieldnames or []), list(r)
except OSError as exc:
fail(str(exc))
return [], []
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("feedback")
ap.add_argument("codebook")
ap.add_argument("--text-col", default="text")
ap.add_argument("--account-col", default="account")
ap.add_argument("--segment-col", default="segment")
ap.add_argument("--value-col", default="arr")
ap.add_argument("--date-col", default="date")
ap.add_argument("--quotes", type=int, default=2)
ap.add_argument("--uncoded-out")
a = ap.parse_args()
cb_cols, cb_rows = read_csv(a.codebook)
if not {"theme", "patterns"} <= set(cb_cols):
fail("codebook needs columns theme,patterns")
themes: list[tuple[str, re.Pattern]] = []
for row in cb_rows:
name = (row["theme"] or "").strip()
pats = [p.strip() for p in (row["patterns"] or "").split("|") if p.strip()]
if not name or not pats:
continue
try:
themes.append((name, re.compile("|".join(f"(?:{p})" for p in pats), re.I)))
except re.error as exc:
fail(f"bad pattern in theme '{name}': {exc}")
if not themes:
fail("codebook has no themes")
cols, rows = read_csv(a.feedback)
if a.text_col not in cols:
fail(f"feedback needs a '{a.text_col}' column; got {cols}")
has = {k: getattr(a, f"{k}_col") in cols for k in ("account", "segment", "value", "date")}
hits: dict[str, list[dict]] = defaultdict(list)
uncoded: list[dict] = []
seg_total: Counter = Counter()
n = 0
for row in rows:
text = (row.get(a.text_col) or "").strip()
if not text:
continue
n += 1
if has["segment"]:
seg_total[(row.get(a.segment_col) or "unknown").strip() or "unknown"] += 1
matched = False
for name, rx in themes:
if rx.search(text):
hits[name].append(row)
matched = True
if not matched:
uncoded.append(row)
if n == 0:
fail("no feedback rows with text")
def value(r: dict) -> float:
try:
return float((r.get(a.value_col) or "0").replace(",", "").replace("$", ""))
except ValueError:
return 0.0
order = sorted(hits, key=lambda t: len(hits[t]), reverse=True)
print(f"{n} feedback rows, {len(themes)} themes, uncoded {len(uncoded)} ({len(uncoded) / n:.0%})\n")
for t in order:
rs = hits[t]
line = f"## {t}: {len(rs)} rows ({len(rs) / n:.0%})"
if has["account"]:
accts = {}
for r in rs:
acc = (r.get(a.account_col) or "").strip()
if acc:
accts[acc] = value(r) if has["value"] else 0.0
line += f", {len(accts)} account" + ("" if len(accts) == 1 else "s")
if has["value"]:
line += f", value {sum(accts.values()):,.0f}"
print(line)
if has["segment"]:
seg = Counter((r.get(a.segment_col) or "unknown").strip() or "unknown" for r in rs)
parts = [f"{s} {c} ({c / seg_total[s]:.0%} of {s})" for s, c in seg.most_common()]
print(" segments: " + "; ".join(parts))
if has["date"]:
months = Counter((r.get(a.date_col) or "")[:7] for r in rs if r.get(a.date_col))
if months:
print(" by month: " + ", ".join(f"{m} {c}" for m, c in sorted(months.items())))
for r in rs[: a.quotes]:
q = re.sub(r"\s+", " ", r[a.text_col]).strip()
print(f' "{q[:160]}{"..." if len(q) > 160 else ""}"')
print()
for name, _ in themes:
if name not in hits:
print(f"(no matches: {name})")
if len(uncoded) / n > 0.25:
print(f"warning: {len(uncoded) / n:.0%} uncoded; read those rows and extend the codebook")
if a.uncoded_out:
with open(a.uncoded_out, "w", newline="", encoding="utf-8") as fh:
w = csv.DictWriter(fh, fieldnames=cols)
w.writeheader()
w.writerows(uncoded)
print(f"uncoded rows written to {a.uncoded_out}")
if __name__ == "__main__":
main()
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.