Tutorial7 min read2026-10-05

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.

Tutorial

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.

Minimal — Gemini's grounding sources for one query
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:

Response — a grounded answer, trimmed
{
  "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.

gemini_grounding.py — grounded and ungrounded both persisted
#!/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.

Get my free audit

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 full answerText; 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.
  • query plus surfaces is the whole request. web_search is honoured only by chatgpt, language only by google_ai_overview, country only by google_ai_overview and copilot. Extra parameters on a gemini call don't error; they sit in the payload doing nothing, implying behaviour the code isn't getting.
  • position ranks 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 a KeyError, 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 carries providerFields.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=30 gives up on answers seconds from arriving, and no timeout hangs forever on a dead socket. timeout=200 sits 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 three Counter keys. Lowercase the host, drop www., strip the query string — or your most-grounded page ranks third behind two copies of itself.
  • data= is not json=. requests.post(url, data={...}) form-encodes the body and the API rejects it. Use the json= kwarg — it serializes the dict and sets Content-Type: application/json in 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.

FAQ

Direct answers to the questions this page raises.

POST to https://api.agentgeo.org/v1/fetches with {"query": "...", "surfaces": ["gemini"]} and an Authorization: Bearer ag_live_... header, then read answers[0]["sources"] — when Gemini grounds the query, each entry carries title, url and position. There's no SDK; the client is a plain HTTP call. Set timeout=200 and call raise_for_status().

Because Gemini didn't ground that query — it answered from its own training instead of reaching for the web. The record is still complete and billed, with a full answerText; only sources[] is empty. That's a real signal, not a bug: a query that never grounds can't be won with a better page, so record the zero rather than retrying it.

An ungrounded answer is a successful fetch with an empty sources[] — Gemini chose not to ground. A failed record carries status: "failed" while HTTP returns 200, usually because a scrape exceeded the 180-second budget; it costs zero credits and carries a providerFields.snapshot_id you redeem with a follow-up POST. Check status first, then check whether sources[] is empty.

No. Unlike Perplexity, which cites nearly every answer, Gemini grounds conditionally — it returns sources when it grounds a query on the web and an empty array when it answers from its weights. Presence of sources is itself the signal, much like ChatGPT, so track whether a query grounds at all, not just which hosts appear when it does.

No — there is no official SDK in any language, and none is needed: the endpoint is a single POST, so plain requests is the whole client. If you'd rather an agent make the call than a script, the same fetch is exposed over MCP: claude mcp add agentgeo -- npx -y agentgeo-mcp --key ag_live_....

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.