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.
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.
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 everyurlis 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
answerTextand one credit charged, and an emptysources[]. 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 inuncited. countryis 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 bychatgpt) andlanguage(honoured only bygoogle_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 notjson=.requests.post(url, data={...})form-encodes the body and the API rejects it. Thejson=kwarg serializes the dict and setsContent-Type: application/jsonin 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=30gives up on answers that were about to arrive, and notimeouthangs forever on a dead socket.timeout=200sits 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, soraise_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 carriesproviderFields.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. Thenormalize()above — lowercase host, dropwww., strip query string and trailing slash — is the floor. - Mixing markets in one file. A
USrun and aGBrun cite different hosts because Bing served different pages. Putcountryin 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.
Get a free API key → · Run one fetch without signing up → — Free tier, no credit card required.
Start for freeFrequently asked questions
- How do I get Microsoft Copilot citations in Python?
- POST to
https://api.agentgeo.org/v1/fetcheswithrequests, aBearer ag_live_key and the body{"query": "...", "surfaces": ["copilot"]}, then readanswers[0]["sources"]— each entry carries atitle, aurland aposition. 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.postcall. 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 notlanguageorweb_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
answerTextand one credit charged, just an emptysources[]. 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 withproviderFields.snapshot_id. POST that id back with the same singlecopilotsurface and the finished answer is redeemed — you collect it for free instead of paying to scrape the same query again.
Keep reading