Topic Discovery Skill (Layer 0)
DATA LAYER RULING (2026-06-12). Every mcp__semrush__<kebab-name> tool named below is FICTIONAL (never existed on the live server), and DataForSEO is retired. Before making any data call, read ../keyword-research-pipeline/references/semrush-data-layer.md — it maps this layer's old calls to the real Semrush MCP pattern (get_report_schema -> execute_report). The logic below (filters, thresholds, output schema) remains binding; only the data calls changed.
Take brand-config.md and produce two artefacts that downstream layers consume as enrichment:
content-pipeline/0-keywords/topic-graph.json — the Semrush Topic Research idea-cluster tree rooted at the brand's category. Layer 1a (/seed-modifier-prompt) reads the top 5 cluster names so the seed agent grounds its output in Semrush's actual topic graph rather than brand-config alone. Layer 5 (/keyword-prioritization) reads cluster-level metrics to compute the cluster_authority_gap boost.
content-pipeline/0-keywords/trends.md — a short markdown summary of category-level trending queries, momentum direction, and competitor traffic shifts pulled from Semrush .Trends. Layer 4 (/keyword-redteam) reads this to ground its "is this keyword trending up or down today?" critique.
This layer is enrichment, not a gate. If Semrush returns nothing useful, downstream layers fall through to brand-config-only behavior. Layer 0 must never block the pipeline.
Input
/topic-discovery [--regen]
Reads:
brand-config.md — brand category positioning, brand domain
content-pipeline/0-keywords/topic-graph.json — previous output (for change detection)
--regen forces a fresh run even if the brand-config hash hasn't changed.
Process
Compute brand-config hash. SHA-256 of brand-config.md content. Compare against topic-graph.json's brand_snapshot field if it exists. If unchanged AND --regen not passed, exit cleanly with a one-line message saying "topic graph is current, skipping" — Layer 0 stays idempotent (mirrors the pattern in seed-modifier-prompt/SKILL.md step 1).
Read brand-config. Extract:
- Brand category root — the noun-phrase that defines the brand's category for SEO purposes. Derived from
brand-config.md's "Category positioning" line. If the line is verbose, condense to 1–3 words (e.g. for Pleasur.AI: "ai adult companions" → root seed ai companion). When in doubt, prefer the broader of two candidates — Topic Research returns richer trees from broader roots.
- Brand domain — used as the .Trends root URL. Strip protocol and trailing slash.
Call mcp__semrush__topic-research with the brand category as the root keyword. Request the full idea-cluster tree: each cluster includes a name, headlines, questions, related searches, aggregated volume, and aggregated difficulty (KD%). Cache the raw response under content-pipeline/0-keywords/cache/topic-research-raw.json so re-runs without --regen don't refetch when only writing the structured tree.
Call mcp__semrush__trends-overview with the brand domain as the root URL. Pull:
- Trending queries within the category (volume momentum up vs. down)
- Competitor traffic shifts (domains gaining or losing share)
- Audience overlap (which competitor audiences also visit which sites)
Cache the raw response under
content-pipeline/0-keywords/cache/trends-raw.json.
Assemble topic-graph.json as a structured tree:
{
"root_seed": "ai companion",
"brand_domain": "pleasur.ai",
"clusters": [
{
"id": "ksb-cluster-id-from-semrush",
"name": "ai girlfriend",
"volume": 246000,
"kd_percent": 64,
"headlines": ["...", "..."],
"questions": ["what is an ai girlfriend?", "..."],
"related_searches": ["...", "..."]
}
],
"generated_at": "ISO8601 UTC",
"brand_snapshot": "sha256-hex-of-brand-config-md",
"_meta": {
"source": "Semrush Topic Research + .Trends",
"tool_inventory_ref": ".claude/skills/keyword-research-pipeline/references/semrush-mcp-tool-inventory.md"
}
}
Sort clusters by volume descending. Cap headlines / questions / related_searches at 10 entries each per cluster — Layer 1a only consumes the top 5 clusters anyway, but downstream tooling stays bounded.
Assemble trends.md as readable markdown for the redteam agent:
# Category trends — {brand_domain} ({YYYY-MM-DD})
## Trending up (volume momentum +)
- keyword (vol_now → vol_prev, change %)
## Trending down (volume momentum −)
- keyword (vol_now → vol_prev, change %)
## Competitor traffic shifts
- competitor.com — gaining/losing X% organic share over the last 90 days
## Audience overlap
- competitor.com ↔ othercompetitor.com — Y% audience overlap
_Source: Semrush .Trends, generated {ISO timestamp}_
Keep it tight — under 60 lines. The redteam agent reads this as ambient context, not a primary input.
Write both files atomically (write to .tmp then move). Print a one-line summary: cluster count, top 3 cluster names with volumes, trending-up count, trending-down count, top 3 competitors by traffic shift.
Output
content-pipeline/0-keywords/topic-graph.json — structured idea-cluster tree
content-pipeline/0-keywords/trends.md — markdown trends summary
content-pipeline/0-keywords/cache/topic-research-raw.json — raw MCP response (gitignored)
content-pipeline/0-keywords/cache/trends-raw.json — raw MCP response (gitignored)
Idempotency
Same contract as seed-modifier-prompt: re-runs without --regen exit cleanly when the brand-config SHA-256 hasn't changed. Two MCP calls per actual run; zero on the no-op path. Safe for the orchestrator to call on every keyword-research-pipeline invocation without worrying about cost.
Failure handling
Layer 0 never blocks the pipeline. It's enrichment, not a gate. Specific failure modes:
mcp__semrush__topic-research returns empty (root seed too narrow / not indexed): write a stub topic-graph.json with clusters: [], _meta.degraded: true, and the root_seed used. Append a one-line entry to content-pipeline/0-keywords/cache/topic-discovery-failed.log with the timestamp and reason. Exit code 0. Layer 1a reads the empty clusters array and falls through to brand-config-only seed generation (current behavior preserved).
mcp__semrush__topic-research errors (auth, network, server): write the same stub graph with _meta.degraded: true and _meta.error: "<reason>". Log to cache/topic-discovery-failed.log. Exit 0.
mcp__semrush__trends-overview returns empty / errors: write a trends.md containing only the heading and a single line _Source: Semrush .Trends — no data returned for {brand_domain} ({YYYY-MM-DD})_. Log to cache/topic-discovery-failed.log. Exit 0. The redteam agent reads the absence as "no trend signal available" and reasons without it.
- Both calls error with the same auth code (401): Semrush MCP isn't connected or consent expired. Surface a clear one-line error to stderr ("Semrush MCP unauthenticated — re-run consent or check
SEMRUSH_API_KEY_BLOG_AGENT") and exit 0 anyway. The orchestrator continues.
- Rate-limit (429): persist whatever responses we did get; if neither call succeeded, write the stub artefacts and log the quota event. Exit 0 (do not propagate exit 75 — Layer 0 is non-blocking). The orchestrator will pick up the cached stubs on the next run.
The discipline here is the user's call-out: "the pipeline never blocks on Layer 0 because it's enrichment, not a gate."
Quality checklist
Why this exists
Ryan Law's pre-Semrush method had no equivalent of Topic Research or .Trends — Ahrefs surfaced individual keywords but not the cluster topology of a category, and not the time-axis momentum signal that tells you whether a topic is rising or fading. Both are first-class signals in Semrush.
- The cluster topology lets Layer 1a generate seeds that match how the brand's category is actually organized in Google's view (rather than a brand-config-trained generative guess).
- The momentum signal lets Layer 4's redteam reject keywords that look attractive but are on a 12-month decline — an opportunity-cost critique the legacy pipeline couldn't make.
Adding Layer 0 doesn't disturb the existing chain — Layers 1–5 still run if Layer 0 produces nothing. It's pure enrichment, gated on a cheap idempotency check.
When the brand-config has no clear category
(Edge case: a personal blog or generic agency site without a sharp category positioning.) The root seed will be too broad and Topic Research returns a shallow cluster tree. The skill still runs — it writes whatever it gets, and downstream layers behave as if Layer 0 returned an empty tree. No special-casing required; the failure-handling path covers it.
Invocation from the master orchestrator
/keyword-research-pipeline calls this skill first, before Layer 1a. Because of the brand-config hash check, the master can call it on every run without worrying about waste. On a typical run with stable brand-config, Layer 0 is a < 1-second no-op.
References
.claude/skills/research/references/semrush-mcp-cheatsheet.md — Topic Research playbook (sections "Topic Research playbook" and ".Trends quick-reference")
.claude/skills/keyword-research-pipeline/references/semrush-mcp-tool-inventory.md — source of truth for the actual mcp__semrush__* tool names exposed by the connected MCP. Verify mcp__semrush__topic-research and mcp__semrush__trends-overview resolve to live tools before assuming the placeholders are correct.
.claude/skills/keyword-research-pipeline/references/semrush-metric-translation.md — KSB cluster id is the "parent topic" replacement; reference for downstream layers that read topic-graph.json.
1---2name: topic-discovery3description: Layer 0 of the keyword research pipeline. Builds a topic-graph snapshot for the brand's category before any seed work, approximated from Semrush phrase_related + phrase_questions on the category seeds plus the brand's own ranking footprint (domain_organic_unique). Idempotent on brand-config hash; never blocks the pipeline; cheap.4---56# Topic Discovery Skill (Layer 0)78> **DATA LAYER RULING (2026-06-12).** Every `mcp__semrush__<kebab-name>` tool named below is FICTIONAL (never existed on the live server), and DataForSEO is retired. Before making any data call, read [`../keyword-research-pipeline/references/semrush-data-layer.md`](./../keyword-research-pipeline/references/semrush-data-layer.md) — it maps this layer's old calls to the real Semrush MCP pattern (`get_report_schema` -> `execute_report`). The logic below (filters, thresholds, output schema) remains binding; only the data calls changed.910Take `brand-config.md` and produce two artefacts that downstream layers consume as enrichment:11121. **`content-pipeline/0-keywords/topic-graph.json`** — the Semrush Topic Research idea-cluster tree rooted at the brand's category. Layer 1a (`/seed-modifier-prompt`) reads the top 5 cluster names so the seed agent grounds its output in Semrush's actual topic graph rather than brand-config alone. Layer 5 (`/keyword-prioritization`) reads cluster-level metrics to compute the `cluster_authority_gap` boost.132. **`content-pipeline/0-keywords/trends.md`** — a short markdown summary of category-level trending queries, momentum direction, and competitor traffic shifts pulled from Semrush .Trends. Layer 4 (`/keyword-redteam`) reads this to ground its "is this keyword trending up or down today?" critique.1415This layer is **enrichment, not a gate.** If Semrush returns nothing useful, downstream layers fall through to brand-config-only behavior. Layer 0 must never block the pipeline.1617## Input1819`/topic-discovery [--regen]`2021Reads:22- `brand-config.md` — brand category positioning, brand domain23- `content-pipeline/0-keywords/topic-graph.json` — previous output (for change detection)2425`--regen` forces a fresh run even if the brand-config hash hasn't changed.2627## Process28291. **Compute brand-config hash.** SHA-256 of `brand-config.md` content. Compare against `topic-graph.json`'s `brand_snapshot` field if it exists. If unchanged AND `--regen` not passed, exit cleanly with a one-line message saying "topic graph is current, skipping" — Layer 0 stays idempotent (mirrors the pattern in `seed-modifier-prompt/SKILL.md` step 1).30312. **Read brand-config.** Extract:32 - **Brand category root** — the noun-phrase that defines the brand's category for SEO purposes. Derived from `brand-config.md`'s "Category positioning" line. If the line is verbose, condense to 1–3 words (e.g. for Pleasur.AI: "ai adult companions" → root seed `ai companion`). When in doubt, prefer the broader of two candidates — Topic Research returns richer trees from broader roots.33 - **Brand domain** — used as the .Trends root URL. Strip protocol and trailing slash.34353. **Call `mcp__semrush__topic-research`** with the brand category as the root keyword. Request the full idea-cluster tree: each cluster includes a name, headlines, questions, related searches, aggregated volume, and aggregated difficulty (KD%). Cache the raw response under `content-pipeline/0-keywords/cache/topic-research-raw.json` so re-runs without `--regen` don't refetch when only writing the structured tree.36374. **Call `mcp__semrush__trends-overview`** with the brand domain as the root URL. Pull:38 - Trending queries within the category (volume momentum up vs. down)39 - Competitor traffic shifts (domains gaining or losing share)40 - Audience overlap (which competitor audiences also visit which sites)41 Cache the raw response under `content-pipeline/0-keywords/cache/trends-raw.json`.42435. **Assemble `topic-graph.json`** as a structured tree:44 ```json45 {46 "root_seed": "ai companion",47 "brand_domain": "pleasur.ai",48 "clusters": [49 {50 "id": "ksb-cluster-id-from-semrush",51 "name": "ai girlfriend",52 "volume": 246000,53 "kd_percent": 64,54 "headlines": ["...", "..."],55 "questions": ["what is an ai girlfriend?", "..."],56 "related_searches": ["...", "..."]57 }58 ],59 "generated_at": "ISO8601 UTC",60 "brand_snapshot": "sha256-hex-of-brand-config-md",61 "_meta": {62 "source": "Semrush Topic Research + .Trends",63 "tool_inventory_ref": ".claude/skills/keyword-research-pipeline/references/semrush-mcp-tool-inventory.md"64 }65 }66 ```67 Sort clusters by `volume` descending. Cap headlines / questions / related_searches at 10 entries each per cluster — Layer 1a only consumes the top 5 clusters anyway, but downstream tooling stays bounded.68696. **Assemble `trends.md`** as readable markdown for the redteam agent:70 ```markdown71 # Category trends — {brand_domain} ({YYYY-MM-DD})7273 ## Trending up (volume momentum +)74 - keyword (vol_now → vol_prev, change %)7576 ## Trending down (volume momentum −)77 - keyword (vol_now → vol_prev, change %)7879 ## Competitor traffic shifts80 - competitor.com — gaining/losing X% organic share over the last 90 days8182 ## Audience overlap83 - competitor.com ↔ othercompetitor.com — Y% audience overlap8485 _Source: Semrush .Trends, generated {ISO timestamp}_86 ```87 Keep it tight — under 60 lines. The redteam agent reads this as ambient context, not a primary input.88897. **Write both files** atomically (write to `.tmp` then move). Print a one-line summary: cluster count, top 3 cluster names with volumes, trending-up count, trending-down count, top 3 competitors by traffic shift.9091## Output9293- `content-pipeline/0-keywords/topic-graph.json` — structured idea-cluster tree94- `content-pipeline/0-keywords/trends.md` — markdown trends summary95- `content-pipeline/0-keywords/cache/topic-research-raw.json` — raw MCP response (gitignored)96- `content-pipeline/0-keywords/cache/trends-raw.json` — raw MCP response (gitignored)9798## Idempotency99100Same contract as `seed-modifier-prompt`: re-runs without `--regen` exit cleanly when the brand-config SHA-256 hasn't changed. Two MCP calls per actual run; zero on the no-op path. Safe for the orchestrator to call on every keyword-research-pipeline invocation without worrying about cost.101102## Failure handling103104**Layer 0 never blocks the pipeline.** It's enrichment, not a gate. Specific failure modes:105106- **`mcp__semrush__topic-research` returns empty** (root seed too narrow / not indexed): write a stub `topic-graph.json` with `clusters: []`, `_meta.degraded: true`, and the root_seed used. Append a one-line entry to `content-pipeline/0-keywords/cache/topic-discovery-failed.log` with the timestamp and reason. Exit code 0. Layer 1a reads the empty `clusters` array and falls through to brand-config-only seed generation (current behavior preserved).107- **`mcp__semrush__topic-research` errors** (auth, network, server): write the same stub graph with `_meta.degraded: true` and `_meta.error: "<reason>"`. Log to `cache/topic-discovery-failed.log`. Exit 0.108- **`mcp__semrush__trends-overview` returns empty / errors**: write a `trends.md` containing only the heading and a single line `_Source: Semrush .Trends — no data returned for {brand_domain} ({YYYY-MM-DD})_`. Log to `cache/topic-discovery-failed.log`. Exit 0. The redteam agent reads the absence as "no trend signal available" and reasons without it.109- **Both calls error with the same auth code (401)**: Semrush MCP isn't connected or consent expired. Surface a clear one-line error to stderr ("Semrush MCP unauthenticated — re-run consent or check `SEMRUSH_API_KEY_BLOG_AGENT`") and exit 0 anyway. The orchestrator continues.110- **Rate-limit (429)**: persist whatever responses we did get; if neither call succeeded, write the stub artefacts and log the quota event. Exit 0 (do **not** propagate exit 75 — Layer 0 is non-blocking). The orchestrator will pick up the cached stubs on the next run.111112The discipline here is the user's call-out: "the pipeline never blocks on Layer 0 because it's enrichment, not a gate."113114## Quality checklist115116- [ ] `topic-graph.json` exists and parses as JSON117- [ ] `brand_snapshot` matches current `brand-config.md` SHA-256118- [ ] `clusters` array is sorted by volume descending (or empty + `_meta.degraded: true`)119- [ ] Each non-degraded cluster has at least: id, name, volume, headlines, questions120- [ ] `trends.md` exists and has a section heading even if empty121- [ ] Failure paths log to `cache/topic-discovery-failed.log` rather than crashing122- [ ] Two MCP calls maximum per actual run; zero on no-op123- [ ] Cache files are under `content-pipeline/0-keywords/cache/` (gitignored)124125## Why this exists126127Ryan Law's pre-Semrush method had no equivalent of Topic Research or .Trends — Ahrefs surfaced individual keywords but not the **cluster topology** of a category, and not the **time-axis momentum** signal that tells you whether a topic is rising or fading. Both are first-class signals in Semrush.128129- The cluster topology lets Layer 1a generate seeds that match how the brand's category is actually organized in Google's view (rather than a brand-config-trained generative guess).130- The momentum signal lets Layer 4's redteam reject keywords that look attractive but are on a 12-month decline — an opportunity-cost critique the legacy pipeline couldn't make.131132Adding Layer 0 doesn't disturb the existing chain — Layers 1–5 still run if Layer 0 produces nothing. It's pure enrichment, gated on a cheap idempotency check.133134## When the brand-config has no clear category135136(Edge case: a personal blog or generic agency site without a sharp category positioning.) The root seed will be too broad and Topic Research returns a shallow cluster tree. The skill still runs — it writes whatever it gets, and downstream layers behave as if Layer 0 returned an empty tree. No special-casing required; the failure-handling path covers it.137138## Invocation from the master orchestrator139140`/keyword-research-pipeline` calls this skill **first**, before Layer 1a. Because of the brand-config hash check, the master can call it on every run without worrying about waste. On a typical run with stable brand-config, Layer 0 is a < 1-second no-op.141142## References143144- `.claude/skills/research/references/semrush-mcp-cheatsheet.md` — Topic Research playbook (sections "Topic Research playbook" and ".Trends quick-reference")145- `.claude/skills/keyword-research-pipeline/references/semrush-mcp-tool-inventory.md` — source of truth for the actual `mcp__semrush__*` tool names exposed by the connected MCP. Verify `mcp__semrush__topic-research` and `mcp__semrush__trends-overview` resolve to live tools before assuming the placeholders are correct.146- `.claude/skills/keyword-research-pipeline/references/semrush-metric-translation.md` — KSB cluster id is the "parent topic" replacement; reference for downstream layers that read `topic-graph.json`.