Build an AI keyword-clustering tool
Group Northwind's tracked search terms into topic clusters — for SEO content planning.
Published Feb 19, 2026
Northwind tracks a few hundred search terms in a spreadsheet, but a flat list is no use for planning content. What the marketing team actually needs to know is which terms belong together — which ones are really the same topic asked five different ways — so each cluster can become one page instead of five thin ones competing with each other.
This script hands the grouping to Claude. It reads the keyword column, asks for the terms sorted into named topic clusters, and writes each cluster to its own row in an output sheet. What comes back is a content plan: a handful of themes, each with the search terms it should target.
What you’ll need
- A Google Sheet of tracked keywords, one term per row in column A of the first tab, with a header in row 1.
- A second Google Sheet (or another tab) to receive the clusters. The script clears it on every run, so use a sheet you are happy to overwrite.
- An Anthropic API key saved as
ANTHROPIC_API_KEYin Script Properties — see Store API keys and secrets securely.
The script
// The spreadsheet that holds the flat list of tracked keywords.
const KEYWORDS_SHEET_ID = '1abcKeywordsId';
// The spreadsheet that receives the clustered output.
const CLUSTERS_SHEET_ID = '1abcKeywordClustersId';
/**
* Reads the tracked keywords, asks Claude to group them into topic
* clusters, and writes one row per cluster to the output sheet.
*/
function clusterKeywords() {
// 1. Read column A of the keyword sheet and drop blank cells.
const keywords = SpreadsheetApp.openById(KEYWORDS_SHEET_ID)
.getSheets()[0]
.getRange('A2:A')
.getValues()
.flat()
.filter(Boolean);
if (!keywords.length) {
Logger.log('No keywords to cluster — nothing to do.');
return;
}
// 2. Ask Claude for strict JSON. A fixed schema keeps the reply
// safe to parse — see "Watch out for".
const prompt =
'Cluster these Northwind search keywords into topic groups. ' +
'Return ONLY a JSON array — no prose, no markdown — in this shape: ' +
'[{"cluster": string, "keywords": [string]}]. ' +
'Give each cluster a short descriptive name and put every keyword ' +
'into exactly one cluster.\n\n' + keywords.join('\n');
// 3. Sonnet does the grouping; strip any code fence, then parse.
const reply = callClaude(prompt, 'claude-sonnet-4-6', 2000);
const clusters = JSON.parse(stripFences(reply));
// 4. Rebuild the output sheet from scratch on every run.
const sheet = SpreadsheetApp.openById(CLUSTERS_SHEET_ID).getSheets()[0];
sheet.clear();
sheet.getRange(1, 1, 1, 2)
.setValues([['Cluster', 'Keywords']]);
// 5. Write one row per cluster, keywords joined into a single cell.
const rows = clusters.map((c) => [c.cluster, c.keywords.join(', ')]);
if (rows.length) {
sheet.getRange(2, 1, rows.length, 2).setValues(rows);
}
Logger.log('Wrote ' + rows.length + ' clusters.');
}
/**
* 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
clusterKeywordsopens the keyword spreadsheet and reads column A, flattening the range into a plain list and dropping blank cells.- If there are no keywords, it logs a message and stops — no wasted API call.
- It builds a prompt that pins the output to a strict JSON schema: an array of
objects, each with a
clustername and a list ofkeywords. The prompt also tells Claude to put every term into exactly one cluster. - It calls Claude Sonnet, which is worth the cost here because good grouping
needs reasoning over the whole list.
stripFencesremoves any code fence, thenJSON.parseturns the reply into objects. - It rebuilds the output sheet from scratch and writes one row per cluster, joining each cluster’s keywords into a single comma-separated cell.
Example run
Say column A of the keyword sheet holds terms like these:
- “google apps script tutorial”
- “apps script send email”
- “automate google sheets”
- “apps script gmail trigger”
- “google sheets formulas”
After a run, the output sheet holds a clustered plan:
| Cluster | Keywords |
|---|---|
| Learning Apps Script | google apps script tutorial, apps script for beginners |
| Email automation | apps script send email, apps script gmail trigger |
| Sheets automation | automate google sheets, google sheets formulas |
That is three content briefs instead of a list of forty loose terms — each cluster becomes one focused page rather than several thin ones.
Run it
This is a planning job you run when the keyword list changes, not on a schedule:
- In the Apps Script editor, select
clusterKeywordsand click Run. - Approve the authorisation prompt the first time.
- Open the output sheet to read the clusters.
Watch out for
- Clustering is a judgement call, not a fact. Claude groups by meaning, so borderline terms can land in either of two reasonable clusters. Treat the output as a starting point a human reviews.
- The output sheet is wiped on every run. If you have annotated it — added priorities or notes — those are lost. Keep notes in a separate column or tab.
- A very long keyword list can exceed the token budget. If
JSON.parsethrows, raisemax_tokens, or split the list and cluster in batches. - Strict JSON keeps this reliable.
stripFenceshandles the common case where Claude wraps the array in a code fence. If parsing still fails, log the raw reply and tighten the prompt rather than reaching for regex.
Related
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
Build a document-classification system
Sort Northwind Drive files into types by their content — contracts, briefs, invoices.
Updated Jan 6, 2026