Tutorial2026-07-286 min read

Get Microsoft Copilot citations in Node.js

POST one request from Node's built-in fetch and Microsoft Copilot's citations come back as data: a sources[] array of { title, url, position } on the answer record, with a fetchedAt timestamp. No Puppeteer, no npm install — Node 18+ ships everything this needs. Copilot grounds its replies in a live Bing search, so the list is usually dense, which shifts the work from getting a citation to aggregating many of them. Below is the whole path in one language: a minimal call, a schedulable script that runs a query set, normalizes URLs with new URL() and appends JSON Lines, then the copilot-specific behaviour — the locale knob, the delivered-but-uncited answer, the redeemable slow scrape — and where it breaks first.

Tutorial

The shortest honest answer fits in a sentence: POST https://api.agentgeo.org/v1/fetches with {"query": "...", "surfaces": ["copilot"]} and a Bearer ag_live_ key, then read answers[0].sources[] — each citation is { title, url, position }, already ordered. Everything after that sentence is the difference between a one-off and a dataset: a client timeout that outlasts slow scrapes, URL normalization so one page counts once, and rows on disk with their fetchedAt so runs can be compared.

One call, one ranked citation list

Node 18 made fetch global, so the integration is one file and zero npm install. The one optional field worth setting on this surface is country: Copilot honours it and it steers the Bing results the answer is built from. Save this as citations.mjs — ESM, so top-level await just works:

// Node 18+ — built-in fetch, zero dependencies. Run with: node citations.mjs
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 applicant tracking system for small business",
    surfaces: ["copilot"],
    country: "US", // copilot-only knob: steers the Bing results it cites
  }),
  signal: AbortSignal.timeout(200_000), // the API holds slow scrapes up to 180s — stay above that
});
if (!resp.ok) throw new Error(`AgentGEO ${resp.status}: ${await resp.text()}`);

const { answers } = await resp.json();
for (const src of answers[0].sources) {
  console.log(src.position, src.url);
}

What comes back is one JSON document per run. Everything the rest of this page builds is made from three fields on each source — title, url, position — plus the record's fetchedAt:

{
  "id": "run_2b7f90c14ade",
  "query": "best applicant tracking system for small business",
  "surfaces": ["copilot"],
  "status": "completed",
  "recordsDelivered": 1,
  "creditsCharged": 1,
  "answers": [
    {
      "surfaceKey": "copilot",
      "answerText": "For a small hiring team the shortlist usually includes ...",
      "sources": [
        { "title": "Best Applicant Tracking Systems 2026", "url": "https://example.com/best-ats", "position": 1 },
        { "title": "ATS Pricing for Small Teams", "url": "https://example.org/ats-pricing", "position": 2 }
      ],
      "fetchedAt": "2026-07-27T14:03:58Z"
    }
  ]
}

One record delivered, one credit charged, and the citations arrive already ranked — position starts at 1 and preserves the order Copilot cited its sources. Because those URLs came out of a live Bing search they're real, openable pages. If sources is empty, that's a real result rather than a transport error; the section on Copilot's behaviour below covers why.

The version you can schedule

A single list answers today's question. The version worth scheduling runs a related set of queries, normalizes every URL to a stable host and path, counts hosts in a Map, and appends each citation as one JSON line with its timestamp. JSON Lines beats rewriting a JSON array: appends survive a crashed run, and a month of output stays greppable. It also keeps the delivered-but-uncited answers as their own rows, because on Copilot they carry information.

// pull-copilot-citations.mjs — Node 18+, ESM, no npm install.
// Runs a query set against copilot, counts normalized hosts, and appends
// one JSON line per citation (and per uncited answer) for future diffs.
import { appendFile } from "node:fs/promises";

const KEY = process.env.AGENTGEO_KEY ?? "ag_live_your_key_here";
const COUNTRY = "US"; // steers Bing — keep one file per market
const OUT = `copilot-citations-${COUNTRY.toLowerCase()}.jsonl`;

const QUERIES = [
  "best applicant tracking system for small business",
  "ats with structured interview scorecards",
  "greenhouse alternatives for a 20-person company",
];

async function fetchCopilot(query) {
  const resp = await fetch("https://api.agentgeo.org/v1/fetches", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ query, surfaces: ["copilot"], country: COUNTRY }),
    signal: AbortSignal.timeout(200_000),
  });
  if (!resp.ok) throw new Error(`AgentGEO ${resp.status}: ${await resp.text()}`);
  return resp.json();
}

// One page, one identity: lowercase host without www., path without the
// trailing slash, query string dropped (tracking params split counts).
function normalize(raw) {
  try {
    const u = new URL(raw);
    return {
      host: u.hostname.toLowerCase().replace(/^www\./, ""),
      path: u.pathname.replace(/\/+$/, "") || "/",
    };
  } catch {
    return null; // malformed URL in a source — rare, but don't kill the run
  }
}

const hostCounts = new Map();

for (const query of QUERIES) {
  let run;
  try {
    run = await fetchCopilot(query);
  } catch (err) {
    console.error(`skipped "${query}": ${err.message}`);
    continue; // one bad query shouldn't cost you the other rows
  }

  for (const answer of run.answers) {
    if (answer.status === "failed") {
      // Slow scrape: zero credits, redeemable via providerFields.snapshot_id.
      const snap = answer.providerFields?.snapshot_id;
      console.error(`failed at 0 credits, snapshot_id=${snap}: ${query}`);
      continue;
    }

    const sources = answer.sources ?? [];
    if (sources.length === 0) {
      // Delivered, but Copilot answered without searching Bing. Keep the zero —
      // it is not a host losing ground, and it should not be counted as one.
      await appendFile(
        OUT,
        JSON.stringify({ query, sourceCount: 0, fetchedAt: answer.fetchedAt }) + "\n"
      );
      continue;
    }

    for (const src of sources) {
      const page = normalize(src.url);
      if (!page) continue;
      hostCounts.set(page.host, (hostCounts.get(page.host) ?? 0) + 1);
      await appendFile(
        OUT,
        JSON.stringify({
          query,
          host: page.host,
          path: page.path,
          position: src.position,
          sourceCount: sources.length,
          title: src.title,
          url: src.url,
          fetchedAt: answer.fetchedAt,
        }) + "\n"
      );
    }
  }
}

const ranked = [...hostCounts.entries()].sort((a, b) => b[1] - a[1]);
console.log("Most-cited hosts");
for (const [host, n] of ranked.slice(0, 15)) {
  console.log(String(n).padStart(4), "", host);
}

Two decisions are deliberate. The queries run sequentially — a live Copilot fetch can take a couple of minutes, and when one fails you want the failure attributable to its query rather than lost inside a Promise.all rejection. And every citation row carries sourceCount: position 2 of 3 and position 2 of 12 are different outcomes, and the denominator is unrecoverable later if you didn't store it.

Row fieldWhy it's in every line
queryThe grouping key. Several related queries say more about a topic than one, but only if each row remembers which question produced it.
host + pathHost feeds the aggregate count; path tells you which page earned the citation. Reporting at domain level is fine — storing at domain level throws the answer away.
position + sourceCountRank only means something against its denominator. Store both, or your trend line moves for reasons that have nothing to do with you.
fetchedAtThe anchor for every comparison. Rows without timestamps are a list; rows with them are a series.

The JSONL file is the actual product — the top-15 console table is a byproduct. Once two runs exist, the interesting report is a set operation per query on host + path: who entered, who dropped, whose position moved. That's ten lines of Node against two files, and it's the report you cannot reconstruct if the first run was never written down. Keep the sourceCount: 0 rows in it too — they mark the weeks a query wasn't grounded, which is why a host's count fell without anyone being outranked.

What the copilot surface does differently

The request and response shape is identical across all six engines; the behaviour is not. Three things are specific to copilot:

  • Bing grounding makes the citations dense and real. Copilot searches Bing before it writes, so most delivered records carry a source list — denser than Gemini, broadly on par with Perplexity — and every url is a page Bing served, openable now rather than reconstructed from training.
  • country is the knob that matters; the other two are dead weight. country steers the Bing results, so US and GB are different datasets — build the request per market and keep a file each. web_search is honoured only by chatgpt and language only by google_ai_overview; both are ignored here, so leaving them in the body just documents a lever that doesn't exist.
  • Delivered doesn't guarantee cited. Copilot occasionally answers from the model without searching, and that record delivers with a full answerText, one credit and an empty sources[]. Rarer than on Gemini, but frequent enough that folding it into the host counts makes every domain look like it's slipping. The script writes it as a sourceCount: 0 row instead.

The surface is consumer Microsoft Copilot at copilot.microsoft.com — not GitHub Copilot in your editor and not Microsoft 365 Copilot in Office. They're different products with different answers; if you benchmark against what a teammate sees in their IDE, you're comparing two things. When you're ready to ask the same question of the other five engines, the request shape doesn't change; the six-engine fan-out lives on the Node.js API page.

Where it breaks first

  • An AbortError long before the API gave up. The endpoint holds a request for up to 180 seconds while a slow surface finishes, so a habitual AbortSignal.timeout(30_000) cancels scrapes that were about to succeed. The house convention is 200_000 — above the server's budget, below forever.
  • A scrape that outlives even the 180-second budget — and Copilot is a slow surface, so you'll meet this one. It comes back status: "failed" at zero credits with providerFields.snapshot_id. Don't re-run the query; POST the id back with the same single surface and redeem the finished answer for free.
  • One page, four rows. ?utm_source=, trailing slashes and www. variants split a single page's count into fragments. The normalize() above handles it by construction — new URL(), lowercase host, path only — but if you roll your own, normalize before the Map, not in the report.
  • Mixing markets in one file. A US run and a GB run cite different hosts because Bing served different pages. Put country in the filename, not just the request, or a later diff reads a locale switch as a ranking change.
  • Hunting for npm install agentgeo. There's no SDK in any language; built-in fetch is the integration. The one real npm package is agentgeo-mcp, the MCP server (claude mcp add agentgeo -- npx -y agentgeo-mcp --key ag_live_...) — for when the consumer is an agent in your editor rather than a script.
async function redeem(snapshotId) {
  const resp = await fetch("https://api.agentgeo.org/v1/fetches", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${KEY}`,
      "Content-Type": "application/json",
    },
    // snapshot_id needs exactly one surface — the one that produced it.
    body: JSON.stringify({ snapshot_id: snapshotId, surfaces: ["copilot"] }),
    signal: AbortSignal.timeout(200_000),
  });
  if (!resp.ok) throw new Error(`AgentGEO ${resp.status}: ${await resp.text()}`);
  return resp.json();
}

Summary

One fetch gets you a ranked, structured citation list for any query Copilot searches; a script of about fifty lines turns that into a dataset with hosts normalized, denominators stored and every row timestamped. Set country and log per market, keep the sourceCount: 0 rows, redeem the failed scrapes instead of re-running them, and keep the file — the JSONL is what makes the second month more informative than the first. The Python and cURL versions hit the same endpoint with the same fields, so the language changes nothing about the data.

Frequently asked questions

How do I get Microsoft Copilot's citations in Node.js without Puppeteer?
POST to https://api.agentgeo.org/v1/fetches with {"query": "...", "surfaces": ["copilot"]} and a Bearer ag_live_ key, using the fetch built into Node 18+. Each answer record carries sources[] with title, url and position — the citations, already structured and ordered. No browser automation, no proxy pool, no npm dependency at all.
Does Microsoft Copilot always return sources?
Usually. Copilot grounds its answers in a live Bing search, so most delivered records carry a source list — denser than Gemini, broadly comparable to Perplexity. But it sometimes answers from the model without searching, and then the record still delivers with a full answerText and an empty sources[]. Log that as delivered-and-uncited, not as a failed fetch.
What timeout should I set on fetch for the Copilot surface?
200 seconds — AbortSignal.timeout(200_000). The API holds a request up to 180 seconds while slow surfaces finish, and Copilot is one of the slower ones, so any client timeout below that aborts scrapes about to complete. Anything above the 180-second server budget works; 200 seconds is the convention used throughout the docs.
Should I send country, language or web_search on a copilot request?
Send country — Copilot honours it and it steers the Bing results it cites, so it materially changes the data. Leave off language and web_search: language is read only by google_ai_overview and web_search only by chatgpt, so both are ignored here and only clutter the body.
How do I collect a Copilot fetch that came back failed?
A slow scrape returns status: "failed" at zero credits with providerFields.snapshot_id on the record. POST that id back — { snapshot_id, surfaces: ["copilot"] }, exactly one surface — and the finished answer is redeemed instead of re-scraped. Because Copilot is a slow surface, wiring this redeem step into a scheduled job is worth doing up front.

Keep reading