Clay logo, go to homepage

Clay GTM guide

How to Build an AI Visibility Dashboard

Track whether ChatGPT, Claude, and Perplexity mention your brand. Build an AI visibility dashboard with Clay, a custom analyzer, and Supabase.

June 15, 202611 min read

Most teams track their Google rankings every week. Nobody tracks whether they show up when a buyer asks ChatGPT which tool to buy. That second question is the one that matters now, and there is no Search Console for it. AI platforms give you nothing: no impressions, no position, no click data. So Clay's own growth team built one. Our SEO/AEO lead shipped this dashboard in two days, one of three Clay-powered systems running our SEO and AEO, at about 5x less than the off-the-shelf tools we evaluated. We call the discipline AEO, or AI Engine Optimization, and the dashboard is the measurement layer underneath it. This is how we built it, the exact prompts, schema, and code included.

What you need before you start

This build has four layers, and each one does a single job. Clay runs the prompts through the AI platforms and pulls back the raw answers. A second AI pass reads each answer and turns it into structured data. Supabase stores that data as a daily snapshot. A dashboard reads Supabase and renders the metrics; ours is a Next.js app that Claude Code generates from the Supabase data and deploys on Vercel. The whole thing sits on top of Clay and Supabase and touches no core infrastructure, which is why our team could stand it up in two days and could rebuild it in two more if it broke.

The whole point is turning an unstructured AI answer into a row you can query. The sentence "Clay was named third, framed positively, next to HubSpot and Apollo" is a judgment call, not a SQL filter. The architecture exists to make that judgment once, at write time, so every metric downstream is just arithmetic.

One prompt, the full pipeline

Prompt library

One buyer-intent prompt enters the system: best tools for B2B prospecting.

Output1 prompt
Click a layer to hold it, or watch the signal flow

One prompt becomes one queryable row by passing through collection, AI extraction, storage, and display in a fixed pipeline.

You will need a Clay workspace, a Supabase project, and somewhere to host the dashboard (we use Vercel). If you want a running head start, Clay publishes a SEO/AEO Visibility template you can clone instead of wiring every column by hand. Our public build shipped in two days; under the hood that breaks down to wiring Clay and Supabase cleanly, the query layer, and the dashboard. The hardest part was not code. It was writing the analyzer prompt to return clean JSON every time.

2 days

Clay's growth team built and shipped its AI visibility dashboard in two days, at roughly 5x less than the off-the-shelf AEO tools it evaluated.

Read the full story

Step 1: Design your prompt library

Decide what to track before you write any code. The dashboard is only as good as the prompts you feed it, and the single most important call you make here is splitting prompts into two categories that get reported differently.

Branded prompts inflate your own score, so they never touch your visibility metrics. Every brand looks great when you ask an AI what Clay is used for, because the model always knows what you are. Mix those in and your visibility number is meaningless. Non-branded, buyer-intent prompts (best tools for B2B prospecting, how do I automate outbound) are the only ones that tell you whether you get surfaced when a buyer is not already looking for you.

Why branded prompts inflate the score

Prompt

best tools for B2B prospecting

AI answer

  • 1HubSpot
  • 2Apollo
  • 3Clay (pos 3)
  • 4Outreach
  • 5Salesloft

Visibility score

41%
Click a label to hold a state, or replay the flip

Branded prompts always score near 100% because the model already knows you, so only non-branded prompts measure real visibility.

Tag every prompt with the same metadata you will want to slice by later: Prompt Type (branded, non-branded, or competitive), Topic, PMM use case, Intent (informational, transactional, or navigational), and free-form Tags. Start with 20 to 30 non-branded prompts, then scale once the pipeline is stable. Your leaderboards, topic analysis, and trend lines all filter to non-branded only.

Step 2: Build the Clay table

Each row in the table is one prompt. The columns turn that prompt into a structured record, platform by platform. For each AI platform you track (Claude, ChatGPT, Perplexity), you build the same four-column sequence. We started with Claygent, Clay's AI agent for research, and learned fast that it returns the answer text but not the citation data: the URLs and domains the model actually sourced. That citation signal is one of the most valuable things you can capture, so we rebuilt collection on the native AI API integrations to get it back.

The four columns per platform

Repeated per platform: Claude, ChatGPT, Perplexity

Query column

Claygent or native AI API: sends the prompt, returns the raw answer text plus cited URLs.

Two AI columns per platform: the formula flattens the query object before the analyzer reads it, so you can see exactly what the analyzer received.

Click a column to hold it, or watch the group assemble

Each platform needs a query column, a parsing formula, an analyzer AI column, and an HTTP API column, and splitting query from analysis is what makes the pipeline debuggable.

The Parsed Response column is a Formula column. It exists so the analyzer reads clean, flat fields instead of a nested query object. Keeping the query, the parse, and the analysis as separate columns is what makes the whole thing debuggable: when something breaks, you can see exactly what each step produced.

Step 3: Write the analyzer prompt

The analyzer is the intelligence layer, and it is the hardest part of the build. It reads one raw AI answer and returns strict JSON. The fields your metrics need: whether your brand was mentioned and where, sentiment, citation type, cited URLs with each domain classified, competitors mentioned, themes, and how the answer positioned you against rivals.

Normalize competitor names at extraction time, not later. An AI answer might call the same company "ZoomInfo," "Zoom Info," and "DiscoverOrg" across three responses. If you store those raw, your leaderboard shows three competitors where there is one, and the picture fragments. Fixing it once, in the analyzer prompt, keeps every downstream query clean.

Analyzer prompt (Use AI column)
You are analyzing an AI assistant's response to a buyer-intent prompt.Read the response and the list of cited URLs. Return ONLY valid JSON,no markdown fences, no commentary. Use this exact schema:{  "clayMentioned": "Yes" | "No",  "clayMentionPosition": <integer or null>,  "clayMentionSnippet": <string or null>,  "brandSentiment": "positive" | "neutral" | "negative",  "brandSentimentScore": <0-100, 50 = neutral>,  "citationType": "Direct" | "Indirect" | "None",  "citations": [    { "url": <string>, "domain": <string>, "title": <string>,      "urlType": "Owned" | "Competition" | "Institution"                 | "Earned Media" | "Social" | "PR Wire" | "Other" }  ],  "competitorsMentioned": ["Apollo", "HubSpot", "ZoomInfo"],  "themes": [<short strings>],  "primaryUseCaseAttributed": <string or null>,  "positioningVsCompetitors": <string or null>}Normalize competitor names before returning them:- "ZoomInfo" / "Zoom Info" / "DiscoverOrg"  -> "ZoomInfo"- "HubSpot" / "Hubspot" / "HubSpot CRM"      -> "HubSpot"- "Apollo" / "Apollo.io"                     -> "Apollo"- "Outreach" / "Outreach.io"                 -> "Outreach"Map every product or variant name to its canonical company name.Return ONLY the JSON object.

Build this as a Use AI column in Clay, the feature that runs GPT, Claude, or Gemini against your row data. Test it on one known row before you scale. Clay reports a 200 OK even when the JSON silently fails to parse, so a strict schema and a test row are the only things standing between you and weeks of broken data.

Raw answer to structured JSON

Raw AI answer

For B2B prospecting, popular options include HubSpot and Apollo. Clay is also worth considering for its data enrichment and is frequently recommended for go-to-market automation, often cited alongside sources like the Clay blog.

Extracted JSON

Click a field to trace it back to its source phrase

The analyzer's job is to map specific phrases in an unstructured answer to exact JSON fields, which is why a strict schema matters.

Step 4: Build the Supabase schema

The schema is five tables, and one design decision carries the whole thing. The responses table holds one row per prompt, per platform, per day, with a UNIQUE (prompt_id, platform, run_day) constraint. That constraint means a same-day re-run overwrites the existing row instead of duplicating it. Add it before you have real data, because retrofitting dedup onto a table full of duplicates is miserable.

The other four tables: prompts (one row per unique prompt), citation_domains (one flattened row per cited URL, for domain analysis), and response_competitors (one flattened row per competitor, for leaderboard queries). Denormalize the prompt metadata into responses so you can filter by topic or type without a join.

responses table
create table responses (  id            bigint generated always as identity primary key,  prompt_id     bigint references prompts(id),  platform      text not null,  run_day       date not null,  clay_mentioned         boolean,  clay_mention_position  int,  brand_sentiment        text,  brand_sentiment_score  int,  citation_type          text,  -- denormalized prompt metadata for fast filtering  prompt_type   text,  topic         text,  -- same-day re-runs overwrite instead of duplicating:  unique (prompt_id, platform, run_day));

Step 5: Write the upsert RPC

Clay calls one Supabase RPC with a single JSONB payload, and that function does every multi-table insert atomically. One problem will bite you the moment you connect the two systems: Clay's analyzer outputs camelCase keys (clayMentioned), but a sane Postgres schema uses snake_case (clay_mentioned). Rather than rewrite either side, make the RPC accept both.

COALESCE makes the endpoint backward-compatible without touching Clay or the schema. Read each field from the camelCase key first, then fall back to snake_case; whichever is non-null wins. Do this from day one and a key rename on either side never breaks ingestion.

upsert_visibility_response RPC
create or replace function upsert_visibility_response(payload jsonb)returns void language plpgsql as $$declare  v_analyzer jsonb := payload->'analyzer';  v_mentioned boolean;  v_position  int;begin  -- accept camelCase (Clay analyzer) OR snake_case (schema):  v_mentioned := coalesce(      (v_analyzer->>'clayMentioned')::boolean,      (payload->>'clay_mentioned')::boolean  );  v_position := coalesce(      (v_analyzer->>'clayMentionPosition')::int,      (payload->>'clay_mention_position')::int  );  -- upsert response; the unique constraint dedupes same-day re-runs  insert into responses (prompt_id, platform, run_day,                         clay_mentioned, clay_mention_position)  values ((payload->>'prompt_id')::bigint, payload->>'platform',          (payload->>'run_day')::date, v_mentioned, v_position)  on conflict (prompt_id, platform, run_day)  do update set clay_mentioned        = excluded.clay_mentioned,                clay_mention_position = excluded.clay_mention_position;  -- replace-all is simplest and always correct: the parent response  -- drives the child content, so delete then re-insert  delete from citation_domains     where response_prompt = (payload->>'prompt_id')::bigint;  delete from response_competitors where response_prompt = (payload->>'prompt_id')::bigint;  -- ...re-insert citation_domains and response_competitors from payload...end;$$;

The flow inside the function: normalize the payload, upsert the prompt, upsert the response (the unique constraint dedupes), then delete and re-insert the citation and competitor child rows. Replace-all on the children is simpler than diffing and always correct, because the parent response is the single source of truth for what they contain.

Step 6: Connect Clay to Supabase

The link is one HTTP API column per platform that posts to your RPC. Clay's HTTP API column can call any endpoint, native integration or not, which is exactly what you need to reach a custom Supabase function. Point each column at your RPC URL with three headers. The apikey header takes your anon key for PostgREST routing. The Authorization Bearer header takes your service-role key, which grants the RPC write and bypasses row-level security. Reference your Clay columns in the payload with the {{Column Name.field}} syntax.

HTTP API column
POST https://[project].supabase.co/rest/v1/rpc/upsert_visibility_responseHeaders:  Content-Type:  application/json  apikey:        <anon key>                  # PostgREST routing  Authorization: Bearer <service-role key>   # RPC write, bypasses RLSBody:{  "prompt_id": {{Prompt ID.value}},  "platform":  "claude",  "run_day":   "{{Run Day.value}}",  "analyzer":  {{Response Analyzer.json}}}

One behavior to plan around: HTTP API columns fire on enrichment, meaning a scheduled refresh or a manual run, not when the table loads. So Supabase lags Clay by however long a run takes to process. Schedule your runs with Clay's scheduled columns or scheduled sources, and your dashboard refreshes on that cadence.

Step 7: Build the dashboard and define the metrics

The dashboard is a Next.js App Router app deployed on Vercel; we had Claude Code generate it against the Supabase schema, then kept editing by hand. The rule that keeps it maintainable is that components never touch SQL. Every query lives in /lib/queries as a pure async function (getVisibilityScore(supabase, filters)), and the React components just call them. Six pages cover it: Home, Citations, Competitive Intelligence, Sentiment, Prompts (the raw drill-down), and a Metric Explorer for ad-hoc questions.

Six metrics do the work. They are all simple ratios once the analyzer has done the hard part, and the table below is the exact definition behind each number.

The six dashboard metrics and how each is calculated

MetricCalculationWhat it tells you
Visibility Scorementioned responses / total responses x 100How often you appear, per window, type, and platform
Citation Ratedirect-citation responses / responses that cited any domain x 100How often a mention comes with a real source link
Average Positionavg clay_mention_position where mentioned (1 = named first)Where you land when you do appear
Sentiment Scoreavg brand_sentiment_score (0 to 100, 50 = neutral)How the AI frames you when it names you
Competitor Visibilityresponses containing competitor / total x 100, off response_competitorsHow often each rival appears next to you
Period deltathe same metric over the prior equal-length windowWhether you are gaining or losing ground

The compare-period toggle computes the prior equal-length window and shows the delta, so every metric reads as a trend, not a snapshot. Here is a query-layer function, the shape every metric follows.

/lib/queries/getVisibilityScore.ts
export async function getVisibilityScore(  supabase: SupabaseClient,  filters: { startDate: string; endDate: string; platform?: string }): Promise<number> {  let q = supabase    .from("responses")    .select("clay_mentioned", { count: "exact" })    .eq("prompt_type", "non-branded")          // visibility = non-branded only    .gte("run_day", filters.startDate)    .lte("run_day", filters.endDate);  if (filters.platform) q = q.eq("platform", filters.platform);  const { data, count } = await q;  const total = count ?? 0;  const mentioned = (data ?? []).filter((r) => r.clay_mentioned).length;  return total === 0 ? 0 : Math.round((mentioned / total) * 1000) / 10;}

Once these are running, a PMM can ask real questions and act on them. Which platforms never mention us (a 0% platform is a content or citation gap). Which topics are we invisible on (the content to create next). Which domains does AI cite for our competitors (the sources to go earn). What does AI say about us versus them in the same answer (the clearest competitive read you can get).

Step 8: Monitor it like an engineering system

A vibe-coded pipeline that ingests silently is the one that hurts you. In late April, OpenAI shifted from GPT-4o to GPT-5.5, the response format changed, and our Clay columns failed quietly: 200 OK, empty parsed response, nulls flowing into Supabase. Weeks of data came in incomplete before anyone noticed.

Silent ingestion failures are worse than loud ones, so monitor for them on purpose. Add a daily row-count check (did we get the expected number of rows for the prompts that ran) and a null-rate check on the fields that matter most (clay_mentioned, citation_type, brand_sentiment). If row count drops or null rate spikes, something upstream changed and you want to know that day, not next month. Treat the whole thing as software with a data contract, not a spreadsheet you check sometimes.

Off-the-shelf AEO tools exist, but they are one-size-fits-all: brand mention rate and sentiment, nothing custom. The reason we built our own is the questions only we will ask. Did our new pricing change how AI describes our value? How fast did our MCP start showing up in AI answers? Those need an analyzer schema you write yourself, which is the entire payoff of this build. The other payoff is maintenance: because the dashboard sits on top of Clay and Supabase and touches no core infrastructure, there is no migration, no contract, and no sunk cost. If something breaks, we rebuild it in two days instead of filing a support ticket. This is what Clay's team means by content engineering as an extension of GTM engineering: the same instinct that turns a buying signal into an automated play turns an AI answer into a tracked, queryable metric.

Build your AI visibility dashboard in Clay

Run buyer-intent prompts through every AI platform, extract structured signal, and see who AI recommends instead of you.

Frequently asked questions

What is an AI visibility dashboard?

An AI visibility dashboard tracks whether and how AI assistants like ChatGPT, Claude, and Perplexity mention your brand when buyers ask for tool recommendations. It runs a library of buyer-intent prompts on a schedule, uses a second AI pass to extract structured signal (mentioned or not, position, sentiment, competitors, cited sources), and renders metrics you can act on. It is the measurement layer for AEO, the way Search Console is the measurement layer for SEO.

What is AEO and how is it different from SEO?

AEO, AI Engine Optimization, is optimizing for being surfaced inside AI assistant answers rather than ranking in a list of links. SEO targets ten blue links on a results page; AEO targets the single synthesized answer a model gives when a buyer asks it which tool to buy. They share tactics (earning citations, owning topic authority) but the measurement is different: SEO has impressions and click-through, AEO has mention rate, citation rate, and sentiment that you have to capture yourself.

How do you track brand mentions in ChatGPT and other AI tools?

You query each platform with real buyer-intent prompts and capture the raw answer plus the URLs it cited. Then a second AI pass extracts the structured fields: was the brand mentioned, in what position, with what sentiment, and which competitors appeared alongside it. In Clay, a Claygent or native AI API column collects the answer, a formula column flattens it, a Use AI column analyzes it into JSON, and an HTTP API column writes it to your database. The structured output is what makes "mentioned third, framed positively" a queryable metric.

Why separate branded and non-branded prompts?

Branded prompts (what is Clay used for) always score near 100% because the model already knows what you are, so including them inflates your visibility number and hides the real signal. Non-branded, buyer-intent prompts (best tools for B2B prospecting) are the only ones that show whether AI surfaces you when a buyer is not already searching for you. Keep branded prompts in a separate sentiment view and filter every visibility metric, leaderboard, and trend line to non-branded only.

Should you build an AI visibility dashboard or buy an off-the-shelf AEO tool?

Buy if you only need brand mention rate and sentiment across platforms and want it running today. Build if you want to answer custom questions an off-the-shelf tool cannot: whether a pricing change shifted how AI describes you, how fast a new product started appearing, or which exact domains AI cites for each competitor. Building takes about a week with Clay and Supabase, and the payoff is an analyzer schema you control, so the dashboard answers your questions instead of generic ones.