# Built to be quoted

> The outward-facing surface — what ChatGPT, Perplexity, Gemini and Claude read when they arrive, and how you find out whether they did.

- Canonical: https://iambogdan.com/case-studies/ai-visibility
- Author: Bogdan Andronic (AI Engineer & Full-Stack TypeScript Developer)
- Source: generated from the data modules this page renders
- Records: 48
- Part of: A US travel company (Built for a client · anonymised)

## Measured

### page types reading the full metadata contract — it was 1 of 7

An audit found one detail route wired to the CMS metadata field group and six returning title and description only. Canonical URL, share image and the index flag were fields an editor could set and nothing consumed. All seven now go through one shared builder.

- value: 7 of 7
- kind: shipped

### URLs in a generated sitemap, each with typed structured data

Counted at build across itineraries, supplier profiles, FAQ answers, guides, team profiles, audience hubs and the static surfaces. Generated from the CMS with a static skeleton fallback, so an unreachable CMS cannot 500 the sitemap mid-launch.

- value: 163
- kind: counted

### AI crawler user-agents named individually, on top of the wildcard

GPTBot, ChatGPT-User, OAI-SearchBot, ClaudeBot, Claude-Web, PerplexityBot, Perplexity-User, Google-Extended, CCBot, cohere-ai, Applebot-Extended, Bytespider, DuckAssistBot, Diffbot, meta-externalagent, Amazonbot. Redundant against the wildcard by construction — a legible policy for crawler operators, not a functional unblock.

- value: 16
- kind: counted

### prompt-by-assistant citation grid, frozen so months stay comparable

Fifteen prompts in five intent clusters, run verbatim, never naming the brand, in a clean session with browsing forced on. Specified across five assistants. The grid is the instrument, not the result — one column is complete and there is no trend in it yet.

- value: 15 × 5
- kind: counted

### of buyer-intent prompts returned a link to the client's own pages

2 of 15 frozen prompts, one assistant with browsing on, one run per prompt, evidence captured July 2026. One engine of the five in the panel — the others are unrun or partial. Cells count distinct owned URLs, not brand mentions. A floor to climb from, not a result of the markup work.

- value: 13.3%
- kind: measured

### AI-assistant referrals per day, flat across the relaunch

Two matched Monday-to-Thursday windows either side of the June 2026 cutover, using per-assistant source detection. This is the honest size of the prize as measured today — and the reason the citation panel exists at all, because a click-through metric this small cannot detect anything.

- value: 1–6
- kind: measured

### distinct owned URLs surfaced across the whole baseline run

One from a route-planning prompt, three from a pricing prompt. All were deep pages — an FAQ answer and a cost guide — rather than the homepage, which is the signal the method says matters: it means the work landed on specific content, not on brand recognition.

- value: 4
- kind: measured

## The build order

### One origin, one entity

The canonical origin as a single environment variable, everything else derived from it, and a site-wide Organization plus WebSite graph with stable ids emitted as the first structured-data block on every page.

- why: Entity consistency is how a machine decides two pages belong to the same organisation. Stable ids let every other node reference that entity instead of redescribing it — and one origin constant means a domain cutover can't leave the site quietly asserting two competing identities.
- status: shipped
- n: 1

### Per-page metadata, everywhere

One shared builder returning the full metadata shape — canonical, robots, OpenGraph, Twitter — wired into all seven detail types rather than the one somebody got right first.

- why: Canonical and noindex are how you stop an assistant learning the wrong URL for a fact. Half-wired, they silently do nothing, and the CMS fields behind them look functional while consuming editorial trust.
- status: shipped
- n: 2

### A typed node per content type

One structured-data generator per document type, each emitting a single script with a graph rather than several competing ones. The itinerary pages — the crown jewel — had none at all before this.

- why: A site-wide blob tells a consumer that a page exists. A typed node tells it what the page is and which fields hold the answer. That's the difference between 'a page about X' and an entity with extractable properties.
- status: shipped
- n: 3

### Index pages as lists

CollectionPage plus ItemList on every index, capped to the top entries with the true count declared and an explicit ordering.

- why: Without it an index is unstructured cards. With it, an assistant answering "which X does this company recommend" can reproduce a ranked list and attribute it. Ambiguous ordering makes a reproduced list wrong in a way nobody catches.
- status: shipped
- n: 4

### Sitemap and a named crawler policy

A CMS-generated sitemap with per-entry last-modified dates, and a robots file naming sixteen AI crawlers explicitly alongside the wildcard.

- why: Sitemaps are how crawlers find pages without waiting to follow links, and the last-modified dates feed the freshness signal assistants weight. The named allowlist is a statement of intent to operators who increasingly read silence as ambiguous.
- status: shipped
- n: 5

### llms.txt

A plain-text root file, around 12KB, describing what the site is, where every machine-readable endpoint lives, and one summary block per significant page — capped so it stays ingestible in a single fetch.

- why: Fetched directly by several assistants when they meet a domain. It's the one artefact that says what a site is FOR before a model starts inferring it from page titles.
- status: shipped
- n: 6

### Endpoints that publish judgement

Three JSON endpoints under a versioned envelope with a generated timestamp, permissive CORS and a long shared cache — carrying the parts a competitor can't regenerate: a four-level supplier trust tier with strengths and weaknesses, and per-stop transfer-risk disclosure on itineraries.

- why: A model can already assemble a list. It cannot synthesise dated, opinionated judgement from ten other sources. CORS matters more than it looks — without it, browser-based LLM tooling and MCP servers simply cannot read these at all.
- status: shipped
- n: 7

### Freshness as a field

Per-answer last-updated dates mapped onto the structured data, and dated claims that render an "awaiting refresh" badge once they pass ninety days.

- why: Recency is one of the few properties a retrieval layer can check cheaply, and an undated claim is hard to cite safely. Surfacing staleness in the UI is what makes the refresh actually happen.
- status: shipped
- n: 8

### Rich-result surfaces on guides

FAQ and step-guide emitters, with the step emitter gated on a minimum heading count in code because the format is rejected below three.

- why: Both are rich-result eligible and both are what an assistant reaches for on a direct question. The gate exists so the threshold is enforced by the build rather than by somebody remembering it.
- status: awaiting
- n: 9

### Launch hooks and crawler verification

Search-console verification, sitemap submission, an IndexNow ping on publish, a structured-data validator in CI, and log analysis confirming AI crawlers actually fetch the endpoints.

- why: The half that tells you whether the other nine worked. IndexNow pushes new URLs to Bing, Yandex and Perplexity in minutes rather than waiting to be crawled — and the log check is the only thing that can prove a crawler ever arrived.
- status: pending
- n: 10

## Schema by content type

### The crown jewel — a priced, multi-stop itinerary

Every stop is a Place carrying its IATA code, so a question naming two specific cities is answerable from structure instead of by parsing prose. This page type had zero structured data before the audit.

- emits: TravelAction + Article + ItemList

### A dated factual claim inside any document

The assertion, the date it was true, and the source — rendered visibly AND emitted as a node. The highest-value emission on the site: dated and sourced beats confident and anonymous.

- emits: Claim

### FAQ answer

Rich-result eligible, and the shape an assistant reaches for on a direct question. Each answer carries its own last-updated date.

- emits: FAQPage (QAPage when it's a single question)

### Long-form guide

The threshold is enforced in code — the format is rejected below three steps, so emission is gated on the heading count.

- emits: Article, plus HowTo at three or more steps

### Any index page

With an explicit order. Ambiguity here produces a confidently mis-ranked list.

- emits: CollectionPage + ItemList

### Supplier profile

Specificity is free and it disambiguates. A generic Organization competes with every other organisation on the internet.

- emits: The most specific Organization subtype available

### Author

Anonymous content is close to invisible to citation graphs. The expertise array is the machine-readable form of why this person is worth quoting.

- emits: Person with knowsAbout[]

### Every detail page

Tells a machine where a fact sits in the site's structure. The trailing leaf drops its item — Schema.org says don't self-link.

- emits: BreadcrumbList

## Measurement protocol

### Browsing on, always

With retrieval off you're testing the model's training memory, which predates the work. You'd measure near-zero no matter how good the implementation is, and the result would be meaningless.

### Memory, personalisation and custom instructions off

Otherwise you're measuring your own browsing history. Temporary-chat modes exist for exactly this, and using one is the difference between a test and a flattering anecdote.

### One prompt, one fresh session

Ask the fifteen as follow-ups in a single thread and each answer contaminates the next. This is the mistake that makes most informal AEO testing worthless.

### Verbatim, and never hint the brand

The whole test is whether the site surfaces unprompted. Naming it in the question converts a measurement into a lookup.

### Stamp date, engine, mode and rough location

Several assistants ground differently by region and change without notice. An unlabelled result can't be compared to next month's.

### Count distinct owned URLs, and keep the URLs

The count is the score; the URLs are the evidence. A deep page is a much stronger signal than the homepage. A brand mention with no link is recorded separately, because it's a different outcome.

## Projects

### AI-visibility layer (AEO / GEO)

Ten layers shipped in dependency order across 163 URLs: one canonical origin and a site-wide entity graph with stable @ids, a shared metadata builder wired into all seven detail types (it was one of seven), a Schema.org generator per content type, CollectionPage + ItemList on every index, a CMS-generated sitemap, llms.txt, three JSON endpoints with permissive CORS, per-segment OG cards and Atom feeds. The endpoints deliberately publish the opinionated fields — supplier trust tiers, per-stop transfer-risk disclosure — on the reasoning that a model can already assemble a catalogue but cannot synthesise dated judgement. Measured by a frozen 15-prompt panel run across five assistants under a controlled protocol.

- kind: Flagship · production
- summary: Making a content site legible to the assistants people now ask instead of searching — typed structured data on every page type, machine-readable endpoints, a named AI-crawler policy, and a frozen prompt panel that measures whether any of it worked.
- stack: Next.js 16 · Schema.org / JSON-LD · llms.txt · Sanity · Technical SEO · IndexNow
- status: live

## The hard parts

### AEO is not a file you add at the root

The entire public conversation reduces to "add llms.txt". It's a real convention and it's roughly a tenth of the work. A site that adds it and nothing else has published a table of contents for a book with no pages.

- fix: Build it as four dependent layers. Content rendered as clean HTML, because assistants read the visible page at inference time. The same facts mirrored as typed nodes, so a machine can resolve who is claiming what. Machine-readable endpoints — the JSON catalogue, the sitemap, the feeds, llms.txt — so there's something efficient to fetch. And freshness, authorship and provenance attached at every level, so a claim has a date and a name behind it.
- why: Skip a layer and the system rots in a specific, predictable way. Structured data with no visible content doesn't get cited, because assistants read the rendered DOM and treat buried markup as a supporting signal rather than a source. A JSON catalogue nothing points at never gets discovered. A dated claim with no author is a rumour. It isn't a checklist, it's a dependency chain — which is why the build order matters more than the individual items.

### The most important page type had no structured data at all

The audit found correct markup on secondary pages and none whatsoever on the crown jewel — the priced multi-stop itinerary, the most differentiated thing the client publishes and the closest to revenue. An assistant asked for a route through two specific regions had to read English prose and guess.

- fix: Build the crown jewel's graph first, not last: the itinerary as a bookable action, an Article wrapper carrying author and date and price, and a list of stops where each is a place with its airport code. Then work outward to the secondary types.
- why: It isn't a one-off oversight, it's a pattern with a cause: the most important content type is the one most likely to be missed, because everyone assumes someone senior already handled it. The defence is to survey what every page type actually emits before writing a line of new markup, and to start from the page closest to revenue rather than the one easiest to mark up.

### Publishing the judgement instead of protecting it

The endpoints could have published a safe catalogue — names, prices, destinations. The genuinely valuable fields are the opinionated ones: a four-level trust tier per supplier with strengths and weaknesses, and a per-stop transfer-risk disclosure that tells a traveller where a connection is likely to go wrong. That's consultant judgement built over decades, and the instinct is to keep it behind the conversation.

- fix: Publish it, in typed fields, in public — as the deliberate centrepiece of the machine-readable layer rather than an afterthought.
- why: A model can already assemble a list of suppliers and prices from ten sources; publishing yours adds nothing and gets you averaged into a paragraph. What it cannot synthesise is dated, opinionated judgement attributable to someone. Withholding the judgement protects an asset that stops being an asset the moment the answer arrives without you in it — the risk of being copied is smaller than the risk of being irrelevant to the answer.

### Automating the citation test would have measured a different thing

Sixty to seventy-five manual runs a month is exactly the toil an engineer wants to automate, and the automation is easy — a cron, SDKs already in the codebase, a table to write to. It was specified and then deliberately not built.

- fix: Write down why, and defer it as a second metric rather than a replacement. The consumer products do live retrieval against their own search indexes; the plain APIs mostly don't browse and hit different infrastructure. An automated sweep would produce a real, trendable number that doesn't reproduce the manual one and can't be compared to it.
- why: The failure would have been invisible and permanent: you automate, the numbers move, and you spend a year interpreting a trend in a metric that isn't the one you thought you were tracking. Two incomparable series both labelled "citation rate" is worse than one series that costs ninety minutes a month.

### A framework convention silently overrode the editor's choice

Generated social cards shipped for every page. So did a CMS field letting an editor upload a custom card per document. Both worked. The framework resolves file-based metadata ahead of programmatic metadata, so the generated card always won — the editorial upload survived only in the structured data, invisible to every social platform. Nothing errored, and the field looked functional in the CMS.

- fix: Name it as a known issue with its real impact, write out the three options with their trade-offs, and recommend one — have each generated card check for an editorial image and defer to it — rather than quietly accepting the framework default because it had already shipped.
- why: The tempting move is to say nothing: it renders, it looks good, and the only person who notices is the one editor who uploads an image and can't work out why it never appeared. That's how a CMS field becomes decorative, and a decorative field is worse than a missing one because it spends trust every time someone uses it.

## What this does not claim

### Nothing here proves a crawler ever arrived

The biggest hole, so it goes first. The plan says to confirm from server logs that the AI crawlers actually fetch llms.txt and the JSON endpoints, with a thirty-day tripwire if they never do. That log analysis has not been run. Every claim on this page is about what was built and published — none of it is evidence that a machine came and read it.

### The citation rate hasn't gone up — there's one reading

The baseline was captured on one assistant a few weeks post-launch, which is early by design and not yet a trend. The honest claim is a method plus a starting number, not a lift. And it was meant to be captured BEFORE launch, which is the one piece of measurement design that got away from us: it slipped, so the "before" is really a slightly-after.

### Two emitters are live and have never fired

The FAQ and step-guide emitters are correct and deployed, and no published document has yet been tagged in the CMS to trigger them. Code shipped, output zero. That's a content-operations gap rather than an engineering one — and exactly the kind of thing a checklist would happily record as done.

### The launch hooks are specified, not built

Search-console verification, sitemap submission, the IndexNow ping and the CI validator are written up and none are deployed. They're on the page as a pending layer because leaving them out would misrepresent how complete the system is.

### Nobody can promise you a citation

There's no ranking API, no guaranteed placement, and the engines are non-deterministic and change without notice. What's buildable is the substrate — clean rendered content, a coherent entity graph, machine-readable endpoints, dated sourced claims — plus a measurement that tells you honestly whether it's working.

### This decays without content ownership

Freshness is weighted heavily, and stale dates don't merely stop helping — they cost citation weight. If nobody owns refreshing dated claims on a cadence, the whole investment rots. That's flagged as a decision to make before writing code, not a nice-to-have afterwards.

## Questions and answers

### Is AEO just SEO with a new name?

They overlap heavily, and pretending otherwise would be selling you the same work twice. The difference is the target: SEO ranks a link in a list of results, AEO tries to make your page the thing a model quotes when nobody sees a results page at all.

In practice it is the same work sequenced properly, for twice the surface — structured data that agrees with the visible text, endpoints worth fetching, and a date and an author on every claim.

### Can you guarantee an assistant will cite us?

No, and anyone who does is guessing. There is no ranking API, no guaranteed placement, and the engines are non-deterministic and change without notice — the same prompt can return different sources on two consecutive runs.

What can be guaranteed is that the surface is built correctly and that you will know whether it is working, which is more than most people in this market can offer.

### Is publishing an llms.txt file enough?

It is roughly a tenth of it. The file is a real convention and worth having, but a model that arrives at a page whose visible content does not say the same thing as its markup treats the markup as a liability rather than a source.

The layers that actually determine whether you can be quoted sit underneath: one canonical origin, an entity graph with stable identifiers, typed structured data per content type that matches the rendered page, and machine-readable endpoints carrying something a model could not have assembled on its own.

### How would we know whether any of it worked?

A frozen set of questions a well-informed assistant ought to answer using your site, run verbatim in clean sessions under a written protocol, scored the same way every month. Never naming the brand in the prompt, counting distinct owned URLs rather than brand mentions, and keeping the URLs as the evidence.

A click-through funnel cannot answer this on its own. Assistant referrals to the site in this write-up run at one to six a day and detection is split across two incompatible mechanisms, so the funnel is structurally a floor rather than a count.

### Did it work for the client in this write-up?

There is one reading, it is a floor, and it is not evidence the markup did anything — the baseline was captured slightly after launch rather than before, which is the one piece of measurement design that got away from us.

The largest hole is named on the page: nothing here proves a crawler ever arrived. Confirming from server logs that the assistants actually fetch the endpoints has not been run. Every claim on that page is about what was built and published, not about what a machine came and read.
