Build an AI share-of-voice monitor with Claude Code
The fastest way to build an AI share-of-voice monitor is to stop scraping and let an agent do the counting: connect AgentGEO's MCP server to Claude Code with claude mcp add agentgeo -- npx -y agentgeo-mcp --key ag_live_..., then ask it to fetch your query set across all six surfaces and tally, per engine, how many answers name your brand versus every competitor. Share of voice is that ratio - your mentions over all brand mentions - computed one engine at a time.
The MCP route is the quickest way to a first number, and it lives where your context already is. But a monitor is something that runs on a schedule and diffs against itself, so this guide gives you both: the plain-language version in Claude Code to prove the idea in a minute, and the ~60-line Python version you put on a cron once you want history. The discipline that keeps the number honest is the same in both - count mentions, not citations, normalize brand names, and store every run.
Read this page with an AI
Share of voice in AI answers is a mention-share metric: across a fixed query set, for each engine, what fraction of the brand names that appear in the answer text are yours. It is not citation share - a citation is your domain in sources[], which moves for different reasons - and it is not an average across engines, because ChatGPT and Perplexity surface names very differently. One query set, six engines, one ratio per engine. Build that and you have a monitor; schedule it and diff it and you have a trend.
Two routes, same contract
Both routes hit the same endpoint - POST https://api.agentgeo.org/v1/fetches - and get back the same records. Pick by where you are in the build.
| Route | Best for | Runs on a schedule? | Where the analysis lives |
|---|---|---|---|
| Claude Code + MCP | Proving the idea, first number, one-off checks | No - interactive | In the agent, in your repo |
| Python + cron | The actual monitor: history, diffs, alerts | Yes | In a file you own |
| curl | Wiring tests, CI smoke checks | Via CI | Wherever you pipe it |
Route 1 - the one-minute version in Claude Code
Install the MCP server once. claude mcp add registers it, npx pulls agentgeo-mcp from npm (stdio transport, zero npm dependencies, Node.js 18+), and on your next session one deliberately narrow tool - fetch_raw_answers - appears in the tool list. A live ag_live_ key comes with a plan; an ag_test_ key returns clearly labelled demo records at zero credits so you can wire it up first.
claude mcp add agentgeo -- npx -y agentgeo-mcp --key ag_live_your_key_hereNow describe the monitor in plain language. Claude Code shapes the calls, reads the records, and does the counting - no arguments to memorize:
Fetch these queries across all six surfaces:
- best project management software
- asana alternatives
- project management tools for small teams
For each engine, count how many answers name Acme versus Monday, Asana,
ClickUp and Notion. Report Acme's share of voice per engine as
Acme mentions / all-brand mentions, and list any query where Acme is absent.That is a real share-of-voice reading in one turn, and because the agent holds your repo it can go further in the same session - read the page a competitor is winning on, draft the change that would close the gap, open the PR. What it can't do is run itself next Monday. For that, graduate the identical contract to a script.
Route 2 - the monitor that runs on a cron
The script fans each query across all six surfaces, tests every brand's alias list against the answer text with a word-boundary regex, and writes one row per (query, surface, brand) with its fetchedAt. Share of voice is then a group-by: per surface, your mentions over the total. Appending is the whole point - next week's run is a filter on the timestamp column, and the diff is your trend.
#!/usr/bin/env python3
"""Fan a query set across all six engines; compute share of voice per engine."""
import csv
import re
from collections import defaultdict
from pathlib import Path
import requests
API = "https://api.agentgeo.org/v1/fetches"
KEY = "ag_live_your_key_here"
OUT = Path("sov_history.csv")
SURFACES = [
"chatgpt", "perplexity", "google_ai_overview",
"google_ai_mode", "copilot", "gemini",
]
FIELDS = ["query", "surface", "brand", "mentioned", "status", "fetchedAt"]
# Your brand first, then the competitive set you want the share measured against.
# Each brand maps to the aliases that count as a mention of it.
BRANDS = {
"Acme": ["Acme", "Acme PM", "AcmeHQ"],
"Monday": ["Monday.com", "monday.com", "Monday"],
"Asana": ["Asana"],
"ClickUp": ["ClickUp"],
"Notion": ["Notion"],
}
YOU = "Acme"
QUERIES = [
"best project management software",
"asana alternatives",
"project management tools for small teams",
"software for tracking team tasks",
]
# One word-boundary regex per brand, over its alias list - no substring hits.
MATCHERS = {
b: re.compile(r"\b(?:" + "|".join(re.escape(a) for a in al) + r")\b", re.I)
for b, al in BRANDS.items()
}
def fetch(query):
resp = requests.post(
API,
json={"query": query, "surfaces": SURFACES},
headers={"Authorization": f"Bearer {KEY}"},
timeout=200, # the API holds slow scrapes up to 180s
)
resp.raise_for_status()
return resp.json()
rows = []
for query in QUERIES:
run = fetch(query)
for answer in run["answers"]:
surface = answer["surfaceKey"]
fetched = answer.get("fetchedAt", "")
if answer.get("status") == "failed":
# Absence of an answer is not zero share - record it and skip.
rows.append({"query": query, "surface": surface, "brand": "",
"mentioned": "", "status": "failed", "fetchedAt": fetched})
continue
text = answer.get("answerText") or ""
for brand, matcher in MATCHERS.items():
rows.append({
"query": query,
"surface": surface,
"brand": brand,
"mentioned": bool(matcher.search(text)),
"status": "delivered",
"fetchedAt": fetched,
})
new_file = not OUT.exists()
with OUT.open("a", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
if new_file:
writer.writeheader()
writer.writerows(rows)
# Share of voice per surface: your mentions / all-brand mentions this run.
totals = defaultdict(int)
yours = defaultdict(int)
for r in rows:
if r["status"] != "delivered" or not r["mentioned"]:
continue
totals[r["surface"]] += 1
if r["brand"] == YOU:
yours[r["surface"]] += 1
print(f"appended {len(rows)} rows to {OUT}\n")
print(f"{YOU} share of voice this run")
for s in SURFACES:
t = totals[s]
share = f"{100 * yours[s] / t:5.1f}%" if t else " n/a"
print(f" {s:<20} {share} ({yours[s]}/{t} brand mentions)")Run it and each engine reports one percentage: of every brand name that surfaced across your query set, what share was yours. Because the raw rows persist, a week later you subtract and see the number move per engine and per query - which is the only view that tells you where to spend effort.
The script runs as-is once KEY is real - dry-run it first with a zero-credit ag_test_ key. Not ready for code? Get a free AI-visibility audit โ - no card, no account.
Why it counts mentions, not citations
Share of voice is a prose metric. It asks how often each brand is named in the answer a buyer reads - which lives in answerText - not how often a domain is linked in sources[]. The two move independently: on ChatGPT sources[] is usually empty because it didn't browse, yet the answer still names three brands, and that mention is the only signal you'll get there. Build SOV on citations and you throw away every engine that recommends without linking.
Keep a citation monitor too - it answers a different question (did the engine read your page) and calls for a different fix. But don't fold citations into the share-of-voice ratio. A brand cited but never named has 0% voice on that query even though its domain appears; merging the two hides exactly that.
What makes the number lie
- A missing competitor deflates the denominator. Share of voice is only as honest as the brand list. Leave a real rival out and your share reads high because the answers that name them count for nothing. Seed the list from the answers themselves - run the queries once, read which names actually appear, and add them before you trust a percentage.
- Substring matching invents mentions. A bare
"acme" in textfires on "acmestudio" and on any competitor whose name contains a common word. Match on a word boundary over an explicit alias list, exactly as the script does - and when two brands share a token, expect a manual disambiguation pass. - A
failedrecord is not zero share. Any surface can returnstatus: "failed"at HTTP 200 - most often Google AI Overview, which is simply absent for some queries. That is missing data, not a share of zero; the script records it asfailedand excludes it from the ratio. Counting it as an absence quietly inflates whoever did appear. - One run is a sample. Fan the same query twice and the engines disagree with themselves - a name drops on ChatGPT, another appears on Perplexity, nobody's content changed. Require a name to enter or leave across several runs before you call the share moved.
- Client timeout below 180s cancels healthy calls. The API holds each connection up to 180 seconds while slow surfaces finish, and a fan-out waits on the slowest of six. Set
timeout=200and run the loop from cron or a worker, never from inside a request handler.
Summary
An AI share-of-voice monitor is one query set fanned across six surfaces, counted as mention-share per engine - your brand's names over all brands' names in the answer text. Prove it in a minute with fetch_raw_answers in Claude Code, then move the same contract to a ~60-line cron script that appends every run so the file diffs against itself. Count mentions not citations, seed the competitor list from the answers, exclude failed records, and judge trends across runs. To split the two signals apart, see track your brand across every AI engine; the Python API page covers the endpoint beyond this one task.
Get a free AI-visibility audit โ ยท Read the docs โ - No card, no account.
Get my free audit