AnswerLine Start free

, GEO · Brand Monitoring

AI share of voice: a measurement framework

AI share of voice is the portion of AI answers, citations and recommendations in a defined prompt set that belong to your brand, measured per engine and market over repeated runs. A single “AI visibility” score hides the parts you can act on. A small set of rates computed from structured answer data keeps them separate: whether you are named, whether your pages are the evidence, whether you are in the recommendation list, where you appear in it, and whether you rank for the searches the assistant runs to find sources.

The unit of observation

Every metric is built from runs. A run is one answer for one cell: a prompt, a market and an engine. From each run you extract observations:

Observation Question Where it comes from
Mentioned Does the answer name the brand? result.text on all engines
Cited Is one of your URLs a source? At which position? result.sources[] {position, url}
Recommended Is the brand in the recommendation or product set? Shopping and product fields per engine; brand names in text
Answer rank Where in the list of named brands? First-mention order in text
Fan-out queries Which searches did the assistant run? searchQueries[] (ChatGPT’s is empty on the answers served today)

The mentions vs. citations post explains why “named” and “cited” diverge and need separate tracking.

Field names differ per engine

Code that assumes one engine’s shape breaks on the others. Optional arrays are omitted when empty, so always read them with a default.

Engine Entities Product and shopping fields Fan-out queries
ChatGPT entities[] {type, name} entities[] with type product; inlineProducts[].product.title with include.shopping (shoppingCards[] comes back empty today) searchQueries[], free with include.searchQueries, empty on the answers served today
Perplexity none none none
Copilot none coming coming
Grok none none searchQueries[]
Gemini none none (places[] for local) none
Google AI Mode none shoppingCards[].title, inlineProducts[].title none

Perplexity, Gemini and AI Mode give no fan-out queries and ChatGPT’s list comes back empty today, so fan-out coverage is computed from the engines that return them.

The metrics

Mention rate

The share of runs in which the brand is named.

mention_rate(cell) = runs where brand mentioned / runs

Compute it per cell, then aggregate by engine, market or prompt cluster (category, comparison, problem). A single blended number across engines hides which engine moved.

Mention share

Your mentions relative to all tracked brands’ mentions in the same runs.

mention_share = your mentions / Σ mentions of all tracked brands

This is the metric closest to classic share of voice, and it depends on the competitor list: define that list once and keep it stable across periods.

Citation share

Your share of the source entries across the prompt set.

citation_share = sources[] entries on your domains / all sources[] entries

It measures how much of the evidence layer you own. Track mean citation position alongside it; being source 1 and source 9 are different outcomes.

Recommendation share

For commercial prompts, the share of runs in which you appear in the recommendation set: the product fields in the table above where the engine returns products, and brand detection in text on every engine. Report it by engine, because the underlying signal differs.

Answer rank

When the answer is a list, your position among named brands: the order of first occurrence of each tracked brand name in text, on every engine. Report the mean rank over runs where you appear, together with the recommendation share, since a good rank on rare appearances is not a strong position.

Fan-out coverage

The share of the assistant’s own searches for which your domain ranks in Google Search.

fanout_coverage = fan-out queries where your domain is in organicResults / fan-out queries

Assistants that search the web choose sources from what those searches return. If you do not rank for the searches, you are unlikely to be cited from them. The query fan-out post and how engines choose citations go deeper.

To compute it:

  1. Collect fan-out queries from the engines that return them (table above). ChatGPT’s searchQueries[] is free with include.searchQueries: true, but it comes back empty on the answers served today.
  2. Deduplicate per market (normalize case and whitespace).
  3. Run each query on Google Search with the same country: POST /v1/monitor/google synchronously, or a GOOGLE async task ($0.00103 for the first page on every tier).
  4. For each query, record the best position in organicResults[] whose link is on your domain, or none.
  5. Coverage is the share of queries with a rank, optionally split into top 3 and top 10.

Uncertainty and sample size

Answers vary run to run, so every rate is an estimate. For a rate p over n independent runs:

SE = sqrt( p × (1 − p) / n )        95% margin ≈ 1.96 × SE
n needed for margin E = p × (1 − p) × (1.96 / E)²

Computed examples:

Decide the margin your reporting needs, then size runs per cell, or pool cells when a coarser number is acceptable. For a set-level rate across many prompts, the prompts themselves differ, so use the spread of per-prompt rates: SE = sd(prompt rates) / sqrt(prompts). For citation share, sources within one answer are correlated, so treat the answer, not the source entry, as the independent unit when estimating its uncertainty. The volatility post covers intervals near 0% and 100% and comparisons between periods.

Implementing it

The pipeline is one scheduled batch submission and one webhook receiver that turns each finished task into a row. The examples use requests and Flask over HTTP.

1. Submit the batch

Encode what each task measures in its idempotencyKey, so a retried submission creates nothing twice and the receiver can recover the prompt and market from the delivery.

import datetime, os, requests

API = "https://api.answerline.dev"
HEADERS = {"Authorization": f"Bearer {os.environ['API_KEY']}", "Content-Type": "application/json"}
WEBHOOK = "https://example.com/hooks/answers"

PROMPTS = {"p001": "best crm for small agencies", "p002": "hubspot vs pipedrive for agencies"}
MARKETS = ["US"]
ENGINES = {
    "CHATGPT": {"include": {"searchQueries": True, "shopping": True}},
    "GEMINI": {},
    "PERPLEXITY": {},
    "COPILOT": {},
    "GROK": {},
}

def submit(run_date, run_index):
    tasks = []
    for pid, prompt in PROMPTS.items():
        for country in MARKETS:
            for task_type, extra in ENGINES.items():
                tasks.append({
                    "taskType": task_type,
                    "idempotencyKey": f"sov:{run_date}:{run_index}:{task_type}:{country}:{pid}",
                    "payload": {"prompt": prompt, "country": country, **extra},
                    "webhook": {"url": WEBHOOK},
                })
    for start in range(0, len(tasks), 500):  # a batch holds 1-500 tasks
        r = requests.post(f"{API}/v1/async/task/batch", json=tasks[start:start + 500], headers=HEADERS, timeout=30)
        r.raise_for_status()
        for item in r.json()["results"]:
            if not item["success"] and item["error"]["code"] != "RESOURCE_ALREADY_EXISTS":
                print(tasks[start + item["index"]]["idempotencyKey"], item["error"]["code"])

submit(datetime.date.today().isoformat(), 1)

Cost for this example per run slot at the Starter tier: 2 prompts × 1 market × (ChatGPT $0.0006 + Gemini $0.0006) = 2 × $0.0012 = $0.0024; searchQueries is free. Multiply by runs per period for the budget; cost planning covers monthly totals and tiers.

2. Receive, verify and extract rows

The delivery body is the finished task JSON. Verify the signature on the raw bytes, ignore test deliveries, deduplicate by task.id, and read the engine result at response.result.

import hashlib, hmac, json, os, re, sqlite3, time
from urllib.parse import urlparse
from flask import Flask, abort, request

SECRET = os.environ["WEBHOOK_SECRET"].encode()
BRAND, DOMAIN = "Acme CRM", "acmecrm.com"
TRACKED = ["Acme CRM", "HubSpot", "Pipedrive", "Salesforce"]

db = sqlite3.connect("sov.db", check_same_thread=False)
db.execute("""create table if not exists runs (task_id text primary key, run_date text, engine text,
  country text, prompt_id text, mentioned int, cited_position int, recommended int, answer_rank int,
  source_domains text, fanout text)""")

def verify(raw, header):
    t, sigs = None, []
    for part in (header or "").split(","):
        key, _, value = part.strip().partition("=")
        if key == "t":
            t = value
        elif key == "v1":
            sigs.append(value)
    if not t or not t.isdigit() or abs(time.time() - int(t)) > 300:
        return False
    expected = hmac.new(SECRET, t.encode() + b"." + raw, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in sigs)

def on_domain(url):
    host = (urlparse(url or "").hostname or "").lower()
    return host == DOMAIN or host.endswith("." + DOMAIN)

def product_names(engine, r):
    if engine == "CHATGPT":
        return ([e.get("name", "") for e in r.get("entities", []) if e.get("type") == "product"]
                + [i.get("product", {}).get("title", "") for i in r.get("inlineProducts", [])])
    if engine == "COPILOT":
        return [p.get("name", "") for c in r.get("shoppingCards", []) for p in c.get("products", [])]
    if engine == "AIMODE":
        return [c.get("title", "") for c in r.get("shoppingCards", [])] + [i.get("title", "") for i in r.get("inlineProducts", [])]
    return []

def named_order(engine, r):
    if engine == "CHATGPT" and r.get("entities"):
        names = [e.get("name", "") for e in r["entities"]]
        return [b for n in names for b in TRACKED if b.lower() in n.lower()]
    text = (r.get("text") or "").lower()
    hits = [(m.start(), b) for b in TRACKED for m in [re.search(re.escape(b.lower()), text)] if m]
    return [b for _, b in sorted(hits)]

def row(task, r):
    _, run_date, _, engine, country, prompt_id = task["idempotencyKey"].split(":")
    order = list(dict.fromkeys(named_order(engine, r)))
    cited = [s.get("position") for s in r.get("sources", []) if on_domain(s.get("url"))]
    products = " ".join(product_names(engine, r)).lower()
    return (task["id"], run_date, engine, country, prompt_id,
            int(BRAND in order), min(cited) if cited else None,
            int(BRAND in order or BRAND.lower() in products),
            order.index(BRAND) + 1 if BRAND in order else None,
            json.dumps([urlparse(s.get("url") or "").hostname for s in r.get("sources", [])]),
            json.dumps(r.get("searchQueries", [])))

app = Flask(__name__)

@app.post("/hooks/answers")
def answers():
    raw = request.get_data()
    if not verify(raw, request.headers.get("Webhook-Signature")):
        abort(400)
    delivery = json.loads(raw)
    task = delivery.get("task", {})
    if delivery.get("test") or task.get("status") != "COMPLETED" or not (task.get("idempotencyKey") or "").startswith("sov:"):
        return "", 204
    db.execute("insert or ignore into runs values (?,?,?,?,?,?,?,?,?,?,?)", row(task, delivery["response"]["result"]))
    db.commit()
    return "", 204

Notes on the code:

3. Compute the metrics

With rows stored, each metric is a grouped query. Mention rate per engine and market for one day:

select engine, country, avg(mentioned) as mention_rate, count(*) as runs,
       avg(cited_position is not null) as cited_rate, avg(answer_rank) as mean_rank
from runs where run_date = '2026-09-17'
group by engine, country;

For fan-out coverage, collect the distinct queries from the fanout column per market (empty on ChatGPT, Gemini, Perplexity and Copilot rows today), submit them as GOOGLE tasks with {"query": q, "country": country}, and in that receiver record the best organicResults[].position whose link passes on_domain.

Reporting

The report template shows a layout, and the AI visibility tracking use case lists the fields by engine. Start with the ChatGPT engine page.

Questions

What is AI share of voice?

It is the share of AI-generated answers, citations and recommendations in a defined prompt set that belong to your brand, measured per engine and market over repeated runs. It is a set of rates, not a single score.

Which engines return named entities?

Only ChatGPT returns an entities array, the brands and products an answer names as type and name pairs. On Gemini and the other engines you detect brands in the answer text.

How do I measure fan-out coverage?

Collect the searches the assistant ran from the engines that return them, run each query through Google Search, and compute the share of queries where your domain appears in organicResults. It shows whether you rank for the searches the assistant uses to find sources. ChatGPT's searchQueries field comes back empty on the answers served today and Gemini has none, so for those engines the metric waits for a query list.

How many runs do I need before trusting a share-of-voice number?

Size it from the margin of error you need. The standard error of a rate is sqrt(p(1-p)/n); at p = 0.5, about 97 runs give a 95% margin of roughly plus or minus 10 points.

Where is the answer in a webhook delivery?

The webhook body is the finished task JSON. For a COMPLETED task, the engine result is at response.result, and task.idempotencyKey carries the key you set, which you can use to look up the prompt and market.

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