Gemini grounding sources, as a plain API
Gemini grounding sources are the pages Gemini grounds an answer on when it reaches for the web — and getting them as structured data takes one POST to https://api.agentgeo.org/v1/fetches with "surfaces": ["gemini"]. When Gemini grounds a query, the response carries answers[0].sources[], each entry a title, a url and a position; when it doesn't ground — when it answers from its own weights — that array comes back empty, and that emptiness is a real signal, not a bug, the same way an empty sources[] on ChatGPT means the model didn't browse. So the shape of the work is: make the call, read the sources when they're there, and record the ungrounded answers instead of retrying them, because "this query doesn't ground" is a finding that changes what you write. Below: the one call, the grounding-sources shape, how to handle ungrounded answers, and the failure modes to code for.
Read this page with an AI
The short version: requests.post to https://api.agentgeo.org/v1/fetches with {"query": "...", "surfaces": ["gemini"]} and a bearer key, then read answers[0]["sources"] off the JSON. There's no SDK — the client is a plain HTTP call. What's specific to Gemini is that grounding is conditional: some answers ground and carry sources, some don't and come back empty, and treating the empty case as data rather than failure is most of the job.
The one call
query and surfaces are the entire request for Gemini. web_search, country and language are honoured by other surfaces and do nothing here, so leave them off. raise_for_status() and the 200-second timeout return in the failure-modes section.
import requests
resp = requests.post(
"https://api.agentgeo.org/v1/fetches",
json={"query": "best password managers for teams", "surfaces": ["gemini"]},
headers={"Authorization": "Bearer ag_live_your_key_here"},
timeout=200, # the API holds slow scrapes up to 180s — stay above that
)
resp.raise_for_status()
answer = resp.json()["answers"][0]
sources = answer.get("sources") or []
if not sources:
print("ungrounded — Gemini answered from its own weights")
else:
for src in sources:
print(f"{src['position']:>2}. {src['title']} {src['url']}")When Gemini grounds, the response looks like this — the grounding metadata arrives as an ordered sources[], already parsed:
{
"id": "run_c40b7a1e92d5",
"query": "best password managers for teams",
"surfaces": ["gemini"],
"status": "completed",
"recordsDelivered": 1,
"creditsCharged": 1,
"answers": [
{
"surfaceKey": "gemini",
"answerText": "For teams, the managers most often recommended are ...",
"sources": [
{ "title": "Best Team Password Managers 2026", "url": "https://example.com/team-password-managers", "position": 1 },
{ "title": "Password Manager Security Compared", "url": "https://another-example.com/blog/pw-security/", "position": 2 }
],
"fetchedAt": "2026-10-05T10:03:18Z"
}
]
}When Gemini doesn't ground, the record is still a completed, billed result with a full answerText — the only difference is sources[] comes back empty. creditsCharged counts delivered records; position starts at 1 and ranks within this one answer only. The full field reference lives in the docs.
Handling ungrounded answers
This is the part that trips people up, so it gets its own section. Gemini doesn't ground every query — a question it can answer confidently from its training may come back with prose and no citations at all. That empty sources[] is not a failed fetch and not something a retry fixes; it's Gemini telling you this query is answered from memory, and a query that never grounds can't be won with a better page. The script records the ungrounded case as a zero-row so the finding survives to the next run.
#!/usr/bin/env python3
"""Pull Gemini's grounding sources for a query set; keep ungrounded as data."""
import csv
from collections import Counter
from pathlib import Path
from urllib.parse import urlparse, urlunparse
import requests
API = "https://api.agentgeo.org/v1/fetches"
KEY = "ag_live_your_key_here"
OUT = Path("gemini_grounding.csv")
FIELDS = ["query", "grounded", "position", "host", "url", "title", "fetchedAt"]
QUERIES = [
"best password managers for teams",
"1password vs bitwarden",
"how to store api keys securely",
"password manager for small business",
]
def normalize(url):
p = urlparse(url)
host = p.netloc.lower().removeprefix("www.") # Python 3.9+
clean = urlunparse((p.scheme, host, p.path.rstrip("/"), "", "", ""))
return host, clean
def fetch(query):
resp = requests.post(
API,
json={"query": query, "surfaces": ["gemini"]},
headers={"Authorization": f"Bearer {KEY}"},
timeout=200, # sit above the server's 180s budget
)
resp.raise_for_status()
return resp.json()
hosts = Counter()
grounded_n = ungrounded_n = 0
rows = []
for query in QUERIES:
run = fetch(query)
for answer in run["answers"]:
if answer.get("status") == "failed":
print(f"failed record, skipping: {query}")
continue
sources = answer.get("sources") or []
ts = answer.get("fetchedAt", "")
if not sources:
# Ungrounded: a real signal, not an error. Record the zero.
ungrounded_n += 1
rows.append({"query": query, "grounded": "no", "position": "",
"host": "", "url": "", "title": "", "fetchedAt": ts})
print(f" ungrounded {query}")
continue
grounded_n += 1
for src in sorted(sources, key=lambda s: s["position"]):
host, clean = normalize(src["url"])
hosts[host] += 1
rows.append({"query": query, "grounded": "yes", "position": src["position"],
"host": host, "url": clean, "title": src["title"], "fetchedAt": ts})
print(f"{len(sources):>3} sources {query}")
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"\ngrounded: {grounded_n} ungrounded: {ungrounded_n} rows -> {OUT}")
for host, n in hosts.most_common(10):
print(f" {n:>2}x {host}")The grounded column is the one to watch over time. A query that flips from ungrounded to grounded — or the reverse — is a bigger event than any single host moving inside the citation list, because it changes whether the answer is winnable at all. Persist both cases and the flip shows up in a diff; drop the ungrounded rows and you've thrown away half the story.
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 gemini surface behaves
All six engines share one request and response contract; what differs is behaviour. On gemini:
- Grounding is conditional. Gemini returns grounding sources when it grounds a query and an empty
sources[]when it doesn't. Presence is the signal here, the way it is on ChatGPT — unlike Perplexity, where nearly every answer cites. Don't assume every Gemini answer carries sources; check. - An empty
sources[]is a completed, billed result. The ungrounded answer still has a fullanswerText; Gemini answered from its own weights. Record the zero rather than retrying — asking again mostly reproduces it, and each completed fetch is a charged record. "This query doesn't ground" is a finding, not a failure. queryplussurfacesis the whole request.web_searchis honoured only bychatgpt,languageonly bygoogle_ai_overview,countryonly bygoogle_ai_overviewandcopilot. Extra parameters on ageminicall don't error; they sit in the payload doing nothing, implying behaviour the code isn't getting.positionranks within one answer only. First of two grounded sources is not first of eight. Keep the query and the per-answer source count beside the position whenever you plan to trend it.
The most useful trend on Gemini isn't which host moved inside a citation list — it's whether a query grounds at all. Persist the ungrounded rows so a query flipping into (or out of) grounding shows up in your diff.
Failure modes worth coding for
- An empty
sources[]is not a failed fetch. Ungrounded means Gemini answered without grounding; the record is complete and correct.answer.get("sources") or []handles the empty case without aKeyError, and the script writes a zero-row instead of retrying. Retrying an ungrounded query mostly reproduces it — and charges you for each try. - A failed record is different from an ungrounded one. A record can carry
status: "failed"while the HTTP call returns 200 —raise_for_status()won't catch it, so the script checks and skips. When the failure was a scrape exceeding the 180-second budget, the record costs zero credits and carriesproviderFields.snapshot_id: POST again with a top-level"snapshot_id"and the same single surface —["gemini"]— to redeem the finished answer instead of re-scraping. - A client timeout below 180 seconds cancels healthy requests. The API holds the connection up to 180 seconds while a slow scrape finishes, so
timeout=30gives up on answers seconds from arriving, and no timeout hangs forever on a dead socket.timeout=200sits above the server's budget and below infinity. - Counting before normalizing splits your winners.
example.com/guide,www.example.com/guide/and the same URL with a tracking parameter are one page and threeCounterkeys. Lowercase the host, dropwww., strip the query string — or your most-grounded page ranks third behind two copies of itself. data=is notjson=.requests.post(url, data={...})form-encodes the body and the API rejects it. Use thejson=kwarg — it serializes the dict and setsContent-Type: application/jsonin one move.
Summary
Gemini's grounding sources come back as a plain, structured sources[] from one POST with "surfaces": ["gemini"] — title, URL, position, already parsed, no browser. The wrinkle is that grounding is conditional: some answers ground and cite, some answer from memory and come back empty, and the empty case is data you record rather than an error you retry. The grounded flag is the trend worth watching, because it decides whether an answer is winnable at all. The same request shape covers the other five engines — the Python API page shows the multi-surface version.
Get a free AI-visibility audit → · Read the docs → — No card, no account.
Get my free audit