Perplexity is the one AI engine where sources are the product. Every answer ships with a numbered citation list. That makes it the single best place to measure whether AI search actually knows your brand exists — and, unlike ChatGPT, it will tell you exactly which pages it read to decide.
The problem: Perplexity gives you no dashboard for this. There's no "who mentioned my brand" endpoint. And most tutorials you'll find still tell you to use the Sonar chat-completions API — which is no longer where Perplexity is pointing developers.
In this guide we'll build a working brand-mention tracker on the Agent API, run it for real, and look at the actual numbers. I ran it against my own product. It scored zero. I'll show you that output too, because a tracker that only prints good news isn't a tracker.
- Why Perplexity now steers developers to the Agent API instead of Sonar
- How to parse the
outputarray — including the retrieval set most people miss - A complete, production-shaped Python tracker (mention rate, citation rate, share of voice)
- Real measured costs per prompt, and which preset to track with
- The volatility problem that makes single-run tracking worthless
First: Sonar Is Not Where Perplexity Is Pointing Anymore
If you built anything against Perplexity in 2025, you used Sonar — an OpenAI-compatible /chat/completions endpoint with models like sonar, sonar-pro and sonar-reasoning. It still works. But it is no longer the recommended path, and you can verify that yourself rather than taking my word for it:
- The official docs index at
docs.perplexity.ai/llms.txtdescribes the platform as "documentation for building with the Agent API, the default for web-grounded AI and multi-provider applications, plus Search, Embeddings, and Sonar APIs." Agent API is listed first and called the default; Sonar is listed last. - Perplexity published a post titled "Your Sonar workload runs better on Agent API", calling the Agent API its "flagship agentic research endpoint" and publishing head-to-head benchmarks — roughly double the DSQA accuracy with the
fastpreset at the same ~$0.006/query as Sonar, and a large BrowseComp jump for thelowpreset versus Sonar Pro. - There is a formal migration guide describing the Agent API as "a major upgrade of Sonar Chat Completions", plus an official migration skill on GitHub that refers to "replacing deprecated
sonar-proorsonar-reasoningmodels."
For brand tracking specifically, there's a second reason to switch that has nothing to do with benchmarks. Sonar returned an answer and a flat list of citations. The Agent API returns the whole agent trace: the search queries Perplexity generated on your behalf, the full ranked retrieval set it pulled, which of those it actually cited, and an itemised cost breakdown. For visibility work, the retrieval set is the most valuable object in the response — it's the difference between knowing you lost and knowing why.
POST /v1/agent, and also aliased at POST /v1/responses so you can point the OpenAI SDK at api.perplexity.ai and keep most of your existing code shape.
Setup
You need Python 3.9+, a Perplexity API key from the API settings page, and one package:
pip install perplexityai
export PERPLEXITY_API_KEY="pplx-your-key-here"
The SDK reads PERPLEXITY_API_KEY from the environment automatically, so the smallest possible call is three lines:
from perplexity import Perplexity
client = Perplexity()
resp = client.responses.create(
preset="fast",
input="What are the best AI visibility tracking tools?",
)
print(resp.output_text)
Anatomy of an Agent API Response
This is the part that trips people up coming from Sonar. resp.output is a list of items, not a message. A typical answer contains two:
| Item type | What's inside | Why you care |
|---|---|---|
search_results |
queries (the searches Perplexity ran) and results — each with id, url, title, snippet, date |
The retrieval set. Everything Perplexity considered, cited or not. |
message |
content[].text where type == "output_text" |
The answer a real user would read. |
Deep-research presets can also emit fetch_url_results items when the agent opens a page in full.
The link between the two is the citation marker. The id on each search result is the number that appears in the prose as [3]. So to find out whether your domain was actually used — not merely retrieved — you match citation markers back to result IDs.
- Citations come in two shapes. Depending on the preset's system prompt you'll see bare
[3]or namespaced[web:3]. Write your regex to accept both:r"\[(?:([a-z_]+):)?(\d+)\]". resultscan beNone. I hit this on themediumpreset — asearch_resultsitem arrived with a null results array and crashed the parser. Always usegetattr(item, "results", None) or [].
The Three Metrics That Matter
Most people track one number — "was my brand named?" — and miss the interesting part. The Agent API lets you separate three very different things:
- Mention rate — your brand name appears in the answer text. This is what a buyer sees.
- Retrieved rate — your domain appeared in the search results Perplexity pulled. You were in the running.
- Citation rate — your domain was retrieved and cited in the prose. You were the evidence.
These come apart in ways that change what you should do next. A brand with a high mention rate and a zero citation rate is being recommended based on other people's listicles — great, but fragile, and not something you control. A domain that's retrieved but never cited has a content quality problem, not a discovery problem. And if you're not retrieved at all, no amount of on-page work matters yet; you have an indexing and authority problem.
The Tracker
Here's the full script. Edit the config block at the top; everything else is machinery. It fans out across prompts with a thread pool, parses the agent trace, scores mention/retrieved/cited, and writes a CSV.
"""
perplexity_brand_tracker.py
Track how often your brand is mentioned and cited in Perplexity answers,
using Perplexity's Agent API (POST /v1/agent).
pip install perplexityai
export PERPLEXITY_API_KEY="pplx-..."
python perplexity_brand_tracker.py
"""
import csv
import os
import re
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from urllib.parse import urlparse
from perplexity import Perplexity
# --------------------------------------------------------------------------
# 1. CONFIG — edit this block, nothing else
# --------------------------------------------------------------------------
BRAND = "RankBits"
BRAND_DOMAIN = "rankbits.com"
COMPETITORS = [
"Profound", "Peec AI", "Otterly.AI", "Scrunch AI",
"Semrush", "Ahrefs", "Rankscale",
]
PROMPTS = [
"What are the best AI visibility tracking tools?",
"How can I track if ChatGPT mentions my brand?",
"Best tools to monitor brand mentions in AI search engines",
"What software tracks share of voice in AI answers?",
"Affordable alternatives to Profound for AI visibility tracking",
"How do I measure my brand's visibility in Perplexity?",
"Best GEO tools for a small marketing team",
"Tools that track citations in AI Overviews and ChatGPT",
]
PRESET = "fast" # fast | low | medium | high | xhigh
RUNS_PER_PROMPT = 1 # bump to 3+ to measure answer volatility
MAX_WORKERS = 4
OUT_CSV = "perplexity_visibility.csv"
client = Perplexity()
# --------------------------------------------------------------------------
# 2. PARSING THE AGENT API RESPONSE
# --------------------------------------------------------------------------
# The Agent API returns `output` as a LIST OF ITEMS, not a single message.
# The two we care about:
# type="search_results" -> what Perplexity RETRIEVED (queries + ranked results)
# type="message" -> the answer text the user actually sees
# That retrieval list is the part Sonar never gave you this cleanly, and it is
# the difference between "we lost" and "we lost because we were never retrieved".
# Perplexity emits citations in two shapes depending on the preset's system
# prompt: bare "[3]" or namespaced "[web:3]". Handle both.
CITE_RE = re.compile(r"\[(?:([a-z_]+):)?(\d+)\]")
@dataclass
class Source:
index: int
url: str
title: str
domain: str
cited: bool = False
@dataclass
class PromptResult:
prompt: str
run: int
answer: str = ""
queries: list = field(default_factory=list)
sources: list = field(default_factory=list)
cost: float = 0.0
latency: float = 0.0
error: str = ""
def domain_of(url: str) -> str:
try:
return urlparse(url).netloc.lower().removeprefix("www.")
except Exception:
return ""
def parse_response(resp) -> tuple:
"""Split an Agent API response into (answer_text, queries, sources)."""
answer, queries, sources = "", [], []
for item in resp.output:
# Deep-research presets can emit a search_results item with results=None,
# so never index these fields directly.
if item.type == "search_results":
queries.extend(getattr(item, "queries", None) or [])
for r in (getattr(item, "results", None) or []):
url = getattr(r, "url", "") or ""
sources.append(Source(
index=getattr(r, "id", len(sources) + 1),
url=url,
title=getattr(r, "title", "") or "",
domain=domain_of(url),
))
elif item.type == "message":
for block in (getattr(item, "content", None) or []):
if getattr(block, "type", "") == "output_text":
answer += block.text
# Mark which retrieved sources the model actually cited in the prose.
cited_ids = {int(n) for _, n in CITE_RE.findall(answer)}
for s in sources:
s.cited = s.index in cited_ids
return answer, queries, sources
def ask(prompt: str, run: int) -> PromptResult:
started = time.time()
try:
resp = client.responses.create(preset=PRESET, input=prompt)
except Exception as exc: # rate limits, 5xx, timeouts
return PromptResult(prompt, run, error=f"{type(exc).__name__}: {exc}")
answer, queries, sources = parse_response(resp)
cost = 0.0
if resp.usage and resp.usage.cost:
cost = resp.usage.cost.total_cost or 0.0
return PromptResult(
prompt=prompt, run=run, answer=answer, queries=queries,
sources=sources, cost=cost, latency=time.time() - started,
)
# --------------------------------------------------------------------------
# 3. SCORING — mention, position, citation
# --------------------------------------------------------------------------
def find_brand(text: str, brand: str) -> int:
"""Character offset of the first whole-word mention, or -1."""
m = re.search(rf"(?<!\w){re.escape(brand)}(?!\w)", text, re.IGNORECASE)
return m.start() if m else -1
def score(res: PromptResult) -> dict:
all_brands = [BRAND] + COMPETITORS
hits = {b: find_brand(res.answer, b) for b in all_brands}
present = {b: pos for b, pos in hits.items() if pos >= 0}
# Rank brands by where they first appear. Earlier = more prominent.
ranked = sorted(present, key=lambda b: present[b])
brand_rank = ranked.index(BRAND) + 1 if BRAND in present else None
domain_sources = [s for s in res.sources if s.domain == BRAND_DOMAIN]
return {
"prompt": res.prompt,
"run": res.run,
"mentioned": BRAND in present,
"rank": brand_rank,
"brands_in_answer": len(present),
"competitors": ", ".join(b for b in ranked if b != BRAND),
"retrieved": bool(domain_sources), # our page was in the search set
"cited": any(s.cited for s in domain_sources), # ...and made it into the answer
"n_sources": len(res.sources),
"cost_usd": round(res.cost, 5),
"latency_s": round(res.latency, 1),
"error": res.error,
}
# --------------------------------------------------------------------------
# 4. RUN
# --------------------------------------------------------------------------
def main():
if not os.getenv("PERPLEXITY_API_KEY"):
sys.exit("Set PERPLEXITY_API_KEY first.")
jobs = [(p, r) for p in PROMPTS for r in range(1, RUNS_PER_PROMPT + 1)]
results = []
print(f"Running {len(jobs)} Agent API calls (preset={PRESET})...\n")
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
futures = {pool.submit(ask, p, r): (p, r) for p, r in jobs}
for f in as_completed(futures):
res = f.result()
results.append(res)
flag = "!" if res.error else ("HIT " if find_brand(res.answer, BRAND) >= 0 else "miss")
print(f" [{flag}] {res.prompt[:58]}")
rows = [score(r) for r in results]
rows.sort(key=lambda r: (r["prompt"], r["run"]))
with open(OUT_CSV, "w", newline="", encoding="utf-8") as fh:
w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
w.writeheader()
w.writerows(rows)
ok = [r for r in rows if not r["error"]]
n = len(ok) or 1
mention_rate = sum(r["mentioned"] for r in ok) / n * 100
retrieved_rate = sum(r["retrieved"] for r in ok) / n * 100
cited_rate = sum(r["cited"] for r in ok) / n * 100
total_cost = sum(r["cost_usd"] for r in ok)
print(f"\n{'='*62}\n {BRAND} — Perplexity visibility over {n} answers\n{'='*62}")
print(f" Mention rate {mention_rate:5.1f}% (named in the answer text)")
print(f" Retrieved rate {retrieved_rate:5.1f}% ({BRAND_DOMAIN} in the search set)")
print(f" Citation rate {cited_rate:5.1f}% ({BRAND_DOMAIN} cited in the answer)")
print(f" Total cost ${total_cost:.4f} (${total_cost/n:.4f}/prompt)")
# Competitive share of voice
tally = {}
for r in ok:
for b in ([BRAND] if r["mentioned"] else []) + [
c.strip() for c in r["competitors"].split(",") if c.strip()
]:
tally[b] = tally.get(b, 0) + 1
print(f"\n Share of voice across {n} answers")
print(f" {'-'*44}")
for b, c in sorted(tally.items(), key=lambda kv: -kv[1]):
bar = "#" * round(c / n * 24)
star = " <-- you" if b == BRAND else ""
print(f" {b:<14} {c/n*100:5.1f}% {bar}{star}")
# Which domains does Perplexity keep reading for this topic?
freq = {}
for res in results:
for d in {s.domain for s in res.sources if s.domain}:
freq[d] = freq.get(d, 0) + 1
print(f"\n Most-retrieved domains (your citation targets)")
print(f" {'-'*44}")
for d, c in sorted(freq.items(), key=lambda kv: -kv[1])[:12]:
print(f" {c:>3}/{n} {d}")
print(f"\n Saved -> {OUT_CSV}")
if __name__ == "__main__":
main()
Real Output (Including the Part That Hurts)
I pointed this at RankBits — my own AI visibility tool — with eight prompts a buyer in this category would plausibly type. Here's the unedited run:
Running 8 Agent API calls (preset=fast)...
[miss] What are the best AI visibility tracking tools?
[miss] What software tracks share of voice in AI answers?
[miss] Best tools to monitor brand mentions in AI search engines
[miss] How can I track if ChatGPT mentions my brand?
[miss] How do I measure my brand's visibility in Perplexity?
[miss] Affordable alternatives to Profound for AI visibility trac
[miss] Best GEO tools for a small marketing team
[miss] Tools that track citations in AI Overviews and ChatGPT
==============================================================
RankBits — Perplexity visibility over 8 answers
==============================================================
Mention rate 0.0% (named in the answer text)
Retrieved rate 0.0% (rankbits.com in the search set)
Citation rate 0.0% (rankbits.com cited in the answer)
Total cost $0.0700 ($0.0087/prompt)
Share of voice across 8 answers
--------------------------------------------
Profound 75.0% ##################
Peec AI 75.0% ##################
Semrush 50.0% ############
Scrunch AI 37.5% #########
Rankscale 25.0% ######
Ahrefs 25.0% ######
Otterly.AI 12.5% ###
Most-retrieved domains (your citation targets)
--------------------------------------------
4/8 rankability.com
4/8 beamtrace.com
3/8 therankmasters.com
3/8 vismore.ai
2/8 cognizo.ai
2/8 visible.seranking.com
2/8 deepsmith.ai
2/8 slatehq.com
2/8 orchly.ai
2/8 ahrefs.com
2/8 otterly.ai
2/8 sitepoint.com
Saved -> perplexity_visibility.csv
Zero across the board. RankBits is about a month old, so this is exactly what you'd expect — and it's a far more useful result than a vanity screenshot, because it shows what the tool is for. Three things fall out of this data.
1. Mention rate and citation rate are different numbers
In a parallel run I scored every competitor on both axes:
| Brand | Mention rate | Own domain retrieved | Own domain cited |
|---|---|---|---|
| Peec AI | 75% | 0% | 0% |
| Profound | 62% | 0% | 0% |
| Semrush | 62% | 12% | 12% |
| Ahrefs | 38% | 12% | 12% |
| Scrunch AI | 38% | 0% | 0% |
| Rankscale | 38% | 0% | 0% |
| Otterly.AI | 25% | 0% | 0% |
| RankBits | 0% | 0% | 0% |
Look at Peec AI: recommended in three of every four answers, with its own website cited zero times. Perplexity isn't recommending these tools because it read their homepages. It's recommending them because it read rankability.com, beamtrace.com and therankmasters.com — third-party roundups. Your marketing site is largely not the lever here. Getting into other people's comparison posts is.
2. Retrieved ≠ cited
Because the Agent API exposes the retrieval set, you can see pages that got pulled and then ignored. beamtrace.com was retrieved in 3 of 8 answers and cited in zero. Same for geoptie.com and llmclicks.ai. Meanwhile therankmasters.com and siftly.ai converted 3-for-3.
That distinction is the single most actionable thing in the response, and Sonar never surfaced it. Being retrieved and dropped is a content problem you can fix — the page was found and judged unhelpful. Not being retrieved at all is a different problem entirely. If you want the fix for the first one, see how to structure a page so AI actually cites it.
3. One run tells you almost nothing
I ran the identical prompt set three times, same preset, minutes apart:
| Brand | Run 1 | Run 2 | Run 3 | Swing |
|---|---|---|---|---|
| Profound | 50.0% | 62.5% | 75.0% | 25 pts |
| Peec AI | 50.0% | 75.0% | 75.0% | 25 pts |
| Scrunch AI | 12.5% | 37.5% | 37.5% | 25 pts |
| Semrush | 62.5% | 62.5% | 50.0% | 12 pts |
| Otterly.AI | 12.5% | 25.0% | 12.5% | 12 pts |
Nothing changed on the web in those few minutes. That's pure sampling noise, and it's large — up to 25 percentage points on an 8-prompt set. If you run this once and report "we're at 40% share of voice," you're reporting a coin flip. Raise RUNS_PER_PROMPT, widen your prompt set, and only ever look at trends. A useful rule: with 8 prompts your error bars are roughly ±18 points, so you need 30–50 prompts before a week-over-week change means anything.
Which Preset Should You Track With?
Presets bundle a model, a search config, a step budget and a system prompt. Perplexity renamed them to tier names — the old fast-search, pro-search, deep-research, advanced-deep-research and ultra are now fast, low, medium, high and xhigh. Here's what I measured on the same prompt:
| Preset | Model behind it | Latency | Cost | Sources | Answer length |
|---|---|---|---|---|---|
fast |
openai/gpt-5.4-mini |
7.1s | $0.0097 | 10 | 1,901 chars |
low |
google/gemini-3-flash-preview |
16.4s | $0.0109 | 15 | 4,700 chars |
medium / high |
gpt-5.6-luna / gpt-5.6-sol |
minutes | much higher | 15+, multi-step | very long |
For brand tracking, use fast or low. They approximate what a normal person gets when they ask Perplexity a question. The deep-research presets run up to 15–100 agent steps and produce reports nobody's actually reading before they pick a vendor — you'd be measuring a surface your buyers never see, at many times the cost.
max_steps, instructions, tools) inline and omit preset. The docs call this a frozen configuration. Otherwise a model swap on Perplexity's side will look like a visibility change on yours.
What This Actually Costs
Every response carries a real cost breakdown at resp.usage.cost — input, output, cache reads, and tool calls itemised separately. My eight prompts on fast came to $0.0700, about $0.0087 per prompt.
Scale that honestly:
| Setup | Calls/month | Approx. cost |
|---|---|---|
| 20 prompts, weekly | ~87 | ~$0.75 |
| 30 prompts, 3 runs each, weekly | ~390 | ~$3.40 |
| 50 prompts, 3 runs each, daily | 4,500 | ~$39 |
So let's be straight: if you only care about Perplexity, DIY is cheap. Anyone telling you otherwise is selling something. The API bill is not the reason to buy a tool.
The Honest Limitations
Four things this script can't do, in descending order of how much they matter.
The API is not the app
This is the big one, and it's easy to miss. The Agent API let me choose the model — the fast preset answered using openai/gpt-5.4-mini. That is not what perplexity.ai serves a logged-in user, which has its own routing, its own system prompt, and its own personalisation. API-based tracking is a solid directional proxy for the retrieval layer, and genuinely excellent for finding which third-party pages influence your category. It is not ground truth for what a human sees in the product. Treat the two as correlated, not identical.
Perplexity is one engine
Your buyers are also asking ChatGPT, Gemini, Claude, Copilot, Grok, and reading Google AI Overviews and AI Mode. Some of those have APIs — I've covered the ChatGPT version of this script using the OpenAI Responses API. Several have no API at all. There is no endpoint that returns a Google AI Overview, a Bing Copilot answer, or what Claude's consumer app says. Those require browser automation that breaks constantly.
Your prompt list is doing most of the work
I chose eight prompts. Had I chosen eight flattering ones, RankBits would have scored better and the number would have meant less. Prompt selection is the highest-leverage and most-fudged part of AI visibility measurement — see how to build a prompt set that reflects real buyer questions.
Substring matching is naive
The word-boundary regex handles "Profound." and "Profound," correctly, but it can't tell a recommendation from a warning, and it will happily match a brand named "Notion" inside unrelated prose. For anything serious, use the Agent API's structured outputs to have the model return a JSON verdict per brand — mentioned, recommended, or disparaged — instead of pattern-matching strings.
When to Stop Building This
Keep the script if you want Perplexity only, on a handful of prompts, and you enjoy owning the pipeline. It's genuinely good for that, and the retrieval-set analysis is something most commercial dashboards don't even expose.
The wall isn't cost — it's the other seventeen surfaces, the scheduling, the storage, the deduping, the trend maths, and the engines with no API. That's the gap RankBits fills: 18 engines and search sources — ChatGPT and ChatGPT Pro, Claude and Claude Pro, Gemini and Gemini Pro, Grok and SuperGrok, Perplexity, Bing Copilot, Google AI Mode, Google AI Overviews, plus Google, Bing, DuckDuckGo and Yahoo as the source-layer baseline, and Exa and Tavily as the retrieval layer agents actually use.
Two things worth knowing if you liked this article: API access is included on every plan, including the free one, so you can keep working in Python and just skip building the collection layer — see the API docs. And there's a dedicated Perplexity tracker that measures the consumer surface rather than the API proxy, which is the one limitation above you can't code around.
🚀 See Where You Stand in Perplexity — Free
Run a free AI visibility scan across Perplexity, ChatGPT, Claude, Gemini, Grok and 13 more engines. No signup required, and you'll get the same mention, citation and share-of-voice breakdown this script produces — across every engine at once.
Run Your Free Scan →Next Steps
- Run the script against your own brand. Expect a low number. Everyone's is low right now.
- Read the "most-retrieved domains" list, not your own score. Those are the pages deciding your category. Getting listed on three of them will move your mention rate more than a month of on-site work.
- Check retrieved-vs-cited for your domain. Retrieved but not cited is a fixable content problem. Never retrieved is an authority and indexing problem.
- Raise
RUNS_PER_PROMPTto 3 and your prompt count to 30+ before you report a number to anyone. - Track the trend weekly. A single scan is a snapshot of a noisy system. The slope is the only thing that means anything — which is also what an AI visibility score is trying to summarise.
The brands winning AI search right now aren't the ones with the best homepage copy. They're the ones showing up in everyone else's comparison posts — and measuring it often enough to notice when that changes.