Skip to content
Tutorial7 min read2026-10-07

Push a Slack alert when AI mentions your brand

To get a Slack message when an AI engine starts - or stops - mentioning your brand, you need three parts: a scheduled fetch across the six surfaces, a diff against the last run that isolates what actually changed, and a Slack Incoming Webhook you POST the change to. The fetch is one POST https://api.agentgeo.org/v1/fetches per query; the alert is one POST to your webhook URL. The only real design decision is what you let through - because an alert that fires on every run-to-run flicker gets muted within a week. The rule that makes this useful: alert on change, not on state. "Acme is mentioned on Perplexity" every Monday is noise. "Acme is now named on ChatGPT for 'best invoicing software' - it wasn't last run" is a message someone reads. This guide builds a Cloudflare Worker that stores each run, diffs mention and citation status per query and engine, and only pings Slack when something crosses a threshold.

Tutorial

Read this page with an AI

The worker fans each tracked query across the surfaces, records two booleans per (query, surface) - is your brand named in answerText (a mention) and is your domain in sources[] (a citation) - diffs those against the previous run, and posts only the transitions to Slack. State is stored per run so the diff always has something to compare against. Everything hinges on alerting when a boolean flips, never on its steady value.

The Slack webhook

Create an Incoming Webhook in Slack (Apps → Incoming Webhooks → add to a channel) and you get a URL that accepts a JSON body and posts it as a message. That's the entire Slack side - no bot token, no OAuth. Store the URL as a Worker secret; it's a credential.

test the webhook
curl -X POST "$SLACK_WEBHOOK_URL" \
  -H "Content-Type: application/json" \
  -d '{"text": "AI visibility bot: wired up and listening."}'

The worker

One scheduled handler does the loop: fetch, evaluate both signals, load the previous run from KV, diff, and post any transitions to Slack before saving the new run. Keep the API key and the webhook URL as secrets (wrangler secret put), and bind a KV namespace for run history.

src/worker.js
const API = "https://api.agentgeo.org/v1/fetches";
const SURFACES = ["chatgpt", "perplexity", "google_ai_overview", "copilot", "gemini"];

const BRAND = "Acme";
const ALIASES = ["Acme", "Acme PM", "AcmeHQ"];
const BRAND_DOMAIN = "acme.com";
const QUERIES = [
  "best project management software",
  "asana alternatives",
];

// Word-boundary alias match - avoids "acmestudio" and other substring hits.
const MENTION_RE = new RegExp(
  "\\b(?:" + ALIASES.map((a) => a.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|") + ")\\b",
  "i",
);

function host(url) {
  try {
    const h = new URL(url).hostname.toLowerCase();
    return h.startsWith("www.") ? h.slice(4) : h;
  } catch {
    return "";
  }
}

async function evaluate(env) {
  const state = {};
  for (const query of QUERIES) {
    const resp = await fetch(API, {
      method: "POST",
      headers: { "Authorization": "Bearer " + env.AGENTGEO_KEY, "Content-Type": "application/json" },
      body: JSON.stringify({ query, surfaces: SURFACES }),
    });
    if (!resp.ok) throw new Error("fetches " + resp.status);
    const run = await resp.json();
    for (const a of run.answers) {
      if (a.status === "failed") continue;               // missing data, not a status
      const hosts = (a.sources || []).map((s) => host(s.url));
      state[query + "|" + a.surfaceKey] = {
        mentioned: MENTION_RE.test(a.answerText || ""),
        cited: hosts.some((h) => h === BRAND_DOMAIN || h.endsWith("." + BRAND_DOMAIN)),
      };
    }
  }
  return state;
}

// Emit a line only when a boolean flips - change, never steady state.
function diff(prev, curr) {
  const lines = [];
  for (const k of Object.keys(curr)) {
    const [query, surface] = k.split("|");
    const was = prev[k] || {};
    const now = curr[k];
    if (was.mentioned !== now.mentioned) {
      lines.push((now.mentioned ? ":green_circle: now MENTIONED" : ":red_circle: no longer mentioned") +
        " - " + BRAND + " on " + surface + " for \"" + query + "\"");
    }
    if (was.cited !== now.cited) {
      lines.push((now.cited ? ":green_circle: now CITED" : ":red_circle: citation dropped") +
        " - " + BRAND + " on " + surface + " for \"" + query + "\"");
    }
  }
  return lines;
}

async function notify(env, lines) {
  if (!lines.length) return;                             // silence is the default
  await fetch(env.SLACK_WEBHOOK_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text: "*AI visibility change*\n" + lines.join("\n") }),
  });
}

async function run(env) {
  const curr = await evaluate(env);
  const prevRaw = await env.STATE.get("last");
  const prev = prevRaw ? JSON.parse(prevRaw) : {};
  await notify(env, diff(prev, curr));
  await env.STATE.put("last", JSON.stringify(curr));     // save AFTER diffing
}

export default {
  async scheduled(event, env, ctx) {
    ctx.waitUntil(run(env));
  },
  async fetch(request, env) {
    await run(env);
    return new Response("ok");
  },
};

Deploy with npx wrangler deploy, set both secrets (wrangler secret put AGENTGEO_KEY and wrangler secret put SLACK_WEBHOOK_URL), and give it a KV binding named STATE. On the first run everything is "new" against an empty baseline, so seed it with one manual run before you trust the diffs - or accept one noisy first message. After that, the channel stays quiet until a mention or citation actually flips.

Point it at a zero-credit ag_test_ key while you tune the queries and threshold - the diff logic runs identically. Prefer we run the check for you? Get a free AI-visibility audit → - no card, no account.

Get my free audit

Keeping the channel worth reading

The single reason mention alerts get muted is false positives, and the cause is almost always alerting on a one-run flicker. Engine answers disagree with themselves between runs with no content change, so a naive diff will ping "now mentioned" and "no longer mentioned" for the same query on alternating Mondays. The fix is to require a change to hold before it fires.

  • Confirm across runs. Store the last N runs, not just one, and only alert when the new value differs from the last two - a flip that survives one extra run is signal, a flip that reverts is noise.
  • Alert on drops louder than gains. A citation you lost on a money query is more urgent than a mention you gained on a long-tail one. Route drops to a channel people watch; batch gains into a weekly digest.
  • Scope the queries tightly. A worker watching your five highest-intent queries sends messages worth reading. One watching fifty sends a wall nobody scans. Start narrow and add queries only when the channel stays calm.
  • Debounce Google AI Overview. It's the surface most likely to return failed - absent, not negative. The worker already skips failed records so an absent Overview never reads as a lost mention; keep that skip, or the channel fills with phantom drops.

Send both signals, labelled. "Now mentioned" and "citation dropped" are different events with different owners - one is a content-and-PR win, the other a page the engine stopped reading. Collapse them into a single "visibility changed" ping and whoever gets it can't tell which lever to pull. The worker emits a separate line per signal for exactly this reason.

What goes wrong

  • Saving state before diffing. Write the new run to KV after posting the diff, or you compare the run against itself and Slack goes permanently silent. The worker saves last, on purpose.
  • A failed engine faking a drop. Any surface can return status: "failed" at HTTP 200. Counting that as "not mentioned" fires a false drop alert every time an engine simply didn't answer. Skip failed records - a timed-out scrape also returns a providerFields.snapshot_id you can redeem later without paying again.
  • Substring mention matching. A bare answerText.includes("acme") fires on "acmestudio" and on rivals whose names contain yours. Match on a word boundary over an escaped alias list, as the worker does.
  • Secrets in source. The webhook URL and the API key are both credentials - set them with wrangler secret put, never in wrangler.jsonc or the code. A webhook URL in a public repo lets anyone post to your channel.
  • Alerting on steady state. If the message fires whenever your brand is mentioned rather than when the status changes, every run pings and the channel dies. Diff, then alert only on transitions.

Summary

A Slack alert when AI mentions your brand is a scheduled fetch across the six surfaces, a per-(query, surface) diff of two booleans - mentioned in answerText, cited in sources[] - and a webhook POST that fires only on transitions. Store state after diffing, skip failed engines, confirm a flip across runs before firing, and label the two signals so the right person acts. Keep the query set tight and the channel stays worth reading. The scheduled citation-tracking worker shares the storage pattern; tracking your brand across every engine covers the mention/citation split in depth.

FAQ

Direct answers to the questions this page raises.

Run a scheduled worker that POSTs your queries to https://api.agentgeo.org/v1/fetches, checks each answer for your brand name in answerText and your domain in sources[], diffs those against the previous run, and POSTs any change to a Slack Incoming Webhook. Alert on transitions only - when a mention or citation flips - not on steady state, or the channel gets muted.

No. An Incoming Webhook is enough: create one in Slack, point it at a channel, and you get a URL that turns a JSON body into a message. The worker POSTs { "text": "..." } to that URL. Store the URL as a secret - it's a credential, and anyone who has it can post to your channel.

Alert on change, not state, and confirm the change across runs. Store the last few runs and only fire when the new value differs from the last two, so a one-run flicker doesn't ping. Keep the query set to your highest-intent queries, route drops louder than gains, and skip failed engines so an absent Google AI Overview never reads as a lost mention.

Both, as separate lines. A mention is your brand named in answerText; a citation is your domain in sources[]. They flip for different reasons and belong to different owners - a mention gain is a content-and-PR win, a citation drop is a page the engine stopped reading. Label each so the reader knows which lever to pull; don't collapse them into one ping.

Yes - the pattern is transport-agnostic. Swap the webhook POST for a Discord webhook, a Microsoft Teams connector, an email API, or a PagerDuty event; the fetch, the two-signal diff, and the store-after-diff logic are unchanged. Only the final POST body differs per destination.

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.