Tutorial2026-07-286 min read

Get Microsoft Copilot citations in Python

Getting Microsoft Copilot's citations in Python is one requests.post to https://api.agentgeo.org/v1/fetches with the query and "surfaces": ["copilot"] — the record comes back with answers[0].sources[], each entry a title, a url and a position, no HTML to parse. Copilot grounds its replies in a live Bing search, so most delivered answers carry a dense source list; the two things the code has to handle are the delivered answer that cited nothing, and the slow scrape that fails soft and is worth redeeming rather than re-running. Below: the minimal call, a script shaped for repeat runs, what the copilot surface does differently, and the failure modes that actually show up.

Tutorial

The shortest honest answer: requests.post to https://api.agentgeo.org/v1/fetches with your ag_live_ key as a bearer token and {"query": ..., "surfaces": ["copilot"]} as the body, raise_for_status(), then read answers[0]["sources"]. There is no pip package and none is needed — the client is four lines of requests. Copilot cites densely because it searches Bing before it writes, so unlike ChatGPT you rarely get an empty list; the work is in aggregating the hosts and keeping the rows so a later run has something to diff against.

The minimal call

One query, one surface, and one optional field that actually does something here: country. Copilot honours it — it steers the Bing results the answer is built from — so a US pull and a GB pull are different datasets and belong in different logs. web_search and language are read by other surfaces and do nothing on copilot, so they stay out of the body.

import requests

resp = requests.post(
    "https://api.agentgeo.org/v1/fetches",
    json={"query": "best expense management software for startups", "surfaces": ["copilot"], "country": "US"},
    headers={"Authorization": "Bearer ag_live_your_key_here"},
    timeout=200,  # the API holds slow scrapes up to 180s — stay above that
)
resp.raise_for_status()

answer = resp.json()["answers"][0]
for src in answer["sources"]:
    print(f"{src['position']:>2}. {src['url']}")

The response is one JSON object per run:

{
  "id": "run_c41a08e7d5b2",
  "query": "best expense management software for startups",
  "surfaces": ["copilot"],
  "status": "completed",
  "recordsDelivered": 1,
  "creditsCharged": 1,
  "answers": [
    {
      "surfaceKey": "copilot",
      "answerText": "For an early-stage team the names that come up most are ...",
      "sources": [
        { "title": "Best Expense Management Software 2026", "url": "https://example.com/expense-management", "position": 1 },
        { "title": "Corporate Cards With Built-in Expense Tracking", "url": "https://example.org/corporate-cards", "position": 2 }
      ],
      "fetchedAt": "2026-07-27T09:12:44Z"
    }
  ]
}

creditsCharged counts delivered records — one here. Because those URLs came out of a live Bing search, they're real pages you can open, not references reconstructed from training. position starts at 1 and is the nearest thing this surface has to a rank; the full field reference is in the docs.

From one call to a dataset

A single pull answers today's question. The version worth keeping runs a query set, normalizes every URL before counting — lowercase the host, drop www., strip the query string — and appends rows to a CSV with their fetchedAt timestamps. It also keeps two lists Copilot forces you to separate: the hosts that got cited, and the queries that were delivered without any citation at all.

#!/usr/bin/env python3
"""Fetch Microsoft Copilot's citations for a query set; append rows for future diffs."""
import csv
from collections import Counter
from pathlib import Path
from urllib.parse import urlparse, urlunparse

import requests

API = "https://api.agentgeo.org/v1/fetches"
KEY = "ag_live_your_key_here"
COUNTRY = "US"  # steers the Bing results copilot cites — keep one file per market
OUT = Path(f"copilot_citations_{COUNTRY.lower()}.csv")
FIELDS = ["query", "position", "n_sources", "host", "url", "title", "fetchedAt"]

QUERIES = [
    "best expense management software for startups",
    "expense tracking with corporate cards",
    "concur alternatives for small business",
]


def normalize(url):
    """Lowercase host, drop www., strip the query string and trailing slash."""
    parts = urlparse(url)
    host = parts.netloc.lower().removeprefix("www.")  # Python 3.9+
    clean = urlunparse((parts.scheme, host, parts.path.rstrip("/"), "", "", ""))
    return host, clean


def fetch(query):
    resp = requests.post(
        API,
        json={"query": query, "surfaces": ["copilot"], "country": COUNTRY},
        headers={"Authorization": f"Bearer {KEY}"},
        timeout=200,  # the API holds the request up to 180s — sit above it
    )
    resp.raise_for_status()
    return resp.json()


hosts = Counter()
top_slot = Counter()
uncited = []   # delivered answers that carried no sources at all
rows = []

for query in QUERIES:
    run = fetch(query)
    for answer in run["answers"]:
        if answer.get("status") == "failed":
            # Slow scrape: zero credits, redeemable — see the failure section.
            snap = (answer.get("providerFields") or {}).get("snapshot_id")
            print(f"failed at 0 credits, snapshot_id={snap}: {query}")
            continue

        sources = sorted(answer.get("sources") or [], key=lambda s: s["position"])
        if not sources:
            # Delivered, but Copilot answered without searching Bing. Legitimate.
            uncited.append(query)
            continue

        for src in sources:
            host, clean = normalize(src["url"])
            hosts[host] += 1
            if src["position"] == 1:
                top_slot[host] += 1
            rows.append({
                "query": query,
                "position": src["position"],
                "n_sources": len(sources),
                "host": host,
                "url": clean,
                "title": src["title"],
                "fetchedAt": answer.get("fetchedAt", ""),
            })

new_file = not OUT.exists()
with OUT.open("a", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=FIELDS)
    if new_file:
        writer.writeheader()
    writer.writerows(rows)

print(f"appended {len(rows)} rows to {OUT}")
print("\nmost-cited hosts")
for host, n in hosts.most_common(10):
    print(f"  {n:>2}x  {host}  (position 1: {top_slot[host]})")
if uncited:
    print(f"\ndelivered but uncited: {', '.join(uncited)}")

Two deliberate choices. n_sources rides on every row because position 2 of 3 and position 2 of 11 are different outcomes, and the denominator is gone if you don't store it. And the uncited list is kept separate rather than folded into the counts — a delivered-but-uncited answer is not a host losing ground, and if you let the two blur, every domain looks like it's slipping on the weeks Copilot happened to answer from the model.

The script runs as-is once KEY is real. Create a free API key → — free tier, no credit card — or try one fetch without signing up.

Get a free key

How the copilot surface behaves

All six engines share one request and response contract; what differs is behaviour. On copilot:

  • Citations are dense because Bing is doing the searching. Copilot grounds most replies in a live Bing search, so a populated sources[] is the common case — denser than Gemini, broadly on par with Perplexity. The payoff is that every url is a page Bing actually served and you can open right now, not a link the model half-remembered.
  • A delivered answer can still cite nothing. Copilot sometimes replies straight from the model without reaching for Bing, and that record arrives delivered, with the full answerText and one credit charged, and an empty sources[]. It's less common than on Gemini but common enough to distort a host table if you count it as a miss — which is why the script parks those queries in uncited.
  • country is the one knob, and it moves the data. Passing "country": "US" versus "GB" changes the Bing results underneath, so the cited hosts shift with the market. Keep one file per country; a mixed log can't be untangled later. web_search (honoured only by chatgpt) and language (honoured only by google_ai_overview) do nothing here.
  • This is consumer Copilot, not the developer or enterprise one. The surface is copilot.microsoft.com — the free web assistant — not GitHub Copilot and not Microsoft 365 Copilot. If you're comparing against what a colleague sees in their IDE or their Office sidebar, you're looking at different products.

For the task-level story — what citation data is for and how far the manual copy gets you — see how to pull citations from Microsoft Copilot. This page's job is the Python.

What breaks, and how to handle it

  • data= is not json=. requests.post(url, data={...}) form-encodes the body and the API rejects it. The json= kwarg serializes the dict and sets Content-Type: application/json in one move — use it, and delete any manual header you added.
  • A client timeout below 180 seconds cancels healthy requests. The API holds the connection up to 180 seconds while a slow scrape finishes, so timeout=30 gives up on answers that were about to arrive, and no timeout hangs forever on a dead socket. timeout=200 sits above the server's budget and below infinity.
  • A failed record is not "zero citations", and it's redeemable. A record can carry status: "failed" while the HTTP call returns 200, so raise_for_status() won't catch it. Copilot is a slow surface, so this is the failure you'll actually meet: the scrape exceeded the 180-second budget, the record cost zero credits, and it carries providerFields.snapshot_id. Don't re-run the query — POST the id back and collect the finished answer for free.
def redeem(snapshot_id):
    resp = requests.post(
        API,
        # snapshot_id needs exactly one surface — the one that produced it.
        json={"snapshot_id": snapshot_id, "surfaces": ["copilot"]},
        headers={"Authorization": f"Bearer {KEY}"},
        timeout=200,
    )
    resp.raise_for_status()
    return resp.json()
  • Skipping normalization corrupts every count. Bing hands back the same page wearing tracking parameters, trailing slashes and www. variants; unnormalized, one page becomes three rows and its host silently deflates. The normalize() above — lowercase host, drop www., strip query string and trailing slash — is the floor.
  • 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 will read a locale switch as a ranking change.

Summary

Four lines of requests gets you Copilot's Bing-grounded sources for one query; the script above turns that into a dataset — normalized hosts, positions and their denominators, a timestamp on every row, and the delivered-but-uncited answers kept in their own list. Set country and log per market, redeem the failed scrapes rather than re-running them, and append instead of overwrite so the second month can be diffed against the first. The same call works from Node.js and cURL, and the Python API page covers the endpoint beyond this one task.

Frequently asked questions

How do I get Microsoft Copilot citations in Python?
POST to https://api.agentgeo.org/v1/fetches with requests, a Bearer ag_live_ key and the body {"query": "...", "surfaces": ["copilot"]}, then read answers[0]["sources"] — each entry carries a title, a url and a position. Copilot grounds its answers in a live Bing search, so the list is usually populated, and it arrives already structured — no headless browser, no HTML parsing.
Is there a pip package or Python SDK for AgentGEO?
No — there's no official SDK in any language, and the endpoint doesn't need one: the whole client is a requests.post call. If you'd rather your coding agent make the call than a script, the MCP server exposes the same fetch as a tool: claude mcp add agentgeo -- npx -y agentgeo-mcp --key ag_live_....
Which Copilot does the copilot surface query — GitHub or Microsoft 365?
Neither. It's consumer Microsoft Copilot, the free web assistant at copilot.microsoft.com — not the GitHub coding assistant and not Microsoft 365 Copilot. It honours country, which shifts the Bing results it cites, but not language or web_search, and you fetch it by passing "surfaces": ["copilot"] to the endpoint.
Why did a Copilot answer come back with no sources?
Because it answered from the model without searching Bing for that query. The record still delivers, with the full answerText and one credit charged, just an empty sources[]. It's less common than on Gemini but real — log it as delivered-and-uncited rather than a failed fetch, since counting it as a miss makes every cited host look like it's slipping.
What happens when a Copilot fetch is too slow?
Copilot is a slow surface, so a scrape occasionally exceeds the API's 180-second budget. The record comes back status: "failed" at zero credits with providerFields.snapshot_id. POST that id back with the same single copilot surface and the finished answer is redeemed — you collect it for free instead of paying to scrape the same query again.

Keep reading