| name | ai-visibility |
| version | 1.3 |
| description | Baseline AI visibility report — a one-page client diagnostic showing how a brand appears across ChatGPT, Claude, Perplexity, and Google AIO. Pulls live data from AEO Copilot, computes per-LLM scorecards, surfaces gaps with evidence, ranks the external domains LLMs cite, and renders a single self-contained HTML file (print-styled for Cmd+P → PDF).
Triggers: ai visibility, ai visibility report, baseline ai report, llm visibility, llm visibility report, brand visibility audit, aeo baseline, aeo report.
Requires: AEO Copilot MCP server. Optional: nothing.
Workflow: Discover → Fetch → Aggregate → Render → Save.
Command: /ai-visibility
|
AI Visibility Skill
Baseline diagnostic of a brand's presence across the four tracked AI engines (ChatGPT, Claude, Perplexity, Google AIO). A client deliverable, a single HTML page handed to a new client at the start of an engagement so they can answer in five minutes:
- How visible is my brand in LLMs today?
- What's working and what's not?
This skill is read-only and diagnostic-only for findings. Findings cite evidence (specific prompt counts, scores, citations, competitor names, source domains) and never prescribe "do this" actions inside the findings sections. The client takes the diagnosis to inform their own roadmap.
Exception — "What's next" section: the report ends with a fixed 3-step engagement block (Meet → Discovery → Project) before the appendix. This is Sofian's offer, not a data-derived prescription. It is content-locked: render verbatim from the static block in template.html. Do not regenerate, infer, rewrite, or omit it. Source of truth: references/whats-next.md.
Prerequisites
Workflow Overview
DISCOVER → FETCH → AGGREGATE → RENDER → SAVE
Critical Guards
⚡ GUARD — AEO Copilot MCP unavailable:
- Stop: "AI Visibility report requires the AEO Copilot MCP. Connect
aeo-copilot-mcp and try again."
⚡ GUARD — Brand has no tracked prompts (empty get_results and get_insights.visibility is null):
- Stop: "Brand
{name} has no tracked prompts in AEO Copilot yet. Add topics and prompts in the dashboard, run a tracking cycle, then re-run /ai-visibility."
⚡ GUARD — Filtered fetch returns empty but insights show data:
- Don't stop. The MCP date filter is unreliable. Fall back to an unfiltered
get_results call and trim client-side by runDate. Note this in the methodology footnote: "Date filter bypassed (server-side filter returned empty)."
⚡ GUARD — Prompt corpus too small (< 10 prompts):
- Warn: "Small prompt corpus ({N} prompts). Findings will be directional, not statistical. Continue?"
- If declined: stop.
⚡ GUARD — Per-row engine field missing (e.g., googleAio not in get_results rows):
- Don't stop. Render the card from
get_insights.mentionVolume aggregates only and add a footnote on that card: "Per-prompt detail unavailable; aggregate only."
⚡ GUARD — User requests abort:
- Confirm: "Stop the workflow? Progress will be lost."
- If confirmed: exit cleanly, output any partial results, still write activity-log row noting abort reason.
Phase 0: DISCOVER
0.1 MCP Discovery
Search for tools BEFORE starting:
- AEO Copilot MCP (required): search
+aeo copilot brands. If missing → stop (see guard).
0.2 Load Config
Load .claude/seo-copilot-config.json if it exists. Extract:
business.name → cross-check against AEO Copilot brand name (sanity check the user picked the right brand). If a name match exists, pre-select it in the brand picker.
seo.competitors → highlight these in the competitor landscape section if they appear.
If not found: proceed with defaults, note once "Run /getting-started for personalized recommendations."
0.3 Brand Selection
Always prompt the user — never auto-select.
-
Call list_brands.
-
Filter the list before display. Many AEO Copilot accounts contain test/example brands. Hide brands that:
- have no
website value, OR
- have
website containing example.com, test.com, or matching *.example.*, OR
- have a
name ending in 3–4 random alphanumerics (e.g. xiF, IxK, 2wfJ, YTKs) — heuristic for autogenerated test brands, OR
- have a
name starting with Test or Manual Test.
After filtering, if test brands were hidden, append: "+{N} test brands hidden, type 'all' to see them."
-
If 0 real brands after filter: stop with "No brands found in AEO Copilot. Add a brand in the dashboard first."
-
If 1 real brand: confirm: "Found one brand: {name} ({website}). Generate the baseline report for this brand?"
-
If 2+ real brands: present a numbered list (name, website, industry) and ask user to pick one. If business.name from config matches one of them, pre-select it: "Config matches {name} — proceed with this brand? (y/N to pick a different one)"
Store: brandId, brandName, brandDomain (extracted from website URL: strip https://, www., trailing slash, lowercase).
0.4 Activity Log Check
Read .claude/reports/{brandDomain}/activity-log.md if present. Surface:
- Any prior
/ai-visibility runs in the last 60 days. Note the date so the user knows whether this is a true baseline or a re-run.
- Recent activity from other skills on this brand.
If no activity log: proceed silently.
0.5 Set Date & Paths
report_date = today (YYYY-MM-DD)
report_path = .claude/reports/{brandDomain}/ai-visibility-baseline-{report_date}.html
activity_log_path = .claude/reports/{brandDomain}/activity-log.md
Create the directory if it doesn't exist: mkdir -p .claude/reports/{brandDomain}/.
Phase 1: FETCH
Call these four MCP tools in parallel:
1.1 list_topics(brandId)
Topic catalog with name, description, target customers, geographic focus, differentiation, pages, keywords. Used to label the topic-performance table and group gap prompts.
1.2 get_results(brandId, limit=500)
Fetch without date filters by default. The MCP's date filter has been observed to return zero rows even when data is inside the window. Trim client-side by runDate after the call.
If limit=500 is hit (corpus is larger), record this and add a footnote in the methodology.
1.2a Detect available engine fields
The per-row schema uses nested objects: chatgpt, claude, perplexity, googleAio — each with {mentioned, position, sentiment, sources, competitors}. Not every brand has all four fields populated.
After fetching, inspect the first non-empty row to detect which engine keys are present. Set engines_with_per_row = [keys present]. The remaining engines (e.g. googleAio) fall back to aggregate-only data from get_insights.mentionVolume.
1.2c Compute the canonical engine list
Define engines_to_report once, here, and use it everywhere downstream (Phase 2 aggregations, Phase 4 placeholders, Phase 4.5 verification). All four tracked engines are always reported. The distinction is which data source feeds each card:
engines_to_report = ['chatgpt', 'claude', 'perplexity', 'googleAio']
engines_aggregate_only = [e for e in engines_to_report if e not in engines_with_per_row]
# For each aggregate-only engine, classify its known status from get_insights.mentionVolume:
aggregate_zero_engines = [
e for e in engines_aggregate_only
if sum(t['{e}Mentions'] for t in get_insights.mentionVolume) == 0
]
# These engines are known to have missed every prompt — they must appear
# in every gap row's missed list (Phase 2.3).
aggregate_partial_engines = [e for e in engines_aggregate_only if e not in aggregate_zero_engines]
# These have some mentions but no per-prompt detail — omit from per-row
# missed list and footnote on the engine card instead.
Single source of truth. If you need to answer "which engines am I reporting on?" anywhere downstream, read engines_to_report. Do not re-derive from engines_with_per_row alone.
1.2b Compute the data window
all_run_dates = [r.runDate for r in results]
data_from = min(all_run_dates)
data_to = max(all_run_dates)
window_days = (data_to - data_from).days
Set the dataWindowLabel placeholder accordingly:
window_days | dataWindowLabel |
|---|
| 0–13 | "the last sync ({data_to})" |
| 14–60 | "the past {window_days} days" |
| 61–120 | "the past 90 days" |
| > 120 | "the past {window_days} days" |
Do not say "last 90 days" when the actual data window is one week. Adapt to what's there.
1.3 get_insights(brandId)
Pre-computed: visibility score, sentiment breakdown, competitive share, weekly trends, top topics, competitor list, mention volume per topic per engine. Use the platform's score as-is — do not recompute the composite.
1.4 get_recommendations(brandId)
Used as one signal feeding the "what's not working" findings. Never surface as prescriptions — translate into descriptive findings ("X gap detected" not "do Y to fix it").
Phase 2: AGGREGATE
Compute five derived datasets the API doesn't return directly. Every aggregation must preserve the underlying counts so they can be cited as evidence.
2.1 Per-LLM Scorecard
For each engine in engines_with_per_row, iterate get_results rows and compute:
- Mention rate % =
mentioned_count / total_prompts × 100
- Avg position when mentioned (skip rows where the engine didn't mention; report "n/a" if 0 mentions)
- Sentiment split: counts of positive / neutral / negative across mentioned rows
- Strongest topic: topic with highest mention rate on this engine (ties broken by prompt count)
- Weakest topic: topic with lowest mention rate on this engine (where prompt count ≥ 3 — ignore tiny topics)
For engines NOT in engines_with_per_row, fill the card from get_insights.mentionVolume only:
- Mention rate % =
Σ engine_mentions across topics / total_prompts × 100
- Avg position, sentiment split, strongest/weakest topic = "n/a (aggregate only)"
- Add a small footnote on the card: "Per-prompt detail unavailable on this engine."
2.2 Topic Performance Table
For each topic, compute:
- Prompt count
- Combined visibility % across the engines that have per-row data:
total_mentions / (engine_count × prompt_count) × 100. If only aggregate data exists, fall back to get_insights.topTopics[i].visibility.
- Avg position when mentioned (across the available engines)
- Sentiment skew: dominant sentiment label (positive / neutral / negative / mixed)
Sort by visibility % descending.
2.3 Gap Inventory
A prompt is a gap if the brand is mentioned in 0 or 1 of the engines in engines_with_per_row. For each gap prompt, capture:
- The prompt text
- The topic it belongs to
- Which engines missed the brand (with the display label, not the key — "Google AIO" not "googleAio")
- Which competitors appeared in those engines (deduplicated, ranked by frequency)
Building missed_engines for each gap row — three rules, applied in order:
- Per-row engines: for each engine in
engines_with_per_row where row[engine].mentioned == False, add the display label.
- Aggregate-zero engines: every engine in
aggregate_zero_engines (Phase 1.2c) is known to have missed every prompt — append to the missed list of every gap row.
- Aggregate-partial engines: omit from per-row
missed_engines. We don't know which specific prompts they missed. The engine card footnote already documents this.
This is non-negotiable: when a brand has zero mentions on an aggregate-only engine (e.g., Google AIO with googleAioMentions: 0 across all topics), the appendix's "Missing on" column must include that engine for every row. Phase 4.5 verifies this.
2.4 Competitor Share-of-Voice on Gaps
Across all gap prompts, count competitor appearances. Top 5 by count. For each, also record:
- Number of gap prompts they appear in
- Topics where they dominate (topics where this competitor's count > brand's count)
2.5 External Source Influence
Aggregate the domains LLMs cite when answering the brand's prompts. This shows the evidence base the LLMs are reading from — and whether the brand's own domain is part of it.
For each row in get_results:
- Take
r.mainSource (if present)
- Take every entry in
r.{engine}.sources for each engine in engines_with_per_row
Normalize each source:
- If it's a URL:
urlparse(s).netloc.lower().replace('www.', ''). Keep the host.
- If it has no dot or contains spaces (descriptive label like "Salesforce Partner Directory"): drop. The aggregate is for citable domains only.
Count occurrences. Top 10 by count. For each:
domain, citations, is_owned (true if domain == brandDomain).
Also compute and surface:
unique_sources = total unique domains
total_citations = sum of all citations
brand_in_sources = sources_count.get(brandDomain, 0)
If brand_in_sources is 0 or below 5% of total_citations, generate a finding for "What's not working": "{brandDomain} appears in {brand_in_sources} of {total_citations} citations. The brand isn't part of the evidence base the LLMs are reading from."
2.6 Engine Headline
A one-line insight rendered above the per-LLM grid. The reader should walk away with the engine takeaway in 5 seconds without scanning all 4 cards.
Algorithm (deterministic, no creative phrasing):
- From the per-LLM mention rates (Phase 2.1), identify
min_rate (weakest engine), max_rate (strongest), and median_all (median of the four). Also note the min and max of the other three engines when isolating the weakest (others_min, others_max).
- If
min_rate ≤ median_all − 15 percentage points → frame the weakness:
"{Engine} is the soft spot at {min_rate}%. The other three engines sit between {others_min}% and {others_max}%."
- Else if
max_rate ≥ median_all + 15 percentage points → frame the lead:
"{Engine} leads at {max_rate}%, well ahead of the {others_min}–{others_max}% range on the others."
- Else (engines clustered) → frame parity:
"All four engines mention the brand within a {max_rate − min_rate}-point band ({min_rate}–{max_rate}%). No single engine is the standout."
Always renders one of the three forms, never null. Render verbatim, do not embellish. Pass the result through Phase 3.4 voice rules before output.
Phase 3: BUILD FINDINGS
Generate the narrative content for sections 5 (What's working) and 6 (What's not working). Every finding follows the same structure:
{one-sentence claim}. {evidence line with concrete numbers}.
Evidence-first principle (non-negotiable): every finding must cite at least one of:
- A prompt count (e.g., "11 of 62 prompts")
- A score with its band (e.g., "B grade, 68/100")
- A specific competitor name (e.g., "Competitor X appears in 19 of 26 'vs' prompts")
- A specific source URL or topic name
No floating claims like "the brand performs well" without a number behind it.
3.1 What's Working — 0 to 5 findings
Source from:
- Engines or topics with mention rate ≥ 40%
- Topics with positive sentiment ≥ 50% of mentions
- Position metrics where avg position ≤ 3
- Source attribution wins (brand domain in
External Source Influence top 10)
If 0–2 working findings exist, collapse the section into a paragraph and be honest about it: "There isn't much to celebrate yet. {single best signal with number}."
3.2 What's Not Working — 3 to 5 findings
Source from:
- Engines or topics with mention rate ≤ 20%
- Topics with high prompt count (≥ 5) and zero brand mentions on any engine
- Sentiment patterns where negative ≥ 25% of mentions
- Competitor dominance: topics where one competitor outperforms the brand by 2× or more
- Source absence: brand domain in 0 or < 5% of source citations (Phase 2.5)
- Technical gaps surfaced by
get_recommendations — translate prescriptions into descriptive findings (e.g., recommendation "Add llms.txt" → finding "No llms.txt detected on the domain")
3.3 Format Adaptation
Per repo CLAUDE.md output guidance:
- If a section has fewer than 3 findings, collapse into a paragraph instead of bullets.
- Never output a section header with nothing under it.
- Prefer specificity over completeness — one concrete finding beats five vague ones.
3.4 Voice Pass (mandatory)
Before rendering, run all generated copy (TL;DR, scorecard drivers, working/gap findings, source intro) through the rules in references/voice-rules.md. Specifically:
- Strip every em dash. Replace with commas, periods, or colons.
- Cut filler ("showcasing", "highlighting", "natural beachhead", "currently aligned", "presumed in order").
- Kill rule-of-three padding when the third item carries no extra weight.
- Use "is"/"are" instead of "serves as", "stands as", "functions as".
- Cite the count, not just the rate ("11 of 62" beats "18%").
After the first pass, do a single audit sentence: "What about this would make a smart reader assume an LLM wrote it?" Answer in one line, then revise the offending sentences. Single audit pass — don't loop.
This step is non-negotiable for client deliverables. Sofian's global CLAUDE.md mandates /humanizer on all client-facing copy.
Phase 4: RENDER
4.1 Load template
Read template.html from this skill's directory. It contains the full HTML/CSS skeleton with {{placeholder}} tokens for every dynamic value. Mustache-style sections ({{#name}}...{{/name}}) are repeated per-item. The triple-braced {{{engineLogoSvg}}} is rendered without HTML escaping (the SVG is trusted, sourced from references/engine-logos.md).
4.2 Fill placeholders
Replace placeholders with computed values. The template defines all of them — see the template file for the full list. Key groups:
- Header: brand name, domain, date, overall grade, TL;DR
- Executive scorecard: 3 score cards (AI Visibility, Technical Readiness, Sentiment Health) with band labels. Sentiment Health uses the platform value but the driver text must clarify brand-vs-category (see
references/scoring-notes.md "Brand sentiment vs category sentiment").
- Engine headline: one-line
{{engineHeadline}} from Phase 2.6, rendered above the per-LLM grid
- Per-LLM grid: card per engine with
{{{engineLogoSvg}}}, engineName, mention rate, avg position, sentiment bar, strongest topic, weakest topic. Use the labels from references/engine-logos.md (Google AIO, not Google AI Overview).
- Topic table: rows from Phase 2.2
- What's working / what's not working: rendered as
<li> evidence lines (or <p> if collapsed)
- Competitor landscape: top 5 with bar widths proportional to share-of-voice. Append the brand's own row at the bottom for transparency, with its actual mention count.
- External source influence: top 10 from Phase 2.5. Mark
is_owned on the brand's own domain if it appears.
- Appendix table: full gap-prompt inventory from Phase 2.3
- Footer: methodology,
dataWindowLabel, dataFromDate, dataToDate, totalPrompts, totalTopics, corpusBand (statistical/directional/single-cycle), enginesTrackedList, lastSyncDate
4.3 Visual design
Required:
- Editorial serif for headlines (Fraunces or similar), clean sans-serif for body
- Neutral professional palette: white background, near-black text, one accent color
- Engine logos: inline SVG from
references/engine-logos.md, brand colors
- Print-styled:
@page { size: A4; margin: 16mm }, page-break-inside: avoid on cards and tables
- Print color rendering: the template includes
print-color-adjust: exact on every colored element. Do not remove these rules. Browsers default to economy-print and strip backgrounds, which would erase bars, pills, badges, and the third-party section tag in PDF.
- Self-contained: inline CSS, web fonts via
<link> only (no external JS, no images that aren't inline SVG or data URIs)
4.4 Section order
- Header (brand + grade + TL;DR)
- Executive scorecard
- Per-LLM visibility (engine headline from Phase 2.6 + 4-card grid)
- Topic performance (table)
- What's working (evidence-backed findings)
- What's not working (evidence-backed findings)
- Competitor landscape (top 5 bar chart, plus brand row)
- External source influence (top 10 cited domains, with "Third-party" tag)
- What's next (static, content-locked — render verbatim from
template.html; canonical copy in references/whats-next.md)
- Appendix: gap prompts (full evidence table)
- Methodology footer
Phase 4.5: VERIFY
Before writing the file in Phase 5, the rendered HTML must pass all five checks. If any fail, loop back to Phase 4 with a specific diff message and re-render. This is the acceptance pass that prevents silent regressions.
4.5.1 No leftover placeholders
assert re.findall(r'\{\{[^}]+\}\}', rendered_html) == []
Any {{token}} left in the output means the placeholder set in template.html drifted from the placeholder set in Phase 4.2. Surface the exact tokens in the failure message.
4.5.2 Section completeness
Count <!-- ========== ... ========== --> markers in template.html. Every one must have a matching marker in the rendered output. Mismatch = a section was accidentally stripped (regex misfire, mustache block ate too much).
4.5.3 Engine coverage in appendix
For every engine in engines_to_report (Phase 1.2c), one of these must hold:
- It appears in at least one gap row's "Missing on" cell, OR
- It is in
aggregate_partial_engines (per-prompt unknown — exempt from this check), OR
- The brand has 100% mention rate on it (no gap row exists where it's missed — also exempt).
If an engine in aggregate_zero_engines is not present in every gap row's missed list, fail with: "Engine {e} has zero aggregate mentions but is missing from {N} gap rows. Phase 2.3 rule 2 was not applied."
This is the check that would have caught the Google AIO bug.
4.5.4 Gap row count consistency
assert count_appendix_rows(rendered_html) == len(gap_inventory)
If the rendered table has fewer rows than the gap inventory, a render template loop truncated the data.
4.5.5 Internal numeric consistency
totalCitations placeholder == sum of citations across the rendered top-N source rows + remainder (the long tail not in top 10). Tolerate ±1 for rounding.
mentionCount for each engine card == platform mentionVolume for that engine summed across topics. Tolerate ±0.
gapPromptCount == len(gap_inventory).
If any number rendered in the report disagrees with the underlying aggregate computed in Phase 2, fail with the specific delta.
Failure protocol
When a check fails:
- Output the exact failure message (which check, which value, which expected value).
- Loop back to Phase 4 to re-render with the fix.
- Do not write the file in Phase 5 until all five pass.
- After two consecutive fail loops, abort and surface the bug to the user. Do not write a partial report.
This phase is non-negotiable. The cost is ~2 seconds per render. The alternative is the user finding bugs in client deliverables.
Phase 5: SAVE & LOG
5.1 Write report
Write the rendered HTML to report_path.
5.2 Verify in print preview
Before declaring success, mention to the user that the print fidelity has been wired in. The bars, pills, badges, and section tags should render in PDF. If the user reports white-on-white in print, the template's print-color-adjust rules have been tampered with — restore from template.html.
5.3 Activity log
Append to activity_log_path. If the file doesn't exist, create it with the standard header:
# Activity Log — {brandDomain}
| Date | Skill | Summary |
|------|-------|---------|
Append one row:
| YYYY-MM-DD | /ai-visibility | Baseline AI visibility report. Grade: {X}. Visibility: {N}%. Strongest engine: {engine} ({M}%). Weakest: {engine} ({M}%). {brandDomain} in {K}/{T} source citations. |
Log even on early exit (with reason).
5.4 Success message
Output to the user:
✅ Baseline AI visibility report saved.
Path: .claude/reports/{brandDomain}/ai-visibility-baseline-{date}.html
To export as PDF: open the file in a browser, press Cmd+P, choose "Save as PDF".
Top finding: {one-line TL;DR pulled from the report header}.
References
references/scoring-notes.md — AEO Copilot scoring formula (60% topic / 25% technical / 15% sentiment, A–F bands), plus the brand-vs-category sentiment distinction. Load this when generating findings.
references/voice-rules.md — Humanizer checklist for the Phase 3.4 voice pass. Em dash hunting, filler list, audit prompt.
references/engine-logos.md — Inline SVG paths for ChatGPT, Claude, Perplexity, Google AIO. Brand colors and display labels. Load this in Phase 4.2 when filling per-LLM cards.
references/whats-next.md — Canonical copy for the content-locked "What's next" engagement section. Edit here, then mirror into the <!-- WHATS_NEXT --> block in template.html.
template.html — HTML/CSS skeleton with placeholders.
Integration with Other Skills
| Finding | Skill | When |
|---|
| Low engine visibility, content gaps | /aeo-optimize {url} | Page-level AEO rewrite |
| Schema/llms.txt gaps surfaced | /aeo-optimize:audit | Quick AEO scoring |
| Topic coverage gaps | /keywords-opportunity:discover | Find new content topics |
| Brand absent from source list | /aeo-optimize on the brand's pillar pages | Make pages more citable |
| Track progress over time | AEO Copilot dashboard | Weekly automated tracking |