An AI visibility report clients and executives will read
An AI visibility report that clients and executives will read fits on three pages: headline metrics with a time axis, what changed since last month, and an action list where every item has a verb. Every number on it should be computable from stored API responses, so anyone who questions a figure can be shown the underlying answers.
Definitions used throughout
- Monitored prompt: a fixed question, run on a schedule per engine and market, with a stable ID so its history is comparable.
- Mention rate: the share of monitored prompt runs whose answer names the brand at least once.
- Citation share: the brand’s cited URLs divided by all cited URLs across those runs.
- Answer rank: on prompts that name at least one tracked brand, the brand’s position when tracked brands are ordered by where they first appear in the answer text. Rank 1 means named first.
- Transition: a prompt whose state changed between two reporting periods (brand entered, brand left, citation gained, citation lost).
Answer rank is defined on the text on purpose. Only ChatGPT returns entities[]; Gemini has no such field, so a rank defined as “position in entities[]” would be blank there. First appearance in text works everywhere and matches what a reader of the answer sees.
Page 1: the three lines
Three small line charts, one per metric, one line per engine, weekly points across at least the last quarter:
- Mention rate
- Citation share
- Answer rank (lower is better; say so on the axis)
Split by engine rather than blending. Engines disagree, and the disagreement is often the finding. “Gemini cites our pricing page; ChatGPT cites a two-year-old review instead” points at a specific page to fix, and a blended score would hide it. If the client operates in several markets, give each market its own page 1 rather than averaging.
Put one sentence under each chart saying what moved and by how much. That sentence is what most executives will read.
Page 2: what changed
Stable numbers get skipped; transitions get read. List only:
- Entered or left: prompts where the brand appeared in the answer this period and did not last period, and the reverse. Show the prompt text and the engine.
- Citations won or lost: URLs of the client’s that started or stopped being cited, and on lost citations, which competitor URL took the place.
- New sources: pages cited this period that were not cited before for that prompt. A newly cited competitor page is a candidate content target. The searches behind the answer are not available on these engines today (table below), so cited pages are the closest view of what retrieval found.
| Engine | Field with the searches behind the answer |
|---|---|
| ChatGPT | searchQueries[], free with include.searchQueries; empty on the answers served today |
| Gemini | none |
Arrays can be empty or absent, so treat a missing field as “no data”, not “no searches”.
Page 3: the action list
Each finding becomes one line with a verb, an owner and a target:
- “Publish a page on restaurant tip pooling in payroll; ChatGPT cites a competitor’s guide on it for three prompts.”
- “Update the pricing page cited by Gemini; it still lists the old tiers.”
- “The forum thread cited on four prompts recommends a competitor; respond with a disclosed answer.”
Next month’s page 2 checks whether those actions moved anything.
Computing the metrics from stored results
The steps below assume each finished task is stored as the JSON your webhook received (or GET /v1/async/task/{taskId} returned). The engine result of a completed task is at response.result.
- Load runs for the period, keyed by prompt ID, engine and market.
- Detect brand mentions by matching each brand’s alias list in
result.text, case-insensitively with word boundaries. - Record first-match position per brand to derive answer rank.
- Extract cited domains from
result.sources[]URLs. - Aggregate per engine, market and week.
- Diff against the previous period to produce transitions.
import re
from collections import defaultdict
from urllib.parse import urlparse
BRANDS = {
"client": {"aliases": ["Acme Payroll", "Acme"], "domains": ["acmepayroll.com"]},
"rival_a": {"aliases": ["Brightpay"], "domains": ["brightpay.example"]},
"rival_b": {"aliases": ["PayNest"], "domains": ["paynest.example"]},
}
def first_match(text, aliases):
hits = [m.start() for a in aliases for m in re.finditer(rf"\b{re.escape(a)}\b", text, re.IGNORECASE)]
return min(hits) if hits else None
def domain(url):
host = urlparse(url).hostname or ""
return host[4:] if host.startswith("www.") else host
def analyze(stored_task):
task = stored_task["task"]
if task["status"] != "COMPLETED":
return None
result = stored_task["response"]["result"]
text = result.get("text") or ""
positions = {b: first_match(text, cfg["aliases"]) for b, cfg in BRANDS.items()}
named = sorted((p, b) for b, p in positions.items() if p is not None)
rank = next((i + 1 for i, (_, b) in enumerate(named) if b == "client"), None)
cited = [domain(s.get("url", "")) for s in result.get("sources", [])]
client_cited = sum(1 for d in cited if any(d == x or d.endswith("." + x) for x in BRANDS["client"]["domains"]))
return {
"engine": task["taskType"],
"mentioned": positions["client"] is not None,
"rank": rank,
"citations_total": len(cited),
"citations_client": client_cited,
}
def weekly_metrics(rows):
agg = defaultdict(lambda: {"runs": 0, "mentions": 0, "ranks": [], "cit": 0, "cit_client": 0})
for r in filter(None, rows):
a = agg[r["engine"]]
a["runs"] += 1
a["mentions"] += r["mentioned"]
a["cit"] += r["citations_total"]
a["cit_client"] += r["citations_client"]
if r["rank"]:
a["ranks"].append(r["rank"])
return {
engine: {
"mention_rate": a["mentions"] / a["runs"],
"citation_share": a["cit_client"] / a["cit"] if a["cit"] else None,
"avg_rank": sum(a["ranks"]) / len(a["ranks"]) if a["ranks"] else None,
}
for engine, a in agg.items()
}
Notes on the code:
sources[]is used for citation share on every engine because every chat-style engine returns it. If you also usecitationPills[], remembercitationPillIdis 0-based on ChatGPT and 1-based on Gemini.- Failed tasks are skipped, not counted as “not mentioned”. Failed tasks are also not charged, so re-run them rather than leaving holes.
- Average rank is only over runs where the client was named. Report it next to mention rate, never alone: a brand named first once and absent 19 times has a perfect average rank.
Collecting the data behind the report
A TypeScript job using @answerline/sdk can submit the week’s prompts as one batch per 500 tasks:
import { Client } from "@answerline/sdk";
const client = new Client({ apiKey: process.env.API_KEY!, baseUrl: "https://api.answerline.dev" });
const prompts = [
{ id: "p001", text: "best payroll software for restaurants" },
{ id: "p002", text: "how do restaurants handle tip pooling in payroll" },
];
const engines = ["CHATGPT", "GEMINI"] as const;
const runDate = "2026-09-21";
const tasks = prompts.flatMap((p) =>
engines.map((engine) => ({
taskType: engine,
idempotencyKey: `acme:${p.id}:${engine.toLowerCase()}:US:${runDate}`,
webhook: { url: "https://reports.example.com/hooks/answers" },
payload: { prompt: p.text, country: "US" },
})),
);
for (let i = 0; i < tasks.length; i += 500) {
await client.createBatch(tasks.slice(i, i + 500));
}
Three conventions make the report reproducible:
- The idempotency key doubles as the storage key. It encodes client, prompt ID, engine, market and run date, the dimensions the report aggregates on. The key comes back on
task.idempotencyKeyin every webhook body. - The run date is the scheduled date, not the completion time. A task that waits in the queue still belongs to the week it was scheduled for.
- Failures are re-run under the same run date. A failed task is uncharged and keeps its key for 24 hours, so give a re-run inside that window a suffix (for example
:r1) and map it back to the same week.
An appendix for the client who asks
For readers who want to check the work, add a short appendix:
- The full prompt list with IDs, the engines and markets, and the cadence.
- The alias list used for mention matching, and the domains counted for citation share.
- The number of completed runs per engine per week, so a thin week is visible.
- For two or three headline findings, the answer text quoted with engine, market and date.
This is also where engine-specific extras belong, labeled as such: ChatGPT’s products (inlineProducts[] with include.shopping, with price, link and merchant offers; entities[] with type product; its shoppingCards[] come back empty today) for commerce clients, or Gemini places[] for local businesses.
A worked example (illustrative numbers)
Suppose one week has 40 prompts on Gemini. The client is named in 14 answers, so mention rate is 14 / 40 = 35%. Those 40 answers cite 212 URLs, 9 of them on the client’s domain, so citation share is 9 / 212 = 4.2%. On the 14 answers naming the client, its first-appearance ranks sum to 25, so average answer rank is 25 / 14 = 1.8. These figures are invented to show the arithmetic, not typical values.
The page 1 sentence for that engine would read: “Gemini: named in 35% of prompts (last month 28%), usually second after Brightpay; 4.2% of citations.”
What it costs to produce
Collection cost is a function of the prompt set, not the report. At the published prices (ChatGPT $0.0006, Gemini $0.0006 per task at the Starter tier), 40 prompts weekly (4 runs) on those two engines is 40 x 4 x $0.0012 = $0.192 a month. See the pricing docs and cost planning.
What to leave out
- Single-answer screenshots as evidence of a trend. One run is one sample. Use quotes under a metric, with the date and engine, and keep the JSON.
- Blended cross-engine scores that hide which engine moved.
- Anything without a time axis. A 35% mention rate means nothing without last month.
- Metrics that exist on one engine only, presented as universal. Product-card counts exist only where an engine returns product cards; label them as such or leave them out.
- Unreviewed alias matches. Spot-check a sample of matched answers each month, especially for short or generic brand names.
Pitfalls when building the pipeline
- Changing prompt wording without a new prompt ID breaks trend lines.
- Mixing markets in one series averages away differences. Store
countrywith every result (andstateon Gemini). - Counting duplicate webhook deliveries twice. Deliveries can repeat and arrive out of order; upsert by
task.id. - Too few runs per point. Answers vary between runs; the fewer prompts per weekly point, the noisier the line. Sampling vs census covers sizing.
For the metric definitions in more depth see the share-of-voice framework and mentions vs citations; for packaging the report as a service see AI answer tracking for agencies.
Collect the underlying data with the quickstart.
Questions
What metrics belong in a monthly AI visibility report?
Three headline metrics cover most needs: mention rate (share of prompts whose answer names the brand), citation share (share of cited URLs pointing to the brand's domains) and answer rank (where the brand first appears relative to competitors). Each should be shown per engine and per market over time.
How do I measure answer rank if an engine does not return a list of brands?
Order the tracked brands by the character position of their first match in the answer text, and take the client's place in that order. This works the same way on every engine, ChatGPT included.
Does every engine return entities?
No. Only ChatGPT returns an entities array, the brands and products an answer names as type and name pairs. On Gemini and the other engines, detect brands by matching names and aliases in the text or markdown field.
Why not include screenshots of individual AI answers?
A single answer can differ on the next run, so a screenshot shows one sample rather than a rate. Quote answers as supporting evidence under a metric, and keep the stored JSON so any quote can be traced.
Where do the search queries behind an answer come from?
ChatGPT has a searchQueries field, free with include.searchQueries, but it comes back empty on the answers served today. Gemini responses have no search queries field. The cited sources show what retrieval found.