Track your brand across every AI engine, from one call
Tracking your brand across every AI engine means fanning one query out to all six surfaces in a single call, then tallying two separate signals per engine: whether your domain landed in sources[] (a citation) and whether your name landed in answerText (a mention). One POST to https://api.agentgeo.org/v1/fetches with "surfaces": ["chatgpt", "perplexity", "google_ai_overview", "google_ai_mode", "copilot", "gemini"] returns an answers[] array with one entry per surface, keyed by surfaceKey. That's your cross-engine snapshot from a single request.
The rest is discipline: keep citations and mentions distinct, normalize hosts before you count, and append every run with its fetchedAt so next month's file has something to diff. Below: the fan-out call, the tracker that turns it into a per-engine grid, why the two signals never merge, and the failure modes that make cross-engine data read wrong.
Read this page with an AI
One query, six surfaces, one call. The request body takes a query string and a surfaces array; put all six engine keys in that array and the response answers[] comes back with one record per surface, each tagged with its surfaceKey. You don't loop the engines — you loop your query set, and each iteration fans across all six. That single design choice is what turns a pile of per-engine scripts into one cross-engine tracker.
The fan-out call
Pass every surface you care about in one array. Each delivered record costs one credit — six surfaces on one query is six credits — and a record that comes back failed costs nothing. A few optional params ride along per surface: web_search is honoured only by chatgpt, country by google_ai_overview and copilot, language by google_ai_overview alone. Everywhere else they sit in the payload doing nothing, so set them deliberately.
curl -X POST https://api.agentgeo.org/v1/fetches \
-H "Authorization: Bearer ag_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"query": "best project management software",
"surfaces": ["chatgpt", "perplexity", "google_ai_overview", "google_ai_mode", "copilot", "gemini"]
}'What comes back is one envelope with the six answers inside — no HTML, no scraping artifacts, identical shape every run, which is what makes the tally easy to write:
{
"id": "run_5f2c8b91a4e0",
"query": "best project management software",
"surfaces": ["chatgpt", "perplexity", "google_ai_overview", "google_ai_mode", "copilot", "gemini"],
"status": "completed",
"recordsDelivered": 6,
"creditsCharged": 6,
"answers": [
{
"surfaceKey": "chatgpt",
"answerText": "For most teams the tools that come up are Acme, which leads on automation, and Globex ...",
"sources": [],
"fetchedAt": "2026-10-01T08:14:22Z"
},
{
"surfaceKey": "perplexity",
"answerText": "The most-recommended options are Acme and Initech, with Globex cited for enterprise ...",
"sources": [
{ "title": "Best PM software in 2026", "url": "https://example.com/best-pm", "position": 1 },
{ "title": "Acme vs Globex", "url": "https://acme.com/compare", "position": 2 }
],
"fetchedAt": "2026-10-01T08:14:29Z"
}
]
}Note the ChatGPT record: answerText names Acme and Globex, but sources[] is empty because the model didn't browse. That's not a bug and it's not "no data" — it's the whole reason the two signals stay separate. On ChatGPT the mention is often the only signal you'll get; on Perplexity you'll get both, densely. position ranks within one answer only, so first-of-two on Perplexity isn't comparable to first-of-eight on AI Overview.
Two signals, kept apart
A brand can show up in an AI answer in exactly two places, and they mean different things:
- Citation — your domain appears in
sources[]. The engine read a page you (or someone) published and linked it. This is a browsing signal: it moves when your content gets indexed, refreshed, or displaced. Perplexity and the Google surfaces cite densely; ChatGPT only when it browsed. - Mention — your name appears in
answerText. The engine named you, whether or not it cited a page. This lives in the prose and has to be matched with a string test against the answer text. It's the signal that survives on ChatGPT whensources[]is empty, and it's where a recommendation actually reads to a buyer.
Merge them into one "visible?" boolean and you throw away the most actionable distinction you have. Cited but not mentioned means the engine read you and didn't recommend you — a content or positioning problem. Mentioned but not cited means it recommends you from training memory without reading a live page — a durability problem the day the model refreshes. Track them in two columns, always.
The cross-engine tracker
The script fans each query across all six surfaces, tests your brand for both signals per engine, normalizes hosts before any citation comparison, and appends a row per surface with its fetchedAt. Appending is the point: the file grows into a run history, and next month's diff is a filter on the timestamp column. Keep the brand as a display name plus a root domain — the name drives mention matching, the domain drives citation matching.
#!/usr/bin/env python3
"""Fan a query set across all six engines; tally citation + mention per surface."""
import csv
import re
from pathlib import Path
from urllib.parse import urlparse
import requests
API = "https://api.agentgeo.org/v1/fetches"
KEY = "ag_live_your_key_here"
OUT = Path("brand_across_engines.csv")
SURFACES = [
"chatgpt", "perplexity", "google_ai_overview",
"google_ai_mode", "copilot", "gemini",
]
FIELDS = ["query", "surface", "cited", "mentioned", "status", "fetchedAt"]
# Your brand: display name (+ aliases) for mentions, root domain for citations.
BRAND = "Acme"
ALIASES = ["Acme", "Acme PM", "AcmeHQ"]
BRAND_DOMAIN = "acme.com"
QUERIES = [
"best project management software",
"project management tools for small teams",
"asana alternatives",
"software for tracking team tasks",
]
# Word-boundary regex over the alias list — avoids substring false positives.
MENTION_RE = re.compile(
r"\b(?:" + "|".join(re.escape(a) for a in ALIASES) + r")\b", re.I
)
def host(url):
"""Bare host: lowercase, drop www., so acme.com and www.acme.com match."""
h = urlparse(url).netloc.lower()
return h[4:] if h.startswith("www.") else h
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"]
if answer.get("status") == "failed":
rows.append({"query": query, "surface": surface, "cited": "",
"mentioned": "", "status": "failed",
"fetchedAt": answer.get("fetchedAt", "")})
continue
text = answer.get("answerText") or ""
hosts = {host(s["url"]) for s in (answer.get("sources") or [])}
cited = any(h == BRAND_DOMAIN or h.endswith("." + BRAND_DOMAIN)
for h in hosts)
mentioned = bool(MENTION_RE.search(text))
rows.append({
"query": query,
"surface": surface,
"cited": cited,
"mentioned": mentioned,
"status": "delivered",
"fetchedAt": answer.get("fetchedAt", ""),
})
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)
print(f"appended {len(rows)} rows to {OUT}\n")
print(f"{BRAND} this run")
for s in SURFACES:
hits = [r for r in rows if r["surface"] == s and r["status"] == "delivered"]
c = sum(1 for r in hits if r["cited"])
m = sum(1 for r in hits if r["mentioned"])
print(f" {s:<20} cited {c}/{len(hits)} mentioned {m}/{len(hits)}")Run it and you get a compact grid: for each engine, how many of your queries cited your domain and how many named your brand. Because every row carries query, surface and fetchedAt, the file is diffable — subtract last month's rows and you see which engine started (or stopped) surfacing you, and on which queries. Two runs an hour apart will disagree; that's expected, which is why you append and require movement across several runs before calling a trend.
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.
How the engines differ under one call
The request and response contract is identical across all six surfaces; the behaviour behind them is not. A cross-engine grid only reads correctly if you know what each column can and can't tell you.
| Surface | Citation density | Mention reliability | Watch for |
|---|---|---|---|
| chatgpt | Empty unless it browsed | The primary signal here | sources[] empty ≠ no data — read answerText |
| perplexity | Cites nearly every answer | Present and dense | Citation set drifts between runs |
| google_ai_overview | Dense when present | Present | May be absent — comes back failed, 0 credits |
| google_ai_mode | Dense | Present | Conversational — phrasing shifts the set |
| copilot | Dense | Present | Honours country — set it, don't drift |
| gemini | Varies | Present | Answer style varies run to run |
Don't average across engines. "Acme is visible in 4 of 6 engines" hides the fact that ChatGPT gave you a mention with no citation while AI Overview cited you without naming you — two different problems that call for two different fixes. Keep the grid two-dimensional: surface on one axis, the citation/mention pair on the other. A single number is a report nobody can act on.
What breaks across a fan-out
- A
failedrecord is not a zero. Any surface can returnstatus: "failed"at HTTP 200 — most often Google AI Overview, which is sometimes simply absent for a query.raise_for_status()won't catch it; the script logs it asfailed, not as "not cited". Counting a failed AI Overview as an absence quietly understates your reach. When the failure was a scrape exceeding the 180-second budget, the record costs zero credits and carriesproviderFields.snapshot_id— a follow-up POST with that id and the same single surface redeems the finished answer instead of paying to re-scrape. - Skipping normalization corrupts citation counts.
www.acme.com/,acme.comandacme.com/features?ref=aiare one host; unnormalized they read as three and your citation column goes noisy. Lowercase the host, dropwww., and match on the root domain (or a.+ domain suffix) before you compare — thehost()helper above is the floor. - Substring mention matching invents hits. A bare
"acme" in textfires on "acmestudio" and on any competitor whose name contains yours. Match on a word boundary with a regex over an explicit alias list, exactly as the tracker does — and accept that no mention matcher is perfect. When two brands share a token, you'll need a manual disambiguation pass. - One run is a sample. Fan the same query twice and the engines will disagree with themselves — a name drops on ChatGPT, a citation appears on Perplexity, nobody's content changed. Store every run and require a name to enter or leave across several fetches before you report movement.
- Client timeout below 180s cancels healthy calls. The API holds each connection up to 180 seconds while slow surfaces finish; a fan-out waits on the slowest of six. Set
timeout=200, run the loop from cron or a worker rather than a request handler, and make sure nothing upstream imposes a shorter budget.
Summary
One POST with all six surfaces fans a query across every AI engine and returns one answer per surface, keyed by surfaceKey. Tally two signals separately — your domain in sources[], your name in answerText — normalize hosts before counting, and append every run with its fetchedAt so the file diffs against itself. That's the cross-engine brand tracker: one call per query, two columns per engine, history you can subtract. To go deeper on the prose side, see finding competitor mentions in AI answers; 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