Build an AI survey-response analyzer
Cluster Northwind open survey responses into insights with example quotes.
Published Sep 20, 2025
When a Northwind survey closes, the responses are not the hard part — making sense of them is. A few hundred open-text answers carry real signal, but nobody has a spare afternoon to read every one and spot the patterns by hand. So the analysis slips, and by the time it happens the feedback is stale.
This script hands the slow part to Claude. It reads the open-text column, asks
for the responses grouped into a handful of named themes — each with an
estimated count and two real quotes — and writes the result to a Themes tab
you can skim in two minutes. It is the engine behind a quarterly feedback loop,
but it works just as well for a one-off survey.
What you’ll need
- A Google Sheet of survey responses, with the open-text answers in column B and a header in row 1. Form-linked sheets put each question in its own column, so point the script at whichever column holds the open-text question.
- An Anthropic API key saved as
ANTHROPIC_API_KEYin Script Properties — see Store API keys and secrets securely. - Nothing else — the script creates the
Themestab itself.
The script
// The spreadsheet that holds your survey responses.
const SURVEY_SHEET_ID = '1abcSurveyId';
// How many responses to send to Claude in one pass. Keeps the prompt
// within a sensible token budget — see "Watch out for".
const SAMPLE_SIZE = 200;
/**
* Reads the open-text survey answers, asks Claude to cluster them into
* themes, and writes the result to a "Themes" tab.
*/
function analyseResponses() {
const ss = SpreadsheetApp.openById(SURVEY_SHEET_ID);
// 1. Read column B (the open-text answers) and drop blank cells.
const responses = ss.getSheets()[0]
.getRange('B2:B')
.getValues()
.flat()
.filter(Boolean);
if (!responses.length) {
Logger.log('No responses to analyse — nothing to do.');
return;
}
// 2. Take a sample and format it as a bulleted list for the prompt.
const sample = responses.slice(0, SAMPLE_SIZE).join('\n- ');
// 3. Ask Claude for strict JSON. A fixed schema is the difference
// between a parseable result and a parsing nightmare.
const prompt =
'Cluster these Northwind survey responses into 5-8 themes. ' +
'Return ONLY a JSON array — no prose, no markdown — in this shape: ' +
'[{"theme": string, "count": number, "examples": [string, string]}]. ' +
'Use "count" for your estimate of how many responses fit the theme, ' +
'and "examples" for two short verbatim quotes.\n\n- ' + sample;
// 4. Sonnet does the reasoning; strip any code fence, then parse.
const reply = callClaude(prompt, 'claude-sonnet-4-6', 2000);
const clusters = JSON.parse(stripFences(reply));
// 5. Rebuild the Themes tab from scratch on every run.
const sheet = ss.getSheetByName('Themes') || ss.insertSheet('Themes');
sheet.clear();
sheet.getRange(1, 1, 1, 3)
.setValues([['Theme', 'Est. count', 'Example quotes']]);
const rows = clusters.map((c) => [c.theme, c.count, c.examples.join(' / ')]);
if (rows.length) {
sheet.getRange(2, 1, rows.length, 3).setValues(rows);
}
Logger.log('Wrote ' + rows.length + ' themes to the Themes tab.');
}
/**
* Claude occasionally wraps JSON in a ```json code fence. Strip it so
* JSON.parse never chokes on the markdown.
*/
function stripFences(text) {
return text.replace(/```(?:json)?/g, '').trim();
}
/**
* Minimal Anthropic API call. The key lives in Script Properties — it
* is never pasted into the code.
*/
function callClaude(prompt, model = 'claude-haiku-4-5-20251001', maxTokens = 400) {
const key = PropertiesService.getScriptProperties()
.getProperty('ANTHROPIC_API_KEY');
const res = UrlFetchApp.fetch('https://api.anthropic.com/v1/messages', {
method: 'post',
contentType: 'application/json',
headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' },
payload: JSON.stringify({
model,
max_tokens: maxTokens,
messages: [{ role: 'user', content: prompt }],
}),
muteHttpExceptions: true,
});
return JSON.parse(res.getContentText()).content[0].text.trim();
}
How it works
analyseResponsesopens the survey spreadsheet and reads column B, the open-text answers, flattening the range into a plain list and dropping blanks.- If there are no responses, it logs a message and stops — no wasted API call.
- It takes the first 200 responses (
SAMPLE_SIZE) and joins them into a bulleted list, the format Claude clusters most reliably. - It builds a prompt that pins the output to a strict JSON schema — an array of
objects, each with a
theme, acount, and twoexamples. - It calls Claude Sonnet, which is worth the extra cost here because clustering
needs reasoning over the whole sample.
stripFencesremoves any code fence, thenJSON.parseturns the reply into real objects. - It rebuilds the
Themestab from scratch and writes one row per theme, so the tab always reflects the latest run.
Example run
Say column B of the responses sheet contains a few hundred answers like these:
- “Onboarding took ages — I never knew where to start.”
- “Great support team, sorted my issue in minutes.”
- “A bit pricey for what you actually get.”
- “Setup was confusing, but support walked me through it.”
After a run, the Themes tab holds a clustered summary:
| Theme | Est. count | Example quotes |
|---|---|---|
| Onboarding is confusing | 58 | ”Onboarding took ages…” / “Setup was confusing…” |
| Support is responsive | 47 | ”Great support team…” / “…support walked me through it.” |
| Pricing feels high | 31 | ”A bit pricey for what you actually get.” / ”…” |
| Reporting needs work | 22 | ”…” / ”…” |
That is the table you take into a meeting — four to eight themes with real quotes underneath, instead of 200 raw rows nobody will read.
Run it
This is a once-per-survey job, not a daily one, so run it by hand when a survey closes:
- In the Apps Script editor, select
analyseResponsesand click Run. - Approve the authorisation prompt the first time.
- Open the
Themestab to read the result.
To let non-editors trigger it, add a custom menu so it appears in the spreadsheet itself:
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('Survey tools')
.addItem('Analyse responses', 'analyseResponses')
.addToUi();
}
Watch out for
- Counts are estimates. Claude clusters the sample and estimates how many responses fit each theme — it does not tally every row. That is fine for spotting priorities; it is not an exact frequency table.
- The sample is capped at
SAMPLE_SIZE(200). For a larger survey, raise the cap andmax_tokenstogether, or cluster in batches and merge the themes in a second pass. - Strict JSON keeps this reliable.
stripFenceshandles the common case where Claude wraps the array in a code fence. IfJSON.parsestill throws, log the raw reply and tighten the prompt rather than reaching for regex. - This discovers themes from scratch. If you already know your categories and just want every row labelled, use Classify customer feedback by theme instead.
Related
Build an AI keyword-clustering tool
Group Northwind's tracked search terms into topic clusters — for SEO content planning.
Updated Feb 19, 2026
Build an AI customer-churn predictor
Flag at-risk Northwind accounts from behavioural signals — usage, support tickets, billing.
Updated Feb 15, 2026
Build a context-aware AI data validator
Catch values that look wrong in context — '£10' for a Northwind retainer is suspicious.
Updated Feb 7, 2026
Auto-categorize a photo library
Tag Northwind Drive images by visual content — product, team, event, behind-the-scenes.
Updated Feb 3, 2026
Build an AI bug-triage system
Categorise and prioritise Northwind's reported issues automatically — type, severity, owner.
Updated Jan 22, 2026