Build a scheduled citation-tracking worker
A citation-tracking worker is a small scheduled job that answers one question on a cadence: is my domain still cited in what the AI engines say about my category? You build it from a cron trigger, one POST https://api.agentgeo.org/v1/fetches per query, a check for your host in each answer's sources[], and a store you append to. No headless browser, no proxies, no dashboard - a Cloudflare Worker with a scheduled handler does the whole loop in about 70 lines.
This guide uses a Cloudflare Worker because the cron trigger, the fetch runtime, and a KV store come for free and it costs nothing to run a weekly job - but the shape is identical on a Vercel cron, a GitHub Action, or a plain crontab calling a script. What matters is the discipline: append every run with its fetchedAt, normalize hosts before comparing, and treat a failed engine as missing data, not a lost citation.
Read this page with an AI
The worker does four things on a schedule: fan each tracked query across the surfaces you care about, pull sources[] from every answer, test whether your root domain is among the cited hosts, and append the result to a store keyed by run. Everything downstream - an alert, a chart, a monthly diff - reads that store. The collection job stays deliberately dumb; the intelligence is in what you do with the history.
The cron trigger
A Worker's scheduled handler runs on a cron expression declared in wrangler.jsonc. Weekly is the right default - engine answers vary run to run, so a daily job mostly stores noise. Bind a KV namespace for the history and keep your API key as a secret, never in the source.
{
"name": "citation-tracker",
"main": "src/worker.js",
"compatibility_date": "2026-01-01",
"triggers": {
// Mondays at 09:00 UTC. Weekly beats daily - engine answers are noisy.
"crons": ["0 9 * * 1"]
},
"kv_namespaces": [
{ "binding": "CITATIONS", "id": "<your-kv-namespace-id>" }
]
// Set the key as a secret, not here: npx wrangler secret put AGENTGEO_KEY
}The worker
The scheduled handler loops your query set, posts each across the surfaces, and records one entry per (query, surface) noting whether your domain was cited and at what position. Each run is stored under a timestamped key, so history is append-only by construction - you never overwrite, you accumulate.
const API = "https://api.agentgeo.org/v1/fetches";
const SURFACES = ["chatgpt", "perplexity", "google_ai_overview", "copilot", "gemini"];
// What you're tracking: your root domain, and the queries you want to stay cited in.
const BRAND_DOMAIN = "acme.com";
const QUERIES = [
"best project management software",
"asana alternatives",
"project management tools for small teams",
];
// Bare host: lowercase, drop www., so acme.com and www.acme.com compare equal.
function host(url) {
try {
const h = new URL(url).hostname.toLowerCase();
return h.startsWith("www.") ? h.slice(4) : h;
} catch {
return "";
}
}
async function fetchQuery(query, key) {
const resp = await fetch(API, {
method: "POST",
headers: {
"Authorization": "Bearer " + key,
"Content-Type": "application/json",
},
body: JSON.stringify({ query, surfaces: SURFACES }),
});
if (!resp.ok) throw new Error("fetches " + resp.status);
return resp.json();
}
async function collect(env) {
const runAt = new Date().toISOString();
const rows = [];
for (const query of QUERIES) {
const run = await fetchQuery(query, env.AGENTGEO_KEY);
for (const answer of run.answers) {
if (answer.status === "failed") {
// Missing answer, not a lost citation - record it as such.
rows.push({ query, surface: answer.surfaceKey, status: "failed" });
continue;
}
const sources = answer.sources || [];
const hit = sources.find((s) => {
const h = host(s.url);
return h === BRAND_DOMAIN || h.endsWith("." + BRAND_DOMAIN);
});
rows.push({
query,
surface: answer.surfaceKey,
status: "delivered",
cited: Boolean(hit),
position: hit ? hit.position : null,
fetchedAt: answer.fetchedAt,
});
}
}
// Append-only: one immutable record per run, keyed by timestamp.
await env.CITATIONS.put("run:" + runAt, JSON.stringify({ runAt, rows }));
return rows;
}
export default {
// Cron entrypoint - Cloudflare calls this on the schedule in wrangler.jsonc.
async scheduled(event, env, ctx) {
ctx.waitUntil(collect(env));
},
// Optional: hit the Worker URL to run it on demand while you build.
async fetch(request, env) {
const rows = await collect(env);
return Response.json({ ran: true, records: rows.length, rows });
},
};Deploy with npx wrangler deploy, set the key with npx wrangler secret put AGENTGEO_KEY, and the job runs itself every Monday. Hit the Worker's URL any time to run it on demand - handy while you're still shaping the query set. Each run leaves an immutable run:<timestamp> entry in KV; nothing is ever overwritten.
Wire it against a zero-credit ag_test_ key first - same records, no charge - then flip to ag_live_. Not building yet? Get a free AI-visibility audit โ - no card, no account.
Reading the history
The store is only useful because it accumulates. To turn it into a signal, list the run keys, load the last two, and diff citation status per (query, surface). A citation that flips from true to false on a query that matters is the alert worth wiring; a single-run flicker usually isn't.
async function latestTwo(env) {
const list = await env.CITATIONS.list({ prefix: "run:" });
const keys = list.keys.map((k) => k.name).sort(); // ISO keys sort chronologically
const [prevKey, currKey] = keys.slice(-2);
const prev = JSON.parse(await env.CITATIONS.get(prevKey));
const curr = JSON.parse(await env.CITATIONS.get(currKey));
const key = (r) => r.query + "|" + r.surface;
const before = new Map(prev.rows.map((r) => [key(r), r]));
return curr.rows
.filter((r) => r.status === "delivered")
.map((r) => ({ q: r.query, surface: r.surface, was: before.get(key(r))?.cited, now: r.cited }))
.filter((d) => d.was === true && d.now === false); // dropped citations only
}Track citations and mentions in separate columns even here. This worker records whether your domain is in sources[] - a browsing signal. Whether your brand is named in answerText is a different signal that survives on ChatGPT when sources[] is empty. If the worker only watches citations, a query where the engine stops naming you but never cited you in the first place slips past silently.
What breaks a scheduled collector
- A
failedengine is missing data, not a dropped citation. Any surface can returnstatus: "failed"at HTTP 200 - most often Google AI Overview, which is sometimes absent for a query. The worker records it asfailedand the diff skips it; count it as "not cited" and you'll fire a false alarm every time the engine simply didn't answer. When the failure was a scrape over the 180-second budget, the record costs zero credits and carriesproviderFields.snapshot_idyou can redeem later. - Skipping host normalization corrupts the match.
www.acme.com,acme.com, andacme.com/features?ref=aiare one host; compared raw they read as three and your citation status goes noisy. Lowercase, stripwww., and match the root domain (or a.+domain suffix) - thehost()helper is the floor. - Overwriting instead of appending destroys the trend. The value of the worker is the history. Key each run by its timestamp and never write back to a single "latest" key; if you overwrite, you have a status but no way to see it move.
- One run is a sample. Engine answers disagree with themselves between runs with no content change. Require a citation to stay dropped across two or three runs before you treat it as real, and diff consecutive runs rather than reacting to any single one.
- Secrets in source leak keys. Put the API key in a Worker secret (
wrangler secret put), not inwrangler.jsoncor the code. A key committed to a repo is a key you rotate.
Summary
A scheduled citation-tracking worker is a cron trigger, one POST per query across your surfaces, a normalized-host check against sources[], and an append-only store keyed by run timestamp. On Cloudflare that's a scheduled handler plus a KV binding - about 70 lines and nothing to babysit. Diff the last two runs for dropped citations on the queries that matter, keep mentions in their own column, and never count a failed engine as an absence. The same loop moves unchanged to a Vercel cron or a GitHub Action; the Node API page covers the endpoint, and tracking your brand across every engine covers the two signals in depth.
Get a free AI-visibility audit โ ยท Read the docs โ - No card, no account.
Get my free audit