How to pull citations from Google AI Overviews
An AI Overview can't be written without sources. It's assembled from a search, on a search results page, out of pages Google just retrieved — which means a delivered record essentially always arrives with references attached. Of the six engines AgentGEO covers, this makes AI Overviews the most reliable citation surface: no waiting to see whether the model felt like browsing today.
That reliability is what makes the data worth collecting. POST the query to https://api.agentgeo.org/v1/fetches with "surfaces": ["google_ai_overview"] and read answers[].sources[] — each entry carries title, url and position. Aggregate the hosts across a query set, keep the positions, append every run to a JSONL file, and you have the closest thing to a trackable ranking that answer engines currently produce.
To pull citations from a Google AI Overview, take the reference list attached to the block — those are the pages Google actually drew on, in the order it attached them. By hand that's copy-paste from the results page. Programmatically it's one POST, and sources[] returns with title, url and position in their own fields. That separation is doing the real work: it's what turns thirty scattered reference lists into a single aggregate, and last week's aggregate into a comparison.
The manual way (and why it stops working)
For one query it really is simple. Search your question, find the AI Overview, expand the reference chips beside it, and copy each link in order. Keep the order — the first reference is not the same result as the fifth. Paste the links into a sheet next to the query and the date and you have one row of a citation log, which is one more than most teams have. Do a handful and a pattern shows up immediately: the same two or three domains keep supplying the answers in your category.
Row two is where it comes apart:
- The order rarely survives the clipboard. Drag-select the chips, drop them into a sheet, and what lands is either shuffled or a column of titles with the links quietly discarded. Since the ordering was the only thing separating this from a plain list of domains, that's the dataset gone.
- You'll want hosts, not URLs. References pointing at
docs.globex.com/setupandglobex.com/pricingare one competitor, and collapsing them into "Globex" means splitting strings. Done by hand, that's a small chore repeated on every reference of every query of every run. - A query set multiplies everything. Thirty queries at six references each, weekly, is around one hundred and eighty links a week transcribed without a typo. Forever.
- Some queries have no overview to copy. Google doesn't answer every search inline. By hand these get skipped and forgotten, so months later you can't tell whether a host disappeared from your data or the overview it lived in did.
- A spreadsheet column is a dead end. Pasted links can't trigger an alert, populate a client report, or be subtracted from last month's run — and that subtraction is the only reason anyone collects citations in the first place.
Do it with one API call
The API returns the reference list already parsed. Because an AI Overview is search-grounded by construction — Google searches, then writes the block out of what it retrieved — a delivered record essentially always carries one, which makes this the densest and most predictable citation surface of the six engines. Its sibling, Google's AI Mode, is a separate search surface with its own google_ai_mode key and its own reference behaviour.
curl -X POST https://api.agentgeo.org/v1/fetches \
-H "Authorization: Bearer ag_live_..." \
-H "Content-Type: application/json" \
-d '{"query": "best cloud backup for small business", "surfaces": ["google_ai_overview"], "country": "US", "language": "en"}'Three fields per reference — title, url and position — carry the whole job. A single pass over them prints the per-query list, tallies which hosts keep supplying your category, and lands each run on disk for later:
import json
import pathlib
import requests
from collections import defaultdict
from urllib.parse import urlparse
KEY = "ag_live_your_key_here"
LOG = pathlib.Path("aio-citations.jsonl")
QUERIES = [
"best cloud backup for small business",
"how to back up a server to the cloud",
"cloud backup with ransomware protection",
"how much does cloud backup cost per terabyte",
]
def host(url):
h = urlparse(url).netloc.lower()
return h[4:] if h.startswith("www.") else h
positions = defaultdict(list) # host -> every position it was cited at
missing = [] # queries where Google showed no AI Overview
for query in QUERIES:
resp = requests.post(
"https://api.agentgeo.org/v1/fetches",
json={
"query": query,
"surfaces": ["google_ai_overview"],
"country": "US", # search gl=
"language": "en", # search hl=
},
headers={"Authorization": f"Bearer {KEY}"},
timeout=200, # an AI Overview SERP round-trip alone runs 40-90s
)
resp.raise_for_status()
for answer in resp.json()["answers"]:
if answer.get("status") == "failed":
# No AI Overview on this SERP: zero credits, zero references, and
# emphatically not the same thing as an overview citing nobody.
missing.append(query)
continue
sources = sorted(answer.get("sources", []), key=lambda s: s["position"])
print(f"\n{query}")
for src in sources:
h = host(src["url"])
positions[h].append(src["position"])
print(f" {src['position']:>2}. {h} - {src['title']}")
# One line per run, so today's list can be diffed against last week's.
row = {
"fetchedAt": answer.get("fetchedAt"),
"query": query,
"surfaceKey": answer["surfaceKey"],
"sources": [
{"position": s["position"], "url": s["url"], "title": s["title"]}
for s in sources
],
}
with LOG.open("a", encoding="utf-8") as f:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
print(f"\ncited hosts across {len(QUERIES) - len(missing)} overviews")
ranked = sorted(positions.items(), key=lambda kv: (-len(kv[1]), min(kv[1])))
for h, seen in ranked:
avg = sum(seen) / len(seen)
print(f" {len(seen):>2}x best #{min(seen)} avg #{avg:.1f} {h}")
if missing:
print(f"\nno AI Overview for: {', '.join(missing)}")One run of that is a snapshot: today's references, ranked by how often each host appears and how high it lands when it does. Four queries against one surface costs at most four credits, and any query with no overview costs nothing — which is why missing is a list rather than a silent continue. A host that vanishes because the overview vanished is a completely different story from a host that got dropped from an overview that still exists.
Because every run was appended to JSONL, the interesting part is now a subtraction. This reads the log and tells you what entered, what dropped, and what merely moved:
# diff_positions.py - what moved in the reference list since the previous run
import json
import pathlib
from urllib.parse import urlparse
LOG = pathlib.Path("aio-citations.jsonl")
def host(url):
h = urlparse(url).netloc.lower()
return h[4:] if h.startswith("www.") else h
def runs_for(query):
rows = []
for line in LOG.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
row = json.loads(line)
if row["query"] == query:
rows.append(row)
return rows
def moved(query):
runs = runs_for(query)
if len(runs) < 2:
return None # one run is a snapshot, not a comparison
before = {host(s["url"]): s["position"] for s in runs[-2]["sources"]}
after = {host(s["url"]): s["position"] for s in runs[-1]["sources"]}
return {
"entered": sorted(set(after) - set(before)),
"dropped": sorted(set(before) - set(after)),
"moved": {
h: (before[h], after[h])
for h in sorted(set(before) & set(after))
if before[h] != after[h]
},
}
print(moved("best cloud backup for small business"))Treat position as the ranking it effectively is. Google built that block out of the pages it retrieved, and the references it attached first tend to do the heaviest lifting in the prose that came out. A host that keeps landing first across your whole query set is the target; your own domain drifting from second reference to sixth is a genuine loss that a present/absent flag will happily record as no change whatsoever. And because this surface cites on essentially every delivered record, the position series has unusually few holes in it — you get a reading nearly every time you ask, which is the thing that makes a trend possible at all.
| Surface | When references show up | What that means for citation tracking |
|---|---|---|
google_ai_overview | Essentially always on a delivered record — the block is built from a search | The most complete series of the six. The gap to plan for is a missing overview, not a missing reference list |
perplexity | Nearly every answer — it searches, then writes | Dense and reliable. Directly comparable to AI Overviews in shape, if not in source mix |
chatgpt | Only when it browses, and it's the one surface honouring the web_search toggle | Expect gaps. An empty sources[] is a legitimate outcome, not a failed fetch |
gemini | Only when it grounds an answer in a live search | Frequently empty on delivered records. Track brand mentions in the prose instead |
Let your agent do it
Citations are the one place an agent has an obvious edge over a script, because the useful next step is reading: open whatever beat you, work out why Google reached for it, and write the page that should have been there instead. Wire the server in once and the whole errand collapses into a sentence:
claude mcp add agentgeo -- npx -y agentgeo-mcp --key ag_live_...That's the whole install. agentgeo-mcp talks over stdio, pulls in no npm dependencies at all and wants Node.js 18+; the one tool it exposes, fetch_raw_answers, hands records back untouched, positions and all. Now ask for the outcome instead of the JSON:
Pull google_ai_overview for our four cloud backup queries and give me every cited host with its position. Then open whatever sits at position 1 for the first query and tell me what our page is missing compared to it.What comes back is the record and nothing else — AgentGEO attaches no scores, no rankings and no opinion about whether position 4 is bad news. The interpretation happens where your context already lives: in the agent that can also open the competing page, hold it against yours, and commit the resulting brief next to the site it describes. If you'd rather drive it from a script, the Python and curl paths call the same endpoint.
Common problems
Citation data corrupts quietly. On this surface, these five are where it happens:
- A missing overview is not an empty citation list. When Google shows no AI Overview, the record returns
status: "failed"with "Google returned no AI Overview for this query" at zero credits. Log those queries separately. Silently treating them as "cited nobody" will make every host in your dataset look like it's losing ground. - A failed AI Overview record is not a snapshot to redeem. The chatbot surfaces return a
providerFields.snapshot_idon a slow scrape, which you can retry against. AI Overviews come through a different fetch path, so there's no snapshot id here — re-run the query later instead, and accept that it may honestly fail again. - Normalise the URL before you count it. The same page arrives wearing different clothes — a campaign parameter here, a trailing slash there, a redirect wrapper somewhere else — and each variant becomes its own row in a naive tally. Lowercase the host, drop a leading
www., discard the query string, then count. Settle one more convention while you're there: whether two pages from a single domain count once or twice. Both answers are defensible; switching answers mid-quarter silently rewrites every figure you already published. - Store the list length next to the position. Position 3 in a list of 3 and position 3 in a list of 12 are not the same result, and reference lists vary in length between overviews. Keeping the length makes the position comparable across queries instead of only within one.
- Locale changes the citation list.
countrysets the searchgl=andlanguagesetshl=, so a US-English overview and a German one cite different pages — and sometimes one market has an overview where the other has none. Keep a separate log per market rather than one file you can't disentangle later.
Summary
Checking one answer? The references are sitting on the results page; copy them. Once the same question has to be asked of thirty queries every week, fetch them instead — title, url and position arrive as separate fields, the aggregation is a few lines, and appending each run to JSONL is what turns a pile of snapshots into a trend with the position movement still visible inside it. Because an AI Overview can't be written without sources, that trend is more continuous here than on any other engine, provided you keep the queries that produced no overview in a list of their own.
Pull your first structured reference list from Google AI Overviews. Start free — no credit card and store run one today.
Start for freeFrequently asked questions
- How do I get the sources Google AI Overviews used?
- They're the reference links attached to the overview block, so for one query you can lift them straight off the results page. As data: POST the query to
https://api.agentgeo.org/v1/fetcheswith"surfaces": ["google_ai_overview"], then readanswers[].sources[]. Each entry arrives withtitle,urlandpositionas separate fields, which means no regex over prose and no HTML to pick apart. - Do Google AI Overviews always show sources?
- An AI Overview is generated from a search, so a delivered record essentially always carries references — that's what makes it the most reliable citation surface of the six. The thing that does go missing is the overview itself: Google doesn't answer every query inline, and when it doesn't, the record comes back
failedat zero credits rather than delivering an empty answer. - Can I pull AI Overview citations programmatically?
- Yes — that's what the endpoint is for. The overview's reference list comes back as structured JSON from a managed-scraper engine that's maintained for you, so collecting it is an ordinary HTTP request with the ordering already preserved. Nothing on your side has to drive a browser, rotate an address pool, or chase a selector each time Google reshuffles its results page.
- What does position mean in an AI Overview citation list?
- It's the order the reference was attached in, and it behaves like a soft ranking — whatever Google put first tends to shape the block most. Keep the number rather than a yes/no, because a domain that falls from second to sixth has lost ground while a present/absent flag still reports it as cited. Keep the list length beside it too: position 3 of 3 and position 3 of 12 are different results.
- How do I track AI Overview citations over time?
- Keep every run rather than only the latest one. A line of JSONL per fetch — query, timestamp, full reference list — gives you something to subtract, and comparing the last two runs tells you which hosts arrived, which vanished and which simply shifted position. Park the queries that returned no overview in their own list, otherwise a change in Google's behaviour will show up in your data as lost citations.
Keep reading