Tutorial7 min read2026-10-01

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.

Tutorial

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.

Fan one query across all six engines
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:

Response, trimmed to two of six surfaces
{
  "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 when sources[] 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.

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

Get my free audit

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.

SurfaceCitation densityMention reliabilityWatch for
chatgptEmpty unless it browsedThe primary signal heresources[] empty ≠ no data — read answerText
perplexityCites nearly every answerPresent and denseCitation set drifts between runs
google_ai_overviewDense when presentPresentMay be absent — comes back failed, 0 credits
google_ai_modeDensePresentConversational — phrasing shifts the set
copilotDensePresentHonours country — set it, don't drift
geminiVariesPresentAnswer 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 failed record is not a zero. Any surface can return status: "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 as failed, 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 carries providerFields.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.com and acme.com/features?ref=ai are one host; unnormalized they read as three and your citation column goes noisy. Lowercase the host, drop www., and match on the root domain (or a . + domain suffix) before you compare — the host() helper above is the floor.
  • Substring mention matching invents hits. A bare "acme" in text fires 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.

FAQ

Direct answers to the questions this page raises.

POST to https://api.agentgeo.org/v1/fetches with your query and "surfaces": ["chatgpt", "perplexity", "google_ai_overview", "google_ai_mode", "copilot", "gemini"]. The response answers[] carries one record per surface, keyed by surfaceKey, each with answerText and sources[]. Test each answer for your domain (a citation) and your name (a mention), and you have a per-engine snapshot from one call.

A citation is your domain appearing in an answer's sources[] — the engine read and linked a page. A mention is your brand name appearing in answerText — the engine named you in prose, cited or not. They move for different reasons and call for different fixes, so track them in separate columns. On ChatGPT the mention is often the only signal, because sources[] is empty when it didn't browse.

One credit per delivered record, so one query across all six engines is six credits at most. Records that come back failed — a common case for Google AI Overview, which is sometimes absent — cost nothing, and a scrape that times out returns a providerFields.snapshot_id you can redeem later without paying again. ag_test_ keys return demo records at zero credits while you build the script.

Each surface generates answers independently and browses differently — Perplexity searches on every ask, ChatGPT only sometimes, AI Overview may not appear at all. So the same query returns different citation sets and different names per engine, and even the same engine varies run to run. Store every run and judge the trend across several fetches rather than reacting to one snapshot.

Yes. The same endpoint is exposed over MCP, so an agent can run the fan-out in plain language: claude mcp add agentgeo -- npx -y agentgeo-mcp --key ag_live_..., then ask it to fetch your query across all six surfaces and report which engines cited or named your brand. Or start with a free audit and a real person runs your prompts and sends the answers back.

Keep reading

Where this page leads next.

Run these checks on your own brand

Two ways in. Send a URL and a person runs the fetches for you, free — or connect your agent over MCP, on a plan, and run them yourself. Either way you get the raw answers and their citations, never a score.