Get Gemini citations in cURL
One curl command returns Gemini's citations as structured JSON: POST the query to https://api.agentgeo.org/v1/fetches with "surfaces": ["gemini"] and an ag_live_ bearer key, and the answer record carries a sources[] array — title, url and position for every page the engine grounded on. The engine-specific part is that the array is sometimes empty: Gemini cites only when it decides to browse, and no request field changes that decision. A shell version for this surface therefore keeps two tallies — which hosts get cited, and how often anything gets cited at all — and this page builds both with nothing beyond curl, jq and cron.
Everything here is curl and jq: POST the query, read .answers[0].sources[], shape it with a filter. What's specific to Gemini sits in the branch, not the pipe — the engine grounds some answers and writes others from its own weights, no request field changes that, and a script that only handles the cited case throws away the ungrounded runs, which on this surface are measurements rather than misses. The manual-versus-API comparison lives in the task-level guide; this page stays in the shell and ends with a ledger cron can keep growing.
The two-field call
Prerequisites: curl, jq and an ag_live_ key from onboarding — or run one fetch keyless in the playground first. The body is two fields and stays two fields: the endpoint accepts web_search, language and country for other surfaces, but gemini honours none of them. --max-time 200 sits above the API's own 180-second hold, so curl doesn't hang up while a slow scrape is legitimately still running.
curl -s --max-time 200 https://api.agentgeo.org/v1/fetches \
-H "Authorization: Bearer ag_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"query": "best headless cms for a docs site", "surfaces": ["gemini"]}' \
| jq -r '.answers[0].sources[] | [.position, .url] | @tsv'Two columns out — position, then URL, in the order the answer used them. If nothing prints, the filter isn't broken: the answer was delivered without grounding, and the record still holds the full answerText. One line tells you which case you got:
curl -s --max-time 200 https://api.agentgeo.org/v1/fetches \
-H "Authorization: Bearer ag_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"query": "best headless cms for a docs site", "surfaces": ["gemini"]}' \
| jq -r '.answers[0].sources | if length > 0 then "grounded, \(length) sources" else "ungrounded" end'Behind both filters, the full response — one record for the one surface requested:
{
"id": "run_6b02d9e41c7a",
"query": "best headless cms for a docs site",
"surfaces": ["gemini"],
"status": "completed",
"recordsDelivered": 1,
"creditsCharged": 1,
"answers": [
{
"surfaceKey": "gemini",
"answerText": "For a documentation site the usual shortlist is Decap, Sanity and ...",
"sources": [
{ "title": "Choosing a Headless CMS for Documentation", "url": "https://example.com/headless-cms-docs", "position": 1 },
{ "title": "Git-based vs API-based Headless CMS", "url": "https://example.org/git-vs-api-cms", "position": 2 }
],
"fetchedAt": "2026-07-26T08:03:51Z"
}
]
}position is the field to preserve — flatten sources[] into a set of links and the ranking is gone. fetchedAt is what makes any two runs comparable. The ungrounded variant of this record is identical except for "sources": [] — same status, same one credit for the delivered record. Failed records cost nothing.
Create a free key → and both one-liners run as-is — or watch a live fetch first in the no-signup playground. Free tier, no credit card.
Start for freeSeveral queries, one dated ledger
One run answers today's question. The two questions worth a script — which hosts recur across a topic, and what share of queries ground at all — need a query set, one normalization convention, and rows that outlive the run. Queries go in a plain file, one per line:
best headless cms for a docs site
headless cms with a git-based workflow
migrate wordpress to a headless cms
sanity vs contentful for a small team#!/usr/bin/env bash
# gemini-citations.sh — Gemini citations for every query in queries.txt,
# appended as dated rows to gemini-citations.csv. Two tallies per run:
# which hosts got cited, and how many queries grounded at all.
set -euo pipefail
KEY="ag_live_your_key_here"
CSV="gemini-citations.csv"
TODAY=$(date -u +%F)
# Comma-safe columns first, free text last — cut and awk stay honest.
[ -f "$CSV" ] || echo 'date,grounded,position,n_sources,host,query,url,title' > "$CSV"
delivered=0
grounded=0
while IFS= read -r query; do
[ -n "$query" ] || continue
# jq builds the body — an apostrophe in a query can't break the JSON.
body=$(jq -cn --arg q "$query" '{query: $q, surfaces: ["gemini"]}')
resp=$(curl -s --max-time 200 https://api.agentgeo.org/v1/fetches \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d "$body") || { echo "curl failed: $query" >&2; continue; }
if [ "$(jq -r '.status' <<<"$resp")" != "completed" ]; then
# Zero credits — the record may carry a snapshot_id worth redeeming.
echo "not completed: $query" >&2
continue
fi
delivered=$((delivered + 1))
if [ "$(jq '.answers[0].sources | length' <<<"$resp")" -eq 0 ]; then
# Ungrounded run — write the row anyway. The zeros are the metric.
jq -rn --arg d "$TODAY" --arg q "$query" \
'[$d, 0, "", 0, "", $q, "", ""] | @csv' >> "$CSV"
continue
fi
grounded=$((grounded + 1))
jq -r --arg d "$TODAY" --arg q "$query" '
.answers[0].sources as $s
| $s | sort_by(.position)[]
| [$d, 1, .position, ($s | length),
(.url | sub("^https?://(www\\.)?"; "") | split("/")[0] | ascii_downcase),
$q, (.url | split("?")[0]), .title]
| @csv' <<<"$resp" >> "$CSV"
done < queries.txt
if [ "$delivered" -gt 0 ]; then
echo "grounding rate this run: $grounded/$delivered"
fiThree choices in there are deliberate. Bodies are built with jq -cn --arg, because the first apostrophe in a real query would otherwise end the single-quoted -d string mid-payload. Ungrounded runs are written as rows with grounded set to 0 rather than skipped — drop them and every rate you compute later reads 100%. And the comma-proof columns — date, flag, position, count, host — sit ahead of query and title, the two fields that can carry commas, so cut -d, and awk -F, on the leading columns never lie.
# Most-cited hosts across the whole ledger
tail -n +2 gemini-citations.csv | cut -d, -f5 | tr -d '"' | grep -v '^$' \
| sort | uniq -c | sort -rn | head -15
# Grounding trend by run date — each query counted once per run
tail -n +2 gemini-citations.csv | tr -d '"' \
| awk -F, '$2==0 || $3==1 { total[$1]++; g[$1]+=$2 }
END { for (d in total) printf "%s %d/%d grounded\n", d, g[d], total[d] }' \
| sort
# crontab -e — Mondays 06:40 UTC; each run adds a dated layer
40 6 * * 1 cd /home/you/geo && ./gemini-citations.sh >> gemini.log 2>&1The first command is the host table. The second is the number this surface is really measured by, and it only exists because the zero rows were written — the awk condition counts each run once, via the ungrounded row or the position-1 row of a grounded one. After a few cron cycles the same file answers the diff questions — hosts that entered, hosts that dropped, where your own pages moved — as filters over two date ranges. Spend stays visible without a dashboard: a delivered record is one credit whether it cited nine pages or none, and creditsCharged on every response keeps the log honest.
No knobs on this surface
On chatgpt you get one lever: web_search: true nudges the fetch toward browsing. The gemini surface has no lever at all. web_search is ignored here; language is read only by google_ai_overview, where it maps to Google's hl; country steers google_ai_overview and copilot. The correct Gemini body is query plus surfaces, full stop — anything more documents a knob that doesn't exist, and the next person reading the script will assume it works.
- Grounding is the engine's call, made per query. Gemini browses when it judges the question needs current pages and answers from its own weights when it doesn't. You observe the decision in
sources[]; you can't request it. - An empty
sources[]is a delivered record. FullanswerText, afetchedAt, one credit. Branch on the run'sstatusfirst andsources | lengthsecond — they say different things, and the script treats them differently. - The rate is the headline metric. A query whose runs start arriving with sources is a query the engine has begun treating as evidence-hungry — the point at which competing for the cited slots starts to pay. The trend command above is how you catch that week instead of hearing about it later.
Grounding varies run to run: the same query can arrive with six sources on Monday and none on Thursday. Nothing in the plumbing broke — which is exactly why the script writes a grounded flag on every row instead of trusting any single fetch.
Common problems
- Real queries break single-quoted JSON. The first apostrophe — "what's the best headless cms" — terminates a single-quoted
-dstring and the API receives invalid JSON. Build every body withjq -cn --arg, in one-off commands as well as the script. - A client timeout below the server's hold. The API keeps the request open for up to 180 seconds while a slow scrape finishes;
--max-time 200keeps curl in the room. The version that wastes an afternoon is an outer layer — a CI step, a proxy, a wrapper — with its own shorter limit that cuts the connection at second 60. - A
failedrun is not a lost run. A scrape that exceeds the 180-second budget returnsstatus: "failed"at zero credits withproviderFields.snapshot_idon the record. POST again with top-level"snapshot_id"and the same single surface —["gemini"]— and the finished answer is redeemed instead of re-scraped. - Retrying an ungrounded answer. Re-running until
sources[]fills up spends a credit per attempt to re-learn the same fact. Write the zero row; if the engine starts grounding that query next week, the scheduled run will catch it, and the ledger will show exactly when. - Two normalization conventions in one file. The script lowercases hosts, strips
www.and drops query strings at write time. Change any of that later and old rows stop being comparable with new ones — restate the whole CSV, or the diff you built the ledger for will report a migration as movement.
Summary
One POST with two fields returns Gemini's answer and its citations at a stable jq path; the shell does the rest. Keep --max-time above the 180-second hold, build bodies with jq -cn, write the ungrounded rows, and put the comma-safe columns first so cut and awk survive punctuation. The ledger then answers both questions this surface poses — which hosts keep getting cited, and how often anyone does — with uniq -c and one awk line. If the job outgrows the shell, the Python and Node.js versions call the identical endpoint; the wider set of shell idioms lives on the cURL API page, and the docs list every request field and which surface honours it.
Get a free API key → · Run a fetch without signing up → — Free tier, no credit card required.
Start for freeFrequently asked questions
- How do I get Gemini citations with curl?
- POST to
https://api.agentgeo.org/v1/fetcheswith anAuthorization: Bearer ag_live_...header and the body{"query": "...", "surfaces": ["gemini"]}, then readanswers[0].sources[]— each entry carriestitle,urlandposition. Piped throughjq -r '.answers[0].sources[] | [.position, .url] | @tsv'it's a ranked list in one command. Set--max-time 200, because the API holds slow surfaces for up to 180 seconds. - Why does the jq filter print nothing for some Gemini queries?
- Because the answer didn't ground. Gemini cites only when it browses, so a delivered record with
"sources": []is complete, correct and costs its one credit — the filter has nothing to iterate. Log the empty run instead of retrying it: the share of ungrounded runs is the grounding rate, and it's the most useful trend this surface produces. - Does web_search: true make Gemini return sources?
- No.
web_searchis honoured only by thechatgptsurface; ongeminiit's silently ignored, as arelanguageandcountry. There is no request field that makes Gemini browse — grounding is the engine's own per-query decision, which is why the script on this page measures how often it happens rather than trying to switch it on. - How do I compute Gemini's grounding rate in the shell?
- Keep a
groundedflag on every row, including the empty ones, and count each query-run once — the ungrounded row directly, or the position-1 row of a grounded run. The script prints the rate live from two counters, and one awk line over the CSV gives the per-date trend:awk -F, '$2==0 || $3==1 { total[$1]++; g[$1]+=$2 }'with aprintfin theENDblock. - Can I get the same Gemini citation data inside Claude Code?
- Yes — the identical records are available over MCP:
claude mcp add agentgeo -- npx -y agentgeo-mcp --key ag_live_...and the agent can fetch Gemini's answer and readsources[]mid-conversation. Scheduled collection is still the shell script's job; cron plus a flat CSV needs no agent in the loop.
Keep reading