Build a competitor AI-visibility watch bot
A competitor AI-visibility bot answers the question a fixed brand list can't: who is the AI recommending in my category that I didn't know to watch? You build it by fanning your category queries across the six surfaces, extracting every brand each answer names, ranking them by how many engines and queries surface each one, and diffing that ranking against the last run to flag new entrants and movers. The endpoint is one POST https://api.agentgeo.org/v1/fetches per query; the intelligence is in the extraction and the diff.
The hard part of competitor tracking isn't counting known rivals - a share-of-voice monitor already does that. It's discovery: the answer that starts naming a company you've never heard of is the early warning, and a static list never catches it. This guide builds the bot around auto-extraction from the answer text, with a curated alias list only for disambiguation, and a stored baseline so every run reports what changed.
Read this page with an AI
The bot runs a loop: fan each query across the surfaces, pull answerText from every delivered answer, extract candidate brand names, tally each brand by how many (query, surface) pairs name it, and compare the tally to the stored baseline. Two things come out - a ranked visibility board for the category, and a changelog: brands that appeared, brands that vanished, and brands that moved. You watch the changelog; the board is context.
The one honest bit: extracting brands
There is no magic here, and pretending otherwise is how competitor trackers lie. Extracting company names from prose is fuzzy: you get proper-noun candidates and filter them against a curated set of your category's known brands plus their aliases, and you accept that genuinely new entrants need a human to confirm before they join the canonical list. The bot's value is that it surfaces the candidate so a person can confirm it in seconds - not that it's oracularly correct.
Don't ship a bot that treats every capitalized token as a competitor. It will report "Best", "AI", and the first word of every sentence as rivals and drown the real signal. Anchor extraction to a seed list you maintain, use the candidate feed only to grow that list under human review, and keep the two separate. A tracker you can't trust gets muted, and a muted tracker catches nothing.
The bot
The script fans the query set, counts each known brand by (query, surface) coverage, collects unknown proper-noun candidates for review, and diffs the coverage tally against a saved baseline. It writes the new baseline only after reporting, so each run's changelog is against the last accepted state.
#!/usr/bin/env python3
"""Rank every brand the AI engines name in your category; flag run-over-run change."""
import json
import re
from collections import Counter
from pathlib import Path
import requests
API = "https://api.agentgeo.org/v1/fetches"
KEY = "ag_live_your_key_here"
STATE = Path("competitor_baseline.json")
SURFACES = [
"chatgpt", "perplexity", "google_ai_overview",
"google_ai_mode", "copilot", "gemini",
]
# Brands you already know in the category, each mapped to its aliases.
# The candidate feed grows this list under review - it does not replace it.
KNOWN = {
"Acme": ["Acme", "Acme PM", "AcmeHQ"],
"Monday": ["Monday.com", "monday.com", "Monday"],
"Asana": ["Asana"],
"ClickUp": ["ClickUp"],
"Notion": ["Notion"],
"Trello": ["Trello"],
}
QUERIES = [
"best project management software",
"asana alternatives",
"project management tools for small teams",
"software for tracking team tasks",
]
MATCHERS = {
b: re.compile(r"\b(?:" + "|".join(re.escape(a) for a in al) + r")\b", re.I)
for b, al in KNOWN.items()
}
# Proper-noun candidates: TitleCase / CamelCase tokens, for the review feed only.
CANDIDATE_RE = re.compile(r"\b([A-Z][a-zA-Z0-9]+(?:\.[a-z]{2,})?)\b")
STOPWORDS = {"The", "Best", "AI", "For", "Most", "These", "Here", "One", "Its"}
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()
coverage = Counter() # known brand -> number of (query, surface) pairs naming it
candidates = Counter() # unknown proper nouns -> frequency, for human review
known_aliases = {a.lower() for al in KNOWN.values() for a in al}
for query in QUERIES:
for answer in fetch(query)["answers"]:
if answer.get("status") == "failed":
continue # missing answer, not zero visibility
text = answer.get("answerText") or ""
for brand, matcher in MATCHERS.items():
if matcher.search(text):
coverage[brand] += 1
for token in CANDIDATE_RE.findall(text):
if token in STOPWORDS or token.lower() in known_aliases:
continue
candidates[token] += 1
# Diff coverage against the saved baseline.
baseline = json.loads(STATE.read_text()) if STATE.exists() else {}
entered = [b for b in coverage if b not in baseline]
left = [b for b in baseline if b not in coverage]
moved = [
(b, baseline[b], coverage[b])
for b in coverage if b in baseline and coverage[b] != baseline[b]
]
board = coverage.most_common()
total = sum(coverage.values()) or 1
print("Category visibility board (coverage across queries x engines)")
for brand, n in board:
print(f" {brand:<12} {n:>3} ({100 * n / total:4.1f}% of all brand mentions)")
print("\nChange since last run")
for b in entered:
print(f" + NEW {b} (coverage {coverage[b]})")
for b in left:
print(f" - GONE {b} (was {baseline[b]})")
for b, was, now in moved:
print(f" ~ MOVED {b} {was} -> {now}")
# Review feed: frequent unknown proper nouns that may be real competitors.
review = [c for c, n in candidates.most_common(15) if n >= 2]
if review:
print("\nCandidates to review (add real brands to KNOWN):")
print(" " + ", ".join(review))
# Write the new baseline only after reporting, so the diff is against last state.
STATE.write_text(json.dumps(dict(coverage), indent=2))Each run prints three things: the ranked visibility board for the category, a changelog of who entered, left, or moved since the last run, and a review feed of unknown proper nouns that showed up often enough to check. You confirm real newcomers by hand and add them to KNOWN; the noise you ignore. That human-in-the-loop step is the feature, not a gap - it's what keeps the tracker trustworthy enough to keep watching.
Dry-run the whole loop against a zero-credit ag_test_ key before spending a credit. Want the picture without building? Get a free AI-visibility audit โ - no card, no account.
What the changelog is telling you
| Signal | What it means | What to do |
|---|---|---|
| A brand ENTERS on one engine | That engine started recommending them; often a content or PR move landing | Read the query where they appear; find what page the engine is pulling |
| A brand ENTERS on several engines | A durable shift, not a one-run fluke | Treat as a real new competitor; investigate their positioning |
| You MOVE down while a rival MOVES up | Displacement on shared queries | Compare the answers side by side on those exact queries |
| A brand LEAVES for one run only | Almost always run-to-run noise | Wait - don't act on a single disappearance |
The board number is coverage - how many (query, surface) pairs named a brand - not a rank within any single answer. position in sources[] ranks citations inside one answer only and isn't comparable across engines, so the bot deliberately counts presence across the grid rather than pretending positions stack. Breadth of coverage is the honest competitive signal; a fabricated cross-engine rank is not.
This bot watches mentions - brands named in answerText - because that's where a recommendation reads to a buyer and where competitor discovery happens. Pair it with a citation tracker if you also need to know which domains the engines are reading; the two answer different questions and a competitor can win one while losing the other.
What makes a competitor bot untrustworthy
- Extracting every capitalized word. Anchor the count to a curated
KNOWNlist; use the candidate feed only to grow it under review. A bot that reports sentence-initial words as rivals is one nobody reads twice. - Treating a
failedengine as zero visibility. Any surface can returnstatus: "failed"at HTTP 200 - most often Google AI Overview when it's absent. The bot skips failed records; count them as an absence and every brand's coverage drops the day one engine didn't answer, faking a category-wide exit. - Baselining before you report. Write the new baseline after printing the diff, or every run compares against itself and the changelog is always empty. The script writes state last on purpose.
- Reacting to a single run. Engine answers disagree between runs with no real change. Require an entrant or a move to persist across two or three runs before you treat it as a competitive event, not a blip.
- Substring alias matching. A bare
"trello" in textis fine, but short or common-word brand names invite false positives. Match on word boundaries over an explicit alias list, and disambiguate brands that share a token by hand.
Summary
A competitor AI-visibility bot fans your category queries across six engines, ranks every brand the answers name by coverage, and diffs that ranking against a stored baseline to flag entrants, exits, and movers - with a review feed that surfaces unknown names for a human to confirm. Anchor extraction to a curated list, skip failed engines, baseline after reporting, and require moves to persist across runs. To measure your own slice of that board, see building an AI share-of-voice monitor; the competitor AI-visibility use case covers the strategy behind the numbers.
Get a free AI-visibility audit โ ยท Read the docs โ - No card, no account.
Get my free audit