Skip to content
Tutorial8 min read2026-10-06

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.

Tutorial

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.

wrangler.jsonc
{
  "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.

src/worker.js
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.

Get my free audit

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.

diff the two most recent runs
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 failed engine is missing data, not a dropped citation. Any surface can return status: "failed" at HTTP 200 - most often Google AI Overview, which is sometimes absent for a query. The worker records it as failed and 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 carries providerFields.snapshot_id you can redeem later.
  • Skipping host normalization corrupts the match. www.acme.com, acme.com, and acme.com/features?ref=ai are one host; compared raw they read as three and your citation status goes noisy. Lowercase, strip www., and match the root domain (or a .+domain suffix) - the host() 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 in wrangler.jsonc or 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.

FAQ

Direct answers to the questions this page raises.

Run a cron job that POSTs each tracked query to https://api.agentgeo.org/v1/fetches, checks whether your normalized root domain appears in each answer's sources[], and appends the result to a store keyed by run timestamp. On Cloudflare Workers this is a scheduled handler plus a KV namespace; the same shape works on a Vercel cron, a GitHub Action, or plain crontab.

The Worker only orchestrates: a cron trigger, a fetch to the API, and a KV write. The API returns each engine's answer as JSON, so there's no headless browser, no proxy pool, and no per-engine scraper to keep alive - the part that actually rots. The Worker's free tier also runs a weekly job at no cost. Any runtime with a scheduler and fetch works identically.

Store every run and diff consecutive ones. Engine answers vary between runs with no content change, so a citation flipping off for a single run is usually flicker. Require it to stay dropped across two or three runs on a query that matters before you alert, and always exclude failed records - a missing answer is not a lost citation.

One credit per delivered record. Three queries across five surfaces is fifteen credits a week at most; failed records cost nothing and a timed-out scrape returns a providerFields.snapshot_id you can redeem without paying again. Cloudflare's free tier covers a weekly cron and the KV writes. Build against a zero-credit ag_test_ key before spending anything.

In a Worker secret set with npx wrangler secret put AGENTGEO_KEY, read as env.AGENTGEO_KEY at runtime - never in wrangler.jsonc or the source. A key checked into a repo is compromised and has to be rotated. The same rule holds on any platform: use the environment's secret store, not the code.

Keep reading

Where this page leads next.

Your brand, next

Run these checks on your own brand

Send a URL for a free human-run audit, or connect your agent over MCP and run the same records yourself.