<body>. Humans get the page
unchanged and never trigger a card request. Read Where the card must land
first — placement decides whether the model actually reads the card — then pick the path for your CDN.
The ESI tag / Worker sits between visitor and origin. Only an AI-agent User-Agent opens the gate to GET /fragment; humans pass straight through.
Where the card must land
AI agents do not read your page — they read what a content extractor (think Readability) keeps of it: the single densest text node, everything else discarded as boilerplate. Placement therefore decides everything. Measured on two independent stacks:
Two rules follow:
- The card, intact, goes right before the article element — the first autonomous block of
<main>, never after the footer. Do not nest it inside the editorial node either: the card carriesrole="complementary", and extractors drop such sub-regions. - Put a short plain-text brand line inside the article content itself — e.g.
<p><strong>Featured advertiser on this page:</strong> <brand>.</p>as the article’s first element, derived from the card you just fetched and served only when a card is served. Measured on both stacks: this line is what models actually cite, and it doubles as the ad disclosure your editorial team wants anyway. (The Worker snippet below derives it.)
<div class="ds-anchor"></div> immediately before the
article body) and inject on it — an anchor beats guessing someone else’s DOM. Place it so the brand
line lands inside the extracted content, never after it.
The fragment endpoint URL
The base is:Fragment endpoint
Report the agent via X-Agent-UASend the visiting agent’s User-Agent in the
X-Agent-UA header (or the agent_ua query
parameter) and keep a benign User-Agent on the request itself, because WAFs commonly block bot
User-Agents outright, and the endpoint’s own edge protection is no exception. The value only feeds
per-agent reporting; the card never depends on it. Prefer the header: it stays out of the cache key,
while agent_ua is part of the URL and gives every distinct UA string its own CDN cache entry.
Requests that send neither fall back to the plain User-Agent header.ESI: Akamai · Fastly · Varnish
The simplest path: one tag in your page template, resolved by your CDN’s native Edge Side Includes. No code to deploy.$(HTTP_HOST)$(REQUEST_PATH) are standard ESI variables that Akamai and Fastly fill
in to build page_url. Open-source Varnish expands no ESI variables, so on Varnish page_url
must be rebuilt in VCL (snippet below).
ESI tag
text/html responses, gated on the AI-agent User-Agent so the include
resolves only for agents:
- Akamai: Property Manager → add a match on
User-Agent(the agent list) → behavior Edge Side Includes → Enable. - Fastly: set
beresp.do_esi = truefor agent requests:
Fastly VCL
- Varnish (self-hosted):
vcl_backend_responseenables ESI, andvcl_recvrebuildspage_url+ reports the agent (no ESI variables on Varnish). Validated end-to-end on Varnish 7.6:
Varnish VCL
Cloudflare Worker
Cloudflare has no native ESI, so a small Worker plays the same role: classify the request byUser-Agent
and, for AI agents only, fetch the card and inline it with HTMLRewriter. Humans get the page unchanged.
Deploy with npx wrangler deploy.
cloudflare-worker.js
page_url from the live request, so you do not bake it into FRAGMENT_URL. Replace
<your account id>, <your fragment id>, <lang> and the API key with your real values.
Strict or case-insensitive brand match? The snippet folds case (never diacritics) because a
hand-entered
ctx.brand legitimately differs in case from the card’s brand_surface. If your
ctx.brand values are generated from the same source of truth as the cards, prefer strict
equality (after NFKC): any difference then signals drift between your manifest and the account,
and you want it to fail loudly.The page response: cache contract
GET /fragment ships its own cache headers (see Reference) — that covers the
card. The response of your page to the agent is yours, and it is where human/agent isolation
lives. The contract, established across CDN and serverless stacks:
- Never let the canonical (human) render vary by visitor. The cached human page must not read
the User-Agent nor call
/fragment; classification runs in your edge code, on every request, in front of the cache — that execution, not a cache header, is what guarantees isolation. - Serve the agent response with
Cache-Control: public, max-age=0, must-revalidateand no validators — noETag, noLast-Modified, so no304can ever cross the human/agent line. Build these headers from scratch; never copy the origin’ss-maxageor CDN-specific headers onto the composed response. - Compose only
200 text/html. Reproduce every other status — redirects included — untouched, and excludeHEAD(a composedHEADconsumes a fill nothing ever reads) and your framework’s data/prefetch requests. - After injecting, drop
Content-Length/Content-Encodingif your path buffers the body (HTMLRewriter and native ESI handle this for you). - Verify in both orders — human→agent→human and agent→human→agent, cold cache and warm — by counting card markers in the raw HTML.
Timeout budget
The reference snippets budget 800 ms for the fragment call — a same-network figure (CDN edge to Cloudflare). On a serverless platform (Vercel, Netlify functions, …) a cold start alone can blow that budget: use ~2500 ms there. And because the integration fails open (the page always renders), an expired call is invisible by default — log it distinctly, so “no card: not eligible” and “no card: the call timed out” stop looking identical.Verify
Presence first — then readability, which presence does not prove.1 — Presence and position
0 then a 1 means humans are untouched and the card sits before the footer. That still only
proves the card is in the HTML — not that a model reads it. The proof is a probe:
Ask an AI assistant to consult/open https://your-domain.example/some-article?r=57391 and say
which brands the page features. (Phrase it as “open the page”; a bare “read the page” can drift
into web search instead of fetching your URL.)
Three rules make the probe meaningful:
- A fresh URL every time (
?r=<random>): models cache what they have fetched — a probe on an already-seen URL measures the model’s memory, not your site. The query string is stripped frompage_url, so card selection is unchanged. - A neutral article: probing a page whose editorial text already names the brand proves nothing.
- The model citing your in-content brand line is the pass signal — that line, not the card markup, is what models quote back.
