Skip to content
Tutorial8 min read2026-10-07

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.

Tutorial

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.

competitor_bot.py
#!/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.

Get my free audit

What the changelog is telling you

SignalWhat it meansWhat to do
A brand ENTERS on one engineThat engine started recommending them; often a content or PR move landingRead the query where they appear; find what page the engine is pulling
A brand ENTERS on several enginesA durable shift, not a one-run flukeTreat as a real new competitor; investigate their positioning
You MOVE down while a rival MOVES upDisplacement on shared queriesCompare the answers side by side on those exact queries
A brand LEAVES for one run onlyAlmost always run-to-run noiseWait - 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 KNOWN list; 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 failed engine as zero visibility. Any surface can return status: "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 text is 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.

FAQ

Direct answers to the questions this page raises.

Fan your category queries across all six surfaces via https://api.agentgeo.org/v1/fetches, extract the brand names each answer's answerText contains, and rank them by how many (query, surface) pairs name each brand. Diff that ranking against the previous run to flag new entrants and movers. Anchor extraction to a curated brand list and use unknown candidates only as a human-review feed.

It collects proper-noun candidates from the answer text that aren't in your known-brand list, filters obvious stopwords, and reports the ones that appear often enough to be worth checking. It does not auto-add them - extracting company names from prose is fuzzy, so a person confirms a real newcomer before it joins the canonical list. That review step is what keeps the tracker trustworthy.

Because you'll report "Best", "AI", and the first word of every sentence as competitors and bury the real signal. Anchor the count to a curated list of your category's brands and their aliases, matched on word boundaries, and treat the raw candidate feed as suggestions to review - never as confirmed rivals.

Engine answers vary run to run with no content change, so a brand entering or leaving for a single run is usually flicker. Require the change to persist across two or three runs, and weight entries that show up on several engines over ones confined to a single surface. Always exclude failed engines, which are missing data, not a competitor exit.

Mentions - brands named in answerText - because that's where a recommendation reads to a buyer and where discovery happens. If you also need to know which domains the engines are reading, run a citation tracker alongside it; a competitor can be named without being cited, or cited without being named, and the two call for different responses.

Keep reading

Where this page leads next.

Your brand, next

Run these checks on your own brand

Send a URL for a free human-run audit, or connect your agent over MCP and run the same records yourself.