How to pull citations from Gemini answers
Gemini cites when it grounds an answer by browsing, and doesn't when it doesn't. That makes pulling citations from this surface a slightly different job than on a search engine: you're not collecting a list that's always there, you're collecting a list that sometimes is - and the sometimes turns out to be the interesting measurement.
One POST to https://api.agentgeo.org/v1/fetches with "surfaces": ["gemini"] returns answers[].sources[] as structured JSON, every entry carrying title, url and position. This guide covers the manual copy, the one-call fetch in curl and Python, a small logger that turns runs into a grounding trend, the agent version over MCP, and the normalisation traps that quietly corrupt citation data.
Read this page with an AI
To pull citations from a Gemini answer, read sources[] off the record: each entry is a page the engine used, with the order it used them in. By hand that's copy-paste from whatever links the app shows. Programmatically it's one POST - and the code has to handle two outcomes, not one, because a delivered answer with an empty sources[] is a normal result on this surface rather than an error to retry.
The manual way (and why it stops working)
It really is simple for one query. Ask Gemini your question, look for the links or grounding panel under the answer, and copy each one in order. Keep the order - first cited is not the same result as sixth. Paste them into a sheet next to the date and the query, and add a column for "no sources shown", because you'll need it more often than you expect.
Row two is where it falls apart.
- Half the answers have nothing to copy. Gemini frequently answers without browsing, so you'll open ten tabs to collect four source lists - and the six empties are data you'll forget to write down, which is the data this surface is actually about.
- Order dies in transit. Multi-select in a browser, paste into a sheet, and the positions scramble or the URLs vanish behind their titles.
- Hosts are the unit you'll actually want. Rolling
blog.globex.com/uptimeandglobex.com/pricingup into "Globex" is a parsing job, and by hand it's a parsing job you redo on every single row. - A query set multiplies everything. Twenty queries a week, each with a handful of links, is hundreds of transcriptions a month without one typo. Forever.
- No run history, no trend. The most interesting thing here is whether a query started being grounded at all - and that's a comparison across runs, which a sheet of today's links can't make.
Do it with one API call
The API returns the citation list already parsed, in the same shape it uses for all six engines - so the parser you write here also works against Perplexity, where the lists are far denser.
curl -X POST https://api.agentgeo.org/v1/fetches \
-H "Authorization: Bearer ag_live_..." \
-H "Content-Type: application/json" \
-d '{"query": "best uptime monitoring tool for an API-first startup", "surfaces": ["gemini"], "country": "US"}'Each entry in sources[] has a title, a url and a position. That's enough to print a per-query citation list, aggregate which hosts keep recurring, and - the part specific to this surface - count how many queries came back grounded at all:
import requests
from collections import Counter
from urllib.parse import urlparse
KEY = "ag_live_your_key_here"
QUERIES = [
"best uptime monitoring tool for an API-first startup",
"how to monitor API latency across regions",
"uptime monitoring with a status page and on-call alerts",
]
def host(url):
h = urlparse(url).netloc.lower()
return h[4:] if h.startswith("www.") else h
cited = Counter() # how often each host appears anywhere in a source list
top_slot = Counter() # how often each host is cited at position 1
grounded, ungrounded = [], []
for query in QUERIES:
resp = requests.post(
"https://api.agentgeo.org/v1/fetches",
json={"query": query, "surfaces": ["gemini"], "country": "US"},
headers={"Authorization": f"Bearer {KEY}"},
timeout=200, # the API waits up to 180s for live surfaces
)
resp.raise_for_status()
for answer in resp.json()["answers"]:
if answer.get("status") == "failed":
snap = (answer.get("providerFields") or {}).get("snapshot_id")
print(f"{query!r}: failed, 0 credits - snapshot_id {snap}")
continue
sources = answer.get("sources") or []
# Delivered with no sources is a valid record, not an error.
if not sources:
ungrounded.append(query)
print(f"\n{query}\n ungrounded - answered without citing a page")
continue
grounded.append(query)
print(f"\n{query}")
for src in sorted(sources, key=lambda s: s["position"]):
h = host(src["url"])
cited[h] += 1
if src["position"] == 1:
top_slot[h] += 1
print(f" {src['position']:>2}. {h} - {src['title']}")
total = len(grounded) + len(ungrounded)
rate = len(grounded) / total if total else 0
print(f"\ngrounding rate: {len(grounded)}/{total} ({rate:.0%})")
print("most-cited hosts")
for h, n in cited.most_common(10):
print(f" {n:>2}x {h} (position 1: {top_slot[h]})")One run of that is a snapshot: today's sources, the hosts that recur, and the share of your queries Gemini bothered to browse for. The reason to fetch citations programmatically is that you can keep them - append every run to a JSONL file and the grounding question becomes answerable over time:
# grounding_log.py - call log_run(query, answer) inside the fetch loop above
import json
import pathlib
from datetime import datetime, timezone
LOG = pathlib.Path("gemini-citations.jsonl")
def log_run(query, answer):
# One line per run: the query, when it ran, whether it grounded, and what
# it cited. The empty runs are rows too - they are the measurement.
sources = answer.get("sources") or []
row = {
"fetchedAt": answer.get("fetchedAt")
or datetime.now(timezone.utc).isoformat(),
"query": query,
"surfaceKey": answer["surfaceKey"],
"grounded": bool(sources),
"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")
def grounding_rate(query):
# What share of this query's stored runs came back with any sources?
runs = []
for line in LOG.read_text(encoding="utf-8").splitlines():
if not line:
continue
row = json.loads(line)
if row["query"] == query:
runs.append(row)
if not runs:
return None
return sum(1 for r in runs if r["grounded"]) / len(runs)Call log_run(query, answer) inside the fetch loop and grounding_rate(query) gives you the number this surface is really about: how often Gemini goes and looks something up for that question. Each delivered record costs one credit whether or not it cited anything, and failed records cost nothing.
The measurement most people skip here is the simplest one: how often Gemini grounded at all. A chatbot only cites when it browses, so an empty sources[] is a statement about the topic, not about you - the model judged the question answerable from what it already holds. Log a boolean per run and the grounding rate becomes a leading indicator: a query that starts coming back with sources is a query Google has decided needs current evidence, and that is precisely the moment citation work on that topic starts paying. Tracking only the hosts you found means missing the change in whether there were any hosts to find.
Once you have a few weeks of runs, the pattern per query tells you what kind of work is even available:
| What you see across runs | What it means | What to do |
|---|---|---|
| Never any sources | Gemini treats the topic as settled background knowledge. | Citations aren't the lever. Work on how broadly and consistently you're described across the web. |
| Sources on some runs | The topic sits on the boundary - the model browses when it isn't confident. | Worth logging weekly. This is where citation opportunity opens and closes. |
| Sources on every run | Google has decided this question needs current evidence. | Treat it like a search result: the cited pages are specific, and specific is beatable. |
| Grounding rate rising over weeks | The topic is becoming contested, fast-moving or newly commercial. | Publish now - the citation slots are being handed out while you read this. |
| Grounded, but the same hosts every time | A small trusted set is being reused. | Getting in usually means being referenced by those hosts rather than displacing them. |
Let your agent do it
Citation work is where an agent earns its keep: it can fetch the sources, then go read the page that outranked you and draft the answer to it. Connect the MCP server once and the pull becomes an instruction:
claude mcp add agentgeo -- npx -y agentgeo-mcp --key ag_live_...agentgeo-mcp runs over stdio, has zero npm dependencies and needs Node.js 18+. Its single tool, fetch_raw_answers, returns each record verbatim - sources[] included, positions intact. country shapes the market; note that language isn't forwarded for Gemini, because the chatbot datasets behind this surface reject the field, so a locale question here is a country question. Then ask:
Pull Gemini's answers for our five monitoring queries. For each one tell me whether it cited any sources, and list the hosts with their positions. Then give me the share of queries that were grounded at all, and flag any that weren't grounded last month but are now.
The records come back unchanged - no scoring, no ranking, no conclusions. Everything downstream, from the diff to the content brief, happens in your agent with your context, and can be committed alongside the site it's about. Prefer to script it? The Python and curl paths hit the identical endpoint.
Common problems
Citation data corrupts quietly. On this surface, these five are where it happens:
- Empty is not failed. A record with
status: "delivered"andsources: []is correct, complete and costs a credit. Branch onstatusfirst and onlen(sources)second, and log the empty runs - dropping them silently is how a grounding rate turns into 100%. - Normalise URLs before aggregating. Tracking parameters, trailing slashes and redirect wrappers make one page look like two. Lowercase the host, strip a leading
www., drop the query string - then count. - Positions are per-answer, not global. Position 1 on a narrow long-tail query is not the same prize as position 1 on your head term. Always store the query next to the position, or the aggregate means nothing.
- You can't make Gemini browse. The
web_searchtoggle is honoured only bychatgpt; on Gemini it's ignored. Grounding is something you observe rather than something you request, which is exactly why the grounding rate is worth logging instead of engineering around. - Slow scrapes come back
failed. Requests wait up to 180 seconds, and a scrape that exceeds that budget returnsstatus: "failed"at zero credits with aproviderFields.snapshot_id. A follow-up call with that id and the same single surface redeems the finished answer instead of paying to re-scrape it.
Summary
For a single answer, copy whatever links Gemini shows you - it's right there and it costs nothing. For a query set you'll revisit, fetch it: sources[] arrives with titles, URLs and positions already separated, aggregation is a Counter, and appending every run to a JSONL file gives you the number this surface is actually about. Not just which hosts Gemini cited, but how often it decided to go and cite anyone at all.
See the citations Gemini is actually handing your buyers. Get a free audit - no card and we send the sources back with the answers.
Get my free audit