Shows a client's live Search Console, rank tracking and AI visibility numbers from DataWise above the prompt.

Three Claude Code mods for SEO work: live rankings while Claude works, a guard against keyword cannibalization, and a filter that keeps AI slop off your pages.
Client Pulse and Cannibalization Guard read their data from DataWise SEO through its Claude connector.
claude update).In a Claude Code terminal session:
/plugin install client-pulse --marketplace NicoSKOOL/seo-mods
/plugin install cannibal-guard --marketplace NicoSKOOL/seo-mods
/plugin install slop-filter --marketplace NicoSKOOL/seo-mods
Answer y to add the marketplace, then choose the user scope.
Put a .seo-client.json in the root of each client's website project:
{ "domain": "example.com" }
Optional fields:
| Field | What it does |
|---|---|
locationCode, languageCode | DataForSEO market for ranked keywords (for example 2152 and es for Chile) |
contentDirs | Folders holding your pages (default: src/content, src/pages, content, pages) |
cannibalization.minImpressions | Search Console impressions before a keyword counts as taken (default 20) |
cannibalization.rankedKeywords | false to skip the DataForSEO lookup |
datawiseServer | The DataWise connector's name if /mcp shows a different one (default claude.ai DataWise SEO) |
Open Claude Code in the client project and the band appears. Data is cached for 6 hours.
/pulse refreshes the numbers and shares them with Claude./pulse otherclient.com switches client.× hides the band.The Guard checks new pages that declare a target keyword, in any of these forms:
---
keyword: emergency dentist austin
---
const keyword = "emergency dentist austin" in an Astro file, or <!-- keyword: emergency dentist austin --> in HTML.
Add this line to your project's CLAUDE.md so every new page gets one:
Every new page gets a
keyword:field in its frontmatter with its primary target keyword.
When the Guard blocks a page, Claude explains what already covers the keyword. If you still want the new page, tell Claude, and it adds cannibalization: ok to the frontmatter.
It works out of the box. Every time Claude writes or edits website copy (.astro, .html, .mdx, and .md inside your content folders), the filter scores the new text from 0 to 100:
oaicite, utm_source=chatgpt.com) and unfilled placeholders like [Your Phone Number].When it blocks, Claude gets the list of tells with examples and rewrites the copy. The status line shows each page's score. If Claude cannot get a page under the bar in two tries, the third write goes through with a warning, so it never loops.
Tune it under slop in .seo-client.json:
{
"slop": {
"threshold": 60,
"minTells": 3,
"mode": "block",
"banned": ["leading provider", "state-of-the-art"],
"allow": ["Heart of Texas"],
"emDash": "block"
}
}
| Field | What it does |
|---|---|
threshold | Score below which a page is blocked (default 60) |
minTells | Different tells needed before a block (default 3) |
mode | warn writes the page and only flags it to Claude |
banned | Your own phrases that always block |
allow | Names or phrases never to flag (a business name like "Vibrant Smiles") |
emDash | block, count (default) or off |
To publish a page anyway, tell Claude, and it adds slop: ok to the frontmatter.
Patterns draw on Wikipedia's Signs of AI writing, slop-score and slop-guard.
hooks/register.tsx 391 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pulse, PulseStatus } from '../types'
5
6// The DataWise connector's name as /mcp lists it. A project can override it
7// with "datawiseServer" in .seo-client.json.
8const DEFAULT_SERVER = 'claude.ai DataWise SEO'
9const CONFIG_FILE = '.seo-client.json'
10const MAX_AGE_MS = 6 * 60 * 60 * 1000
11
12const status = atom({ plugin: 'client-pulse', key: 'status' } as const, { kind: 'idle' } as PulseStatus)
13const isHidden = atom({ plugin: 'client-pulse', key: 'isHidden' } as const, false)
14
15type Config = { domain: string; server: string }
16type Row = Record<string, any>
17
18export function bareDomain(url: string): string {
19 return url
20 .toLowerCase()
21 .replace(/^sc-domain:/, '')
22 .replace(/^https?:\/\//, '')
23 .replace(/^www\./, '')
24 .replace(/[/?#].*$/, '')
25}
26
27export function percentChange(current: number, previous: number): number | null {
28 return previous > 0 ? Math.round(((current - previous) / previous) * 100) : null
29}
30
31/** Last 7 days of the daily trend against the 7 before them. */
32export function weekOverWeek(daily: { date: string; clicks: number; impressions: number }[]) {
33 const sorted = [...daily].sort((a, b) => a.date.localeCompare(b.date))
34 const last = sorted.slice(-7)
35 const prev = sorted.slice(-14, -7)
36 const sum = (rows: typeof sorted, field: 'clicks' | 'impressions') =>
37 rows.reduce((total, row) => total + (row[field] ?? 0), 0)
38
39 return {
40 syncedTo: last.at(-1)?.date ?? null,
41 clicksDaily: sorted.slice(-14).map(row => row.clicks ?? 0),
42 impressionsDaily: sorted.slice(-14).map(row => row.impressions ?? 0),
43 clicks7: sum(last, 'clicks'),
44 clicksDelta: prev.length === 7 ? percentChange(sum(last, 'clicks'), sum(prev, 'clicks')) : null,
45 impressions7: sum(last, 'impressions'),
46 impressionsDelta:
47 prev.length === 7 ? percentChange(sum(last, 'impressions'), sum(prev, 'impressions')) : null,
48 }
49}
50
51export function trackedMovement(keywords: Row[]) {
52 const moved = (row: Row, isWorse: boolean) => {
53 if (row.prev_position == null) return false
54 if (row.position == null) return isWorse
55 return isWorse ? row.position > row.prev_position : row.position < row.prev_position
56 }
57
58 return {
59 ranking: keywords.filter(row => row.position != null).length,
60 total: keywords.length,
61 up: keywords.filter(row => moved(row, false)).length,
62 down: keywords.filter(row => moved(row, true)).length,
63 }
64}
65
66/** Citations across engines on the most recent check date. */
67export function latestAiCitations(trend: Row[]) {
68 const latest = trend.map(row => String(row.date)).sort().at(-1)
69 if (latest === undefined) return null
70 const rows = trend.filter(row => row.date === latest)
71
72 return {
73 cited: rows.reduce((total, row) => total + (row.cited ?? 0), 0),
74 total: rows.reduce((total, row) => total + (row.total ?? 0), 0),
75 }
76}
77
78/**
79 * The Search Console property to read for a domain: a domain property
80 * (sc-domain:) covers every protocol and subdomain, so it beats any URL-prefix
81 * one; then enabled before disabled, then the most recently synced.
82 */
83export function pickProperty(properties: Row[], domain: string): Row | undefined {
84 const rank = (row: Row) => [String(row.site_url).startsWith('sc-domain:') ? 1 : 0, Number(row.is_enabled) ? 1 : 0]
85
86 return properties
87 .filter(row => row.kind === 'gsc' && bareDomain(row.site_url) === domain)
88 .sort((a, b) => {
89 const [aDomain, aEnabled] = rank(a)
90 const [bDomain, bEnabled] = rank(b)
91 return bDomain! - aDomain! || bEnabled! - aEnabled! || String(b.last_synced_at ?? '').localeCompare(String(a.last_synced_at ?? ''))
92 })[0]
93}
94
95async function datawise($: EngineInterface, server: string, tool: string, args: Row): Promise<Row> {
96 const result = await $.mcp.call(server, tool, args)
97 const text = result.content.find(block => block.type === 'text')?.text ?? ''
98 if (result.isError) throw new Error(`${tool}: ${text.slice(0, 200)}`)
99
100 return JSON.parse(text)
101}
102
103async function loadConfig($: EngineInterface): Promise<Config | null> {
104 const path = `${await $.session.cwd()}/${CONFIG_FILE}`
105 if (!(await $.fs.exists(path))) return null
106 const raw = JSON.parse(await $.fs.read(path))
107 if (typeof raw.domain !== 'string' || raw.domain === '') return null
108
109 return { domain: bareDomain(raw.domain), server: raw.datawiseServer ?? DEFAULT_SERVER }
110}
111
112async function fetchPulse($: EngineInterface, config: Config): Promise<Pulse> {
113 const { domain, server } = config
114 const { properties = [] } = await datawise($, server, 'datawise_search_console', {
115 action: 'list_properties',
116 })
117 const property = pickProperty(properties as Row[], domain)
118 if (property === undefined) throw new Error(`no Search Console property for ${domain} in DataWise`)
119
120 const { overview } = await datawise($, server, 'datawise_search_console', {
121 action: 'overview',
122 property_id: property.id,
123 range_days: 30,
124 response_format: 'detailed',
125 })
126 const week = weekOverWeek(overview.daily_trend ?? [])
127 const opportunity = (overview.opportunities ?? overview.range?.opportunities ?? [])[0]
128
129 let tracked: Pulse['tracked'] = null
130 let aiCited: Pulse['aiCited'] = null
131 try {
132 const { projects = [] } = await datawise($, server, 'datawise_rank_tracking', { action: 'list_projects' })
133 const project = (projects as Row[])
134 .filter(row => bareDomain(row.domain) === domain)
135 .sort((a, b) => b.keyword_count - a.keyword_count)[0]
136 if (project !== undefined) {
137 const { keywords = [] } = await datawise($, server, 'datawise_rank_tracking', {
138 action: 'project_keywords',
139 project_id: project.id,
140 })
141 tracked = keywords.length > 0 ? trackedMovement(keywords) : null
142 const { trend = [] } = await datawise($, server, 'datawise_ai_visibility', {
143 project_id: project.id,
144 period: 30,
145 })
146 aiCited = latestAiCitations(trend)
147 }
148 } catch {
149 // Rank tracking and AI visibility are extras; the band still shows Search Console.
150 }
151
152 return {
153 domain,
154 ...week,
155 avgPosition: overview.summary?.last_30_days?.avg_position ?? null,
156 tracked,
157 aiCited,
158 topOpportunity: opportunity
159 ? { query: opportunity.query, impressions: opportunity.impressions, position: opportunity.avg_position }
160 : null,
161 fetchedAt: await $.clock.now(),
162 }
163}
164
165async function refresh($: EngineInterface, config: Config, isForced: boolean): Promise<PulseStatus> {
166 const cacheKey = `pulse:v3:${config.domain}`
167 const cached = (await $.store.get(cacheKey)) as Pulse | undefined
168 const isFresh = cached !== undefined && (await $.clock.now()) - cached.fetchedAt < MAX_AGE_MS
169
170 if (cached !== undefined) await update($, status, (): PulseStatus => ({ kind: 'ready', pulse: cached }))
171 if (isFresh && !isForced) return { kind: 'ready', pulse: cached }
172 if (cached === undefined) await update($, status, (): PulseStatus => ({ kind: 'loading', domain: config.domain }))
173
174 try {
175 const pulse = await fetchPulse($, config)
176 await $.store.set(cacheKey, pulse)
177 const next: PulseStatus = { kind: 'ready', pulse }
178 await update($, status, () => next)
179 return next
180 } catch (error) {
181 const next: PulseStatus = { kind: 'error', domain: config.domain, message: String((error as Error).message ?? error) }
182 if (cached === undefined) await update($, status, () => next)
183 return next
184 }
185}
186
187const SPARK = '▁▂▃▄▅▆▇█'
188
189/** A one-line bar chart of a series, scaled between its own min and max. */
190export function sparkline(values: number[] | undefined): string {
191 if (!values || values.length === 0) return ''
192 const min = Math.min(...values)
193 const span = Math.max(...values) - min
194 return values.map(v => SPARK[span === 0 ? 3 : Math.round(((v - min) / span) * (SPARK.length - 1))]).join('')
195}
196
197const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
198const shortDate = (iso: string) => {
199 const [, month, day] = iso.split('-').map(Number)
200 return month && day ? `${MONTHS[month - 1]} ${day}` : iso
201}
202
203const signed = (n: number | null) => (n === null ? '' : ` (${n > 0 ? '+' : ''}${n}%)`)
204const compact = (n: number) => (n >= 10000 ? `${(n / 1000).toFixed(1)}K` : n.toLocaleString('en-US'))
205
206export function summarize(pulse: Pulse): string {
207 const parts = [
208 `${pulse.domain}`,
209 `clicks 7d ${compact(pulse.clicks7)}${signed(pulse.clicksDelta)}`,
210 `impressions 7d ${compact(pulse.impressions7)}${signed(pulse.impressionsDelta)}`,
211 ]
212 if (pulse.avgPosition !== null) parts.push(`avg position ${pulse.avgPosition}`)
213 if (pulse.tracked) {
214 parts.push(`tracked ${pulse.tracked.ranking}/${pulse.tracked.total} ranking (${pulse.tracked.up} up, ${pulse.tracked.down} down)`)
215 }
216 if (pulse.aiCited) parts.push(`AI citations ${pulse.aiCited.cited}/${pulse.aiCited.total}`)
217 if (pulse.topOpportunity) {
218 parts.push(`top opportunity "${pulse.topOpportunity.query}" (${pulse.topOpportunity.impressions} impressions, position ${pulse.topOpportunity.position})`)
219 }
220 if (pulse.syncedTo) parts.push(`data through ${pulse.syncedTo}`)
221
222 return parts.join(' · ')
223}
224
225export const register: Register = on => {
226 let config: Config | null = null
227
228 on('session.start', async ($, e, next) => {
229 await $.command.register({
230 name: 'pulse',
231 description: 'Client Pulse: refresh the DataWise numbers, or switch client with /pulse example.com',
232 argumentHint: '[domain]',
233 })
234 config = await loadConfig($).catch(() => null)
235 const startConfig = config
236 if (startConfig !== null) $.clock.after(1, () => void refresh($, startConfig, false))
237
238 return next(e)
239 })
240
241 on('command.run', { command: 'pulse' }, async ($, e) => {
242 const domain = e.args.trim()
243 if (domain !== '') {
244 config = { domain: bareDomain(domain), server: config?.server ?? DEFAULT_SERVER }
245 }
246 if (config === null) {
247 return { text: `Client Pulse: no client here. Add ${CONFIG_FILE} with {"domain": "example.com"} or run /pulse example.com` }
248 }
249 await update($, isHidden, () => false)
250 const result = await refresh($, config, true)
251
252 return result.kind === 'ready'
253 ? { text: `Client Pulse: ${summarize(result.pulse)}` }
254 : { text: `Client Pulse: could not load ${config.domain}: ${result.kind === 'error' ? result.message : 'still loading'}` }
255 })
256
257 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
258 const current = await read($, status)
259 if (e.props.hasSurvey || current.kind === 'idle' || (await read($, isHidden))) return next(e)
260
261 const { Box, Button, Text } = $.ui.resolve(e)
262 const isWide = e.props.bodyColumns >= 96 && e.props.maxRows >= 7
263 const reload = config
264 const controls = (
265 <Box gap={1}>
266 {reload !== null && (
267 <Button key="refresh" plain label="↻" onPress={() => void refresh($, reload, true)} />
268 )}
269 <Button key="hide" plain role="dismiss" label="×" onPress={() => update($, isHidden, () => true)} />
270 </Box>
271 )
272 const header = (domain: string, note: string) => (
273 <Box justifyContent="space-between">
274 <Text>
275 <Text color="claude">◆ </Text>
276 <Text bold>{domain}</Text>
277 </Text>
278 <Box gap={2}>
279 <Text color="subtle">{note}</Text>
280 {controls}
281 </Box>
282 </Box>
283 )
284 const card = (children: unknown) => (
285 <Box flexDirection="column" borderStyle="round" borderColor="promptBorder" paddingX={1}>
286 {children as never}
287 </Box>
288 )
289
290 if (current.kind === 'loading') {
291 return card(header(current.domain, 'loading from DataWise…'))
292 }
293 if (current.kind === 'error') {
294 return card(
295 <Box flexDirection="column">
296 {header(current.domain, 'DataWise')}
297 <Text color="error">{current.message}</Text>
298 </Box>,
299 )
300 }
301
302 const pulse = current.pulse
303 const note = `Search Console via DataWise${pulse.syncedTo ? ` · data to ${shortDate(pulse.syncedTo)}` : ''}`
304 const delta = (n: number | null, isInverted = false) => {
305 if (n === null) return null
306 const isGood = isInverted ? n < 0 : n > 0
307 return (
308 <Text color={n === 0 ? 'subtle' : isGood ? 'success' : 'error'}>
309 {' '}
310 {n > 0 ? '▲' : n < 0 ? '▼' : '•'} {Math.abs(n)}%
311 </Text>
312 )
313 }
314 const opportunity = pulse.topOpportunity && (
315 <Text wrap="truncate-end">
316 <Text color="suggestion">→ </Text>
317 <Text color="subtle">Top opportunity </Text>
318 <Text bold>"{pulse.topOpportunity.query}"</Text>
319 <Text color="subtle">
320 {' '}
321 ranks #{Math.round(pulse.topOpportunity.position)} with {compact(pulse.topOpportunity.impressions)} impressions
322 </Text>
323 </Text>
324 )
325
326 if (!isWide) {
327 return (
328 <Box flexDirection="column">
329 <Box justifyContent="space-between">
330 <Text wrap="truncate-end">
331 <Text color="claude">◆ </Text>
332 <Text bold>{pulse.domain}</Text>
333 <Text color="subtle"> clicks </Text>
334 <Text bold>{compact(pulse.clicks7)}</Text>
335 {delta(pulse.clicksDelta)}
336 <Text color="subtle"> impr </Text>
337 <Text bold>{compact(pulse.impressions7)}</Text>
338 {delta(pulse.impressionsDelta)}
339 </Text>
340 {controls}
341 </Box>
342 {opportunity}
343 </Box>
344 )
345 }
346
347 const stat = (key: string, label: string, value: string, extra: unknown, line?: string) => (
348 <Box key={key} flexDirection="column" minWidth={16}>
349 <Text color="subtle">{label}</Text>
350 <Text>
351 <Text bold>{value}</Text>
352 {extra as never}
353 </Text>
354 <Text color="suggestion">{line ?? ' '}</Text>
355 </Box>
356 )
357 const ratio = (part: number, whole: number) => (whole > 0 ? Math.round((part / whole) * 100) : 0)
358
359 return card(
360 <Box flexDirection="column">
361 {header(pulse.domain, note)}
362 <Box gap={3} marginTop={1}>
363 {stat('clicks', 'CLICKS · 7D', compact(pulse.clicks7), delta(pulse.clicksDelta), sparkline(pulse.clicksDaily))}
364 {stat('impressions', 'IMPRESSIONS · 7D', compact(pulse.impressions7), delta(pulse.impressionsDelta), sparkline(pulse.impressionsDaily))}
365 {pulse.avgPosition !== null && stat('position', 'AVG POSITION', String(pulse.avgPosition), null, 'last 30 days')}
366 {pulse.tracked &&
367 stat(
368 'tracked',
369 'TRACKED KEYWORDS',
370 `${pulse.tracked.ranking}/${pulse.tracked.total}`,
371 <Text>
372 {pulse.tracked.up > 0 && <Text color="success"> ▲{pulse.tracked.up}</Text>}
373 {pulse.tracked.down > 0 && <Text color="error"> ▼{pulse.tracked.down}</Text>}
374 </Text>,
375 'ranking in top 100',
376 )}
377 {pulse.aiCited &&
378 stat(
379 'ai',
380 'AI CITATIONS',
381 `${pulse.aiCited.cited}/${pulse.aiCited.total}`,
382 <Text color="subtle"> {ratio(pulse.aiCited.cited, pulse.aiCited.total)}%</Text>,
383 'latest check',
384 )}
385 </Box>
386 {opportunity}
387 </Box>,
388 )
389 })
390}
391types/index.d.ts 30 lines1export type Pulse = {
2 domain: string
3 /** Last date Search Console data covers (YYYY-MM-DD). */
4 syncedTo: string | null
5 clicks7: number
6 /** Daily clicks and impressions, oldest first, for the sparklines (up to 14 days). */
7 clicksDaily: number[]
8 impressionsDaily: number[]
9 clicksDelta: number | null
10 impressions7: number
11 impressionsDelta: number | null
12 avgPosition: number | null
13 tracked: { ranking: number; total: number; up: number; down: number } | null
14 aiCited: { cited: number; total: number } | null
15 topOpportunity: { query: string; impressions: number; position: number } | null
16 fetchedAt: number
17}
18
19export type PulseStatus =
20 | { kind: 'idle' }
21 | { kind: 'loading'; domain: string }
22 | { kind: 'ready'; pulse: Pulse }
23 | { kind: 'error'; domain: string; message: string }
24
25declare module 'claude-code' {
26 interface PluginState {
27 'client-pulse': { status: PulseStatus; isHidden: boolean }
28 }
29}
30