Tutorial2026-07-276 min read

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.

Tutorial

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 free

Several 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"
fi

Three 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>&1

The 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. Full answerText, a fetchedAt, one credit. Branch on the run's status first and sources | length second — 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 -d string and the API receives invalid JSON. Build every body with jq -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 200 keeps 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 failed run is not a lost run. A scrape that exceeds the 180-second budget returns status: "failed" at zero credits with providerFields.snapshot_id on 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.

Frequently asked questions

How do I get Gemini citations with curl?
POST to https://api.agentgeo.org/v1/fetches with an Authorization: Bearer ag_live_... header and the body {"query": "...", "surfaces": ["gemini"]}, then read answers[0].sources[] — each entry carries title, url and position. Piped through jq -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_search is honoured only by the chatgpt surface; on gemini it's silently ignored, as are language and country. 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 grounded flag 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 a printf in the END block.
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 read sources[] mid-conversation. Scheduled collection is still the shell script's job; cron plus a flat CSV needs no agent in the loop.

Keep reading