Get Gemini citations in Node.js
Getting Gemini's citations out of Node takes one POST and zero dependencies: send your query to https://api.agentgeo.org/v1/fetches with "surfaces": ["gemini"] and the response carries a sources[] array — title, URL and position for every page the answer drew on. Node 18's built-in fetch covers the transport, so there is no npm package to install and none exists to invent. What follows is the whole job in one language: a minimal first call with the JSON it returns, a production-shaped script that aggregates hosts across a query set and logs every run to JSONL, the behaviour specific to this surface — Gemini only cites when it grounds — and the failure modes you will hit on the first slow scrape.
The short version: POST https://api.agentgeo.org/v1/fetches with {"query": "...", "surfaces": ["gemini"]} and read answers[0].sources[] — every entry a { title, url, position } for a page the answer drew on. Native fetch handles it; nothing to install. The rest of this page is what a script needs around that call to be worth running twice: a timeout that outlasts the API's 180-second budget, host normalization, aggregation across a query set, a grounding-rate counter, and rows on disk so a future run has something to diff against.
One query, one fetch call
Save this as gemini-citations.mjs and run it with node gemini-citations.mjs. The only line that isn't standard library is the endpoint.
// Node 18+, zero dependencies. ESM, so top-level await just works.
const resp = await fetch("https://api.agentgeo.org/v1/fetches", {
method: "POST",
headers: {
"Authorization": "Bearer ag_live_your_key_here",
"Content-Type": "application/json",
},
body: JSON.stringify({
query: "best uptime monitoring tool for an API-first startup",
surfaces: ["gemini"],
}),
// The API holds slow surfaces open for up to 180s. Outlast it, never undercut it.
signal: AbortSignal.timeout(200_000),
});
if (!resp.ok) throw new Error(`AgentGEO ${resp.status}: ${await resp.text()}`);
const { answers } = await resp.json();
const answer = answers[0];
console.log(answer.answerText.slice(0, 300));
for (const src of answer.sources ?? []) {
console.log(`${src.position}. ${src.title} - ${src.url}`);
}A grounded answer comes back like this — one credit charged for the delivered record, and every source carrying the three fields the rest of this page is built on:
{
"id": "run_3f9c2e7b41aa",
"query": "best uptime monitoring tool for an API-first startup",
"surfaces": ["gemini"],
"status": "completed",
"recordsDelivered": 1,
"creditsCharged": 1,
"answers": [
{
"surfaceKey": "gemini",
"answerText": "For an API-first startup the strongest options are ...",
"sources": [
{
"title": "Uptime monitoring for APIs: a practical guide",
"url": "https://example.com/api-uptime-guide",
"position": 1
},
{
"title": "Status pages and on-call: choosing a stack",
"url": "https://example.com/status-page-stack",
"position": 2
}
],
"fetchedAt": "2026-07-26T09:14:03Z"
}
]
}Three fields per source do the work: url is what you aggregate by host and attribute by path, position is the order Gemini used the pages in, and the record-level fetchedAt is what makes two runs comparable. An answer that didn't ground arrives in the same shape with an empty sources[] — a result common enough on this surface that the next script treats it as a first-class row rather than an error.
From one call to a dataset
The production shape adds four things to the minimal call: a query set, host normalization so www.globex.com/a?utm_source=x and globex.com/a land on the same key, a Map per host with a separate count for position 1, and an append-only JSONL log. It also computes the metric specific to this surface: what share of delivered answers grounded at all.
// Node 18+, ESM, zero dependencies.
import { appendFile } from "node:fs/promises";
const KEY = process.env.AGENTGEO_KEY ?? "ag_live_your_key_here";
const LOG = "gemini-citations.jsonl";
const 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",
"synthetic monitoring vs real user monitoring",
];
// One page, one key: lowercase host, strip www. Keep paths in the log rows.
function host(url) {
const h = new URL(url).hostname.toLowerCase();
return h.startsWith("www.") ? h.slice(4) : h;
}
async function fetchGemini(query) {
const resp = await fetch("https://api.agentgeo.org/v1/fetches", {
method: "POST",
headers: {
"Authorization": `Bearer ${KEY}`,
"Content-Type": "application/json",
},
// No web_search, language or country here - gemini honours none of them.
body: JSON.stringify({ query, surfaces: ["gemini"] }),
signal: AbortSignal.timeout(200_000),
});
if (!resp.ok) throw new Error(`AgentGEO ${resp.status}: ${await resp.text()}`);
return resp.json();
}
const cited = new Map(); // host -> citations across every answer
const topSlot = new Map(); // host -> citations at position 1
let delivered = 0;
let grounded = 0;
// Sequential on purpose: the log stays ordered and failures stay attributable.
// Switch to a capped fan-out once the query set grows past a dozen.
for (const query of QUERIES) {
const run = await fetchGemini(query);
for (const answer of run.answers) {
if (answer.status === "failed") {
const snap = answer.providerFields?.snapshot_id;
console.error(`${query}: failed at 0 credits - snapshot_id ${snap}`);
continue;
}
delivered += 1;
const sources = answer.sources ?? [];
if (sources.length > 0) grounded += 1;
for (const src of sources) {
const h = host(src.url);
cited.set(h, (cited.get(h) ?? 0) + 1);
if (src.position === 1) topSlot.set(h, (topSlot.get(h) ?? 0) + 1);
}
// Every run becomes a row, the ungrounded ones included. On this surface
// they are the measurement, not noise.
const row = {
fetchedAt: answer.fetchedAt,
query,
surfaceKey: answer.surfaceKey,
grounded: sources.length > 0,
sources: sources.map(({ position, url, title }) => ({ position, url, title })),
};
await appendFile(LOG, JSON.stringify(row) + "\n");
}
}
console.log(`\ngrounding rate: ${grounded}/${delivered} delivered answers`);
console.log("most-cited hosts");
const ranked = [...cited.entries()].sort((a, b) => b[1] - a[1]);
for (const [h, n] of ranked.slice(0, 10)) {
console.log(` ${String(n).padStart(2)}x ${h} (position 1: ${topSlot.get(h) ?? 0})`);
}Output is a ranked host table on the console and one line per run in the log. Delivered records cost one credit each whether or not they cite anything; failed records cost nothing.
The console summary is disposable; gemini-citations.jsonl is the artifact. Two runs give you a diff — hosts that entered, hosts that dropped, queries whose grounded boolean flipped — and none of it can be reconstructed later if run one was never written down.
Get a free API key → · Run one fetch without signing up → — Free tier, no credit card required.
Start for freeWhat the gemini surface does differently
Gemini is a chatbot surface: it browses when it decides the question needs current evidence, and cites only when it browses. There is no request flag that changes this — grounding is observed, not requested — which is why the script above treats the grounding rate as an output rather than trying to force sources to exist. The request-side consequence is a shorter body than on other surfaces:
| Request field | On gemini | Where it is honoured |
|---|---|---|
web_search | Ignored — grounding is the engine's own decision | chatgpt only |
language | Ignored | google_ai_overview only (Google's hl) |
country | Ignored | google_ai_overview (gl) and copilot (steers Bing results) |
Passing an ignored field doesn't error — it just documents a wrong assumption in your own codebase, so the script sends none of them. Everything else is identical across the six engines: same endpoint, same sources[] shape, so pointing fetchGemini at perplexity is a one-string change, and the full six-surface fan-out is covered on the Node.js API page. What the grounding patterns mean at the strategy level — which topics are contested, which are settled — is the subject of how to pull citations from Gemini; this page's job ends at clean rows.
Common failure modes
In rough order of how soon they show up:
- Top-level
awaitneeds ESM.fetchis global from Node 18, but the scripts above use top-levelawait, which CommonJS rejects withSyntaxError: await is only valid in async functions. Name the file.mjsor set"type": "module"inpackage.json— don't wrap everything in an IIFE out of habit. - A delivered answer with
sources: []is data, not a bug. Gemini cites only when it grounds. Branch on the record'sstatusfirst andsources.lengthsecond, and write the empty runs to the log — silently skipping them is how a 40% grounding rate gets reported as 100%. - Timeouts have to clear 180 seconds. The API holds a slow scrape for up to 180s, so
AbortSignal.timeout(200_000)is the floor, not a suggestion. ATimeoutErrorat 30 or 60 seconds means something on your side — a gateway, a leftover default — hung up while the answer was still being fetched. - Normalize URLs before counting. Tracking parameters, trailing slashes and
www.split one page across severalMapkeys and quietly deflate its count.new URL(src.url).hostname, lowercased and stripped ofwww., is enough for host-level counts — but keep the path in the stored rows, because host-level counts can't tell you which page earned the citation. - Scrapes that blow the 180-second budget come back
failedat zero credits, withproviderFields.snapshot_idon the record. A follow-up POST with top-level"snapshot_id"and the same single surface —["gemini"]— redeems the finished answer instead of paying to scrape it again.
Summary
The minimal version is four lines: POST the query with "surfaces": ["gemini"], read answers[0].sources[], done. The version worth running twice adds a timeout above 180 seconds, host normalization through new URL(), a Map for the counts, and an append to gemini-citations.jsonl so the next run has something to diff against. And because this surface only cites when it grounds, keep the empty runs — the grounding rate they produce usually moves earlier than anything in the host table.
Point the script at your own query set. Get a free key — free tier, no credit card — or check the request shape in the docs first.
Start for freeFrequently asked questions
- How do I get Gemini citations in Node.js without an SDK?
- There is no SDK to install — in any language. POST to
https://api.agentgeo.org/v1/fetcheswith Node 18's built-infetch, aBearer ag_live_key and{"query": "...", "surfaces": ["gemini"]}, then readanswers[0].sources[]: each entry carriestitle,urlandposition. The whole integration is standard library. - Why is sources[] empty in my Gemini API response?
- Because Gemini answered without browsing. It only cites when it grounds an answer in fetched pages; otherwise the record still arrives complete, with the full
answerTextand an empty list. Treat it as a real result — log it with agrounded: falseflag, because the share of runs that ground is the trend worth watching on this surface. - Can I force Gemini to cite sources with web_search: true?
- No.
web_searchis honoured only by thechatgptsurface; ongeminiit is ignored, as arelanguageandcountry. Grounding is the engine's own decision, which is exactly why the grounding rate is worth measuring — it moves when Google decides a topic needs current evidence, and no request flag anticipates that. - Is there an npm package for the AgentGEO API?
- Not for calling the REST endpoint — native
fetchcovers it with zero dependencies. The one npm package that exists isagentgeo-mcp, the MCP server:claude mcp add agentgeo -- npx -y agentgeo-mcp --key ag_live_...gives Claude Code or any MCP client the same records, which matters when an agent rather than a cron job is the consumer. - How do I add TypeScript types for the response?
- About twenty lines by hand: a
SurfaceKeyunion, aSourceinterface withtitle,urlandposition, anAnswerwithsurfaceKey,answerText,sourcesandfetchedAt, and aFetchRunwrappinganswers[]. The TypeScript page has the full typed wrapper ready to paste.
Keep reading