Build your own
AI visibility tracker

See which AI assistants cite your site, for which questions, and what share of them you win

What this is. The full build, in seven stages, with the prompts. Stage 1 gets you real numbers in about half an hour with nothing installed. Stages 2 to 7 turn it into something that runs itself and tells you what changed.

Why it matters. Your content performance is split across four places that do not talk to each other: AI citations in Bing, classic search in Google, classic search in Bing, and the visits that actually reach your site. Each answers a different question and none of them answers the one that matters, which is what to build next. Pull all four together and you can finally see which content type earns citations, which topics you already hold share on, and which pages return nothing at all.

Be realistic. The finished thing runs to a few thousand lines. You are not typing that — the prompts generate it. Stage 1 is genuinely easy. Stage 6 is fiddly and will need occasional attention forever.

Why this build does not use a standard pixel. There is no Google Analytics tag and no Microsoft Clarity anywhere in this. That is deliberate, and on Medicare or ACA pages it is not optional.

Clarity records sessions. It captures what a visitor sees and types, which on a quoting flow means date of birth, medications and conditions. Microsoft does not offer a BAA for Clarity. Field masking reduces the exposure but session replay is structurally the wrong tool on health pages.

GA4 reports to Google. Every hit sends the page URL, the referrer and a persistent client identifier. Google will not sign a BAA covering GA4. When the URL or the event parameters carry health context, that combination is the exact fact pattern behind the hospital pixel litigation.

So stage 4 is a first-party beacon. Your endpoint, your storage, nothing leaving your infrastructure by default. If you still want GA4 numbers, you forward them server-side with the health context stripped and a pseudonymous id, so Google receives counts rather than people.

Scope it correctly. This applies to Medicare, ACA and health. On life, annuities and P&C a client-side GA4 tag is fine. Agencies over-apply this and end up blind on business where they never needed to be. None of this is legal advice; run your own setup past your counsel.

Four feeds, one decision surface Bing AI Performance citations, grounding queries, share interface only, no API Google Search Console clicks, impressions, position API Bing Webmaster API classic search, index health API First-party beacon real visits and true referrer yours JSONL store append-only one line per site per day Reconciler compares the feeds against each other and emits flags The decisions Which content type earns citations Which topics you already hold share on Which pages return nothing at all No single feed answers the question. The reconciler is the stage that turns four dashboards into one answer.
Four collectors, one store, one reconciler. Each stage below adds one of these boxes.
Stage 1 30 minutesEasy

See your AI citations today

What you get: Your real citation counts and the exact questions behind them. No server, no code deployed, nothing to maintain.

  1. Verify your site in Bing Webmaster Tools if it isn't already. Importing from Google Search Console is the fastest route and takes about two minutes.
  2. Wait for data. A newly verified site shows nothing for a few days — that is normal, not a failure.
  3. Open bing.com/webmasters, sign in, then open the console (F12) and paste the snippet below.
  4. It asks which domains to check, then prints citations, peak day and your top grounding queries with citation share.
Paste into the browser console while signed in to Bing Webmaster Tools
(async () => {
  const t = await (await fetch('/webmasters/auth/token', {credentials:'include'})).text();
  const H = {'Content-Type':'application/json','X-CSRF-Token':t};
  const list = (prompt('Domains to check, comma separated', 'yoursite.com') || '').split(',')
                 .map(s => s.trim()).filter(Boolean);
  const out = [];
  for (const d of list) {
    const body = JSON.stringify({ siteUrl: 'https://' + d + '/' });
    const r = await fetch('/webmasters/api/aiperformance/citationstats',
                          {method:'POST', headers:H, credentials:'include', body});
    if (!r.ok) { out.push(d + ': HTTP ' + r.status + ' (not verified on this account?)'); continue; }
    const stats = (await r.json()).CitationStats || [];
    const total = stats.reduce((a,x) => a + x.Citations, 0);
    const peak  = stats.length ? Math.max(...stats.map(x => x.Citations)) : 0;
    const q = await fetch('/webmasters/api/aiperformance/searchqueries/stats',
                          {method:'POST', headers:H, credentials:'include', body});
    const queries = q.ok ? ((await q.json()).Queries || []) : [];
    out.push(d + ': ' + total + ' citations over ' + stats.length + ' days, peak ' + peak +
             ', ' + queries.length + ' grounding queries');
    queries.slice(0,5).forEach(x => out.push('    "' + x.GroundingQuery + '"  ' +
             x.Citations + ' cites, ' + Math.round((x.CitationRate||0)*1000)/10 + '% share'));
  }
  console.log(out.join('\n'));
  alert(out.join('\n'));
})();
Check it worked: You get a list like yoursite.com: 412 citations over 28 days, peak 57, 8 grounding queries. If you get HTTP 403, that domain is not verified on the account you are signed into.
Where this bites: There is no API for this. Microsoft exposes AI Performance in the browser only, so anything automated has to run against a signed-in session. That constraint drives every later stage.
Stage 2 About an hourModerate

Pull Google Search Console automatically

What you get: Clicks, impressions, position and your query list for every property, on a schedule, without opening a browser.

  1. In Google Cloud, create a project and a service account. No key rotation headaches, no OAuth dance.
  2. Download its JSON key. Enable the Search Console API on that project.
  3. In Search Console, add the service account's email as a Full user on each property.
  4. Use the prompt below to generate the pull script.
Paste this into Claude Code
Write a Node.js script that pulls Google Search Console data using a service-account JSON key, with no external dependencies (no googleapis package — sign the JWT with node:crypto).

Requirements:
- Read the service account key from a path in an env var, default ./service-account.json
- Mint a JWT and exchange it for an access token against https://oauth2.googleapis.com/token, scope https://www.googleapis.com/auth/webmasters
- List every property the service account can see (GET /webmasters/v3/sites)
- For each property, query searchAnalytics for the last 90 days with dimensions ['query'] and ['page'], rowLimit 25000
- For each property write one JSON line to gsc-daily.jsonl with: date, site, clicks, impressions, position, queryCount, pageCount, topQueries (top 10), topPages (top 10)
- Also flag "opportunities": queries with 10+ impressions ranking worse than position 10
- Handle a property returning no data without crashing the whole run
- Log one summary line per property

Explain how to schedule it with cron, and what to do if a property returns 403.
Check it worked: Run it. You should get one JSON line per property in gsc-daily.jsonl, and a summary line per site in the console. A 403 means the service account was not added to that property.
Where this bites: A Search Console property is either a Domain property or a URL-prefix one, and they are not interchangeable. If your site serves www but you added the bare domain, you will collect nothing and see no error. Check which one your site actually redirects to.
Stage 3 30 minutesEasy

Pull Bing search data too

What you get: Impressions, clicks and query lists from Bing. Unlike the AI report, this one has a real API and takes minutes.

  1. In Bing Webmaster Tools open Settings → API Access → API Key and generate one.
  2. That key covers every site verified on the account — no per-site setup.
  3. Use the prompt below.
Paste this into Claude Code
Write a Node script that pulls Bing Webmaster Tools data for a list of sites and appends one JSON line per site per day to bing-daily.jsonl.

Details:
- Endpoint pattern: https://ssl.bing.com/webmaster/api.svc/json/<Method>?siteUrl=<urlencoded>&apikey=<KEY>
- Use GetRankAndTrafficStats, GetQueryStats and GetPageStats
- Responses are wrapped in {"d": ...} — unwrap it
- Dates come back as Microsoft /Date(ms-offset)/ — parse to YYYY-MM-DD
- Read the site list from the same brands.json the rest of the project uses. Do NOT hardcode domains.
- Per site record: date, site, last 7 days of impressions/clicks, 7-day totals, queryCount, count of conversational queries (7+ words), topQueries, topPages
- If a site returns nothing, log it and continue

Note: Microsoft is retiring the SOAP and POX APIs on 2026-08-31. The /json/ endpoint above is the REST one and is NOT affected — do not migrate away from it.
Check it worked: One JSON line per site in bing-daily.jsonl. Sites with no Bing traffic return zeros rather than errors.
Where this bites: Do not panic about the retirement banner in Bing Webmaster Tools. It applies to api.svc/pox/ and api.svc/soap/ only. The api.svc/json/ endpoint is the replacement and keeps working. I nearly rewrote a working integration over that banner.
Stage 4 An hourEasy

Track the visits AI actually sends you

What you get: A first-party record of every visit, classified by source, including the AI referrals that Analytics files as 'direct'.

  1. This is a tiny endpoint on your own domain plus a few lines of script on the site.
  2. Because it is first-party, it is not blocked the way third-party tags are, and you own the data.
  3. Use the prompt below, then add the snippet it generates to your site template.
Paste this into Claude Code
Build a minimal first-party visit beacon: a Node endpoint plus the browser snippet that feeds it.

Server (POST /hit):
- Accept a small JSON body: {s: site id, p: path, r: referrer HOSTNAME only, plus optional utm fields}
- Reject unknown site ids and anything oversized
- Classify each visit into one source: ai, search, social, paid, email, referral, direct
  - ai = chatgpt.com, chat.openai.com, perplexity.ai, copilot.microsoft.com, gemini.google.com, claude.ai, you.com, duckduckgo.com, search.brave.com
  - paid wins if utm_medium matches cpc/ppc/paid
- Append one JSON line to hits.jsonl. Respond 204 with no body.
- CORS: accept a plain text/plain body so the browser request stays preflight-free.

Browser snippet:
- Fire on EVERY pageview, not only AI referrals
- Send the referrer HOSTNAME only, never the full URL
- Skip our own hostname so internal clicks are not logged as referrals
- Include any utm_* params from the landing URL
- Use navigator.sendBeacon so it never delays the page
- Cookieless. No identifiers of any kind.

Explain how to gate it behind a consent banner for sites that need it.
Check it worked: Load a page on your site, then check hits.jsonl for a new line with the right src. Arrive via a Google link and it should say search.
Where this bites: Write the browser snippet to fire on every visit. My first version only fired when the referrer was one of ten AI hostnames, which silently discarded every direct, organic and social visit — the Traffic tab sat nearly empty for weeks and I assumed we had no traffic. It had been thrown away in the browser before it was ever sent.
Stage 5 Two to three hoursHarder

Put it on one dashboard

What you get: A single page showing AI citations, Google and Bing performance side by side, per brand, with the grounding queries underneath.

  1. Decide where it lives. A small VPS is fine; so is anything that can run Node and serve a page.
  2. Store each feed as newline-delimited JSON. Boring, greppable, survives a bad deploy.
  3. Generate the API and the page with the prompt below.
  4. Put it behind a token, one per brand, so you can hand a client their own view without exposing anyone else's.
Paste this into Claude Code
Build a small Node HTTP server (no framework) that serves a read-only analytics dashboard.

Data sources, all newline-delimited JSON files on disk:
- gsc-daily.jsonl — Google Search Console daily pulls
- bing-daily.jsonl — Bing Webmaster daily pulls
- bing-ai.json — AI citation counts + grounding queries per brand
- hits.jsonl — first-party visit beacon

Requirements:
- A brands.json config maps a short id to {name, domain, color}. Everything else derives from it — never hardcode a brand list anywhere else.
- GET /dash/<brand>?key=TOKEN renders an HTML dashboard for ONE brand. A token maps to the brands it may see; a master token sees all.
- Tabs: Overview, Search performance, AI citations, Traffic sources, Content plan.
- IMPORTANT: scope every per-brand number to that brand only. Do not let one brand's cited sources or panel questions appear on another brand's page.
- Show "—" not "0" when a metric was never captured, so a gap is visually distinct from a measured zero.
- Show a STALE badge if a data file is more than 3 days old.
- No build step, no client framework. Inline the CSS. Charts as inline SVG.

Explain how to add a new brand.
Check it worked: Load /dash/<brand>?key=…. Every number on the page should trace to one of your JSONL files, and a second brand's token should show only that brand.
Where this bites: The bug that will bite you: computing a metric across all brands and rendering it on a single brand's page. I shipped that and it made one site look like it was winning citations that belonged to another. Scope every aggregate by brand id, then check a low-traffic brand to confirm it isn't inheriting a busy one's numbers.
Stage 6 An hour, plus upkeepFiddly

Automate the AI capture

What you get: The Bing AI numbers refresh daily without you opening a browser.

  1. Because there is no API, this drives a real browser that keeps its own signed-in profile.
  2. Use the prompt below to build it, then sign in once. The profile persists.
  3. Schedule it. Expect to sign in again periodically — sessions expire.
Paste this into Claude Code
Write a Node script using Playwright that captures Bing Webmaster Tools AI Performance data headlessly and posts it to my collector.

Constraints:
- Bing has NO API for AI Performance. It is backed by two internal endpoints that authenticate off the signed-in session:
    GET  /webmasters/auth/token                            -> anti-forgery token (plain text)
    POST /webmasters/api/aiperformance/citationstats        -> {CitationStats:[{Date,Citations,UniqueCitedPages}]}
    POST /webmasters/api/aiperformance/searchqueries/stats  -> {Queries:[{GroundingQuery,Intent,Topics,Citations,CitationRate}]}
  Both take {siteUrl} and require header X-CSRF-Token. They IGNORE date-range params and return the full history.
- Use chromium.launchPersistentContext so the login survives between runs.
- Support a --login flag that opens a visible browser, waits for sign-in, and auto-detects success by polling a real data call (do NOT just wait for the window to close).
- CRITICAL: a signed-OUT session still receives a valid-looking token. Detect signed-out by checking whether the DATA calls return 401/403 for every site, not by checking the token.
- Exit codes: 0 ok, 2 signed out (tell the user to re-run --login), 3 failed. Write logs/last-status.json every run.
- A 404 from searchqueries means "no data yet" for that site, not an error.

Then explain how to schedule it daily on Windows Task Scheduler and on cron.
Check it worked: Run it headless. Exit 0 with a summary line means it worked. Exit 2 means the session lapsed — sign in again.
Where this bites: This is the piece that breaks. Sessions expire and the job goes quiet. Two defences: make signed-out a distinct exit code so your scheduler surfaces it, and put a STALE badge on the dashboard so frozen data cannot pass for current. A capture that fails silently while the dashboard still looks current is the worst failure mode in this build, because nothing alerts and the numbers simply stop moving.
Stage 7 An hourThe one that pays

Reconcile the feeds into one verdict

What you get: The four feeds stop being four dashboards and become one answer. This is the stage most people skip, and it is the reason to build the rest.

  1. Four feeds sitting side by side are not an answer until something compares them and tells you what changed.
  2. The reconciler reads what the collectors wrote and emits flags, not just numbers.
  3. It must run after the collectors it reads, or it compares today against yesterday and flags things that are not real.
  4. Group its output by URL pattern. That is what turns it into a content plan rather than a status page.
Paste this into Claude Code
Write a Node script called reconcile.mjs that reads the JSONL feeds my collectors write (Bing AI citations, Google Search Console, Bing classic search, and first-party visits) and produces one verdict per site per day.

For each site it must compare the feeds against each other and emit flags, not just numbers:
- pages indexed with zero impressions over 14 days (the soft-404 signature: unknown paths returning 200 with the homepage)
- pages strong in Bing AI citations but absent from Google's query list
- pages ranking in Google with zero citations in Bing AI
- a citation count that dropped more than 40% week over week with no corresponding ranking drop
- any feed that has not written a line in 48 hours (a dead collector, not a real zero)

Group results by URL pattern so I can see which content type is performing: derive the pattern from the first path segment.

Severity: critical, warning, info. Write results to reconcile-YYYY-MM-DD.json and print a summary table.

Read the site list from brands.json. Never hardcode it.

Important: scope every aggregate by site. A metric computed across all sites but attributed to one is the bug I most want to avoid here.
Check it worked: Run it against a week of collected data. You should get a handful of flags, and at least one should be something you did not already know. On its first pass over our portfolio it raised six critical flags, two of which were exact opposite failures: one set of pages strong in Bing and invisible in Google, another healthy in Google and dead in Bing.
Where this bites: Ordering. If the reconciler runs before a collector finishes, every comparison is off by a day and the flags are noise. Put it last in the schedule and have it refuse to run against a feed whose newest line is older than the others.

If you get stuck, that is expected — particularly at stage 5, where the brand-scoping bug is easy to introduce and hard to spot, and stage 6, where sessions expire quietly. Bring it to the group and we will work through it.