Tutorial2026-07-266 min read

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.

Tutorial

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.

What 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 fieldOn geminiWhere it is honoured
web_searchIgnored — grounding is the engine's own decisionchatgpt only
languageIgnoredgoogle_ai_overview only (Google's hl)
countryIgnoredgoogle_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 await needs ESM. fetch is global from Node 18, but the scripts above use top-level await, which CommonJS rejects with SyntaxError: await is only valid in async functions. Name the file .mjs or set "type": "module" in package.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's status first and sources.length second, 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. A TimeoutError at 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 several Map keys and quietly deflate its count. new URL(src.url).hostname, lowercased and stripped of www., 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 failed at zero credits, with providerFields.snapshot_id on 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 free

Frequently 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/fetches with Node 18's built-in fetch, a Bearer ag_live_ key and {"query": "...", "surfaces": ["gemini"]}, then read answers[0].sources[]: each entry carries title, url and position. 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 answerText and an empty list. Treat it as a real result — log it with a grounded: false flag, 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_search is honoured only by the chatgpt surface; on gemini it is ignored, as are language and country. 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 fetch covers it with zero dependencies. The one npm package that exists is agentgeo-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 SurfaceKey union, a Source interface with title, url and position, an Answer with surfaceKey, answerText, sources and fetchedAt, and a FetchRun wrapping answers[]. The TypeScript page has the full typed wrapper ready to paste.

Keep reading