AnswerLine Start free

, Agencies · GEO · Analytics

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

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:

  1. Mention rate
  2. Citation share
  3. 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:

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:

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.

  1. Load runs for the period, keyed by prompt ID, engine and market.
  2. Detect brand mentions by matching each brand’s alias list in result.text, case-insensitively with word boundaries.
  3. Record first-match position per brand to derive answer rank.
  4. Extract cited domains from result.sources[] URLs.
  5. Aggregate per engine, market and week.
  6. 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:

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:

  1. 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.idempotencyKey in every webhook body.
  2. 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.
  3. 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:

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

Pitfalls when building the pipeline

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.

Try it on your own prompts

100 free requests on any engine, no card. One POST returns the answer, sources and citations as JSON.

Keep reading