Tutorial2026-07-176 min read

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.

Tutorial

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/setup and globex.com/pricing are 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.

SurfaceWhen references show upWhat that means for citation tracking
google_ai_overviewEssentially always on a delivered record — the block is built from a searchThe most complete series of the six. The gap to plan for is a missing overview, not a missing reference list
perplexityNearly every answer — it searches, then writesDense and reliable. Directly comparable to AI Overviews in shape, if not in source mix
chatgptOnly when it browses, and it's the one surface honouring the web_search toggleExpect gaps. An empty sources[] is a legitimate outcome, not a failed fetch
geminiOnly when it grounds an answer in a live searchFrequently 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_id on 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. country sets the search gl= and language sets hl=, 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 free

Frequently 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/fetches with "surfaces": ["google_ai_overview"], then read answers[].sources[]. Each entry arrives with title, url and position as 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 failed at 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