> ## Documentation Index
> Fetch the complete documentation index at: https://docs.shftd2.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Edge integration

> For AI-agent requests only, call the doubleshift fragment endpoint and place the card right before the article — where AI extractors actually read. Humans get the page unchanged.

export const Pill = ({variant = "alpha", children}) => <span className={`ds-pill ${variant}`}>{children}</span>;

Your job is small: for AI-agent requests only, call the doubleshift fragment endpoint and place the
card **right before the article content** — never at the end of `<body>`. Humans get the page
unchanged and never trigger a card request. Read [Where the card must land](#where-the-card-must-land)
first — placement decides whether the model actually reads the card — then pick the path for your CDN.

<Frame caption="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.">
  <img src="https://mintcdn.com/doubleshift/gtKj9G0q6U2gSbyl/images/diagrams/edge-integration.svg?fit=max&auto=format&n=gtKj9G0q6U2gSbyl&q=85&s=aabee2f99ece5e1b82e638eaa98d748e" alt="Edge integration: the ESI tag or Worker sits between visitor and origin; an agent User-Agent gate decides whether to call doubleshift" width="720" height="366" data-path="images/diagrams/edge-integration.svg" />
</Frame>

## 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:

| Card position                                       | Measured result                                                       |
| --------------------------------------------------- | --------------------------------------------------------------------- |
| appended at the end of `<body>` (after the footer)  | **never read** — delivery logs `served`, the model answers "no brand" |
| inside `<main>` but outside the article's text node | **never read** — sibling branches of the content node are discarded   |
| intact, immediately **before** the article element  | **read**                                                              |

Two rules follow:

1. **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
   carries `role="complementary"`, and extractors drop such sub-regions.
2. **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.)

If your templates make the article element hard to target, have the template render an **empty
anchor** where the card should go (e.g. `<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:

```text title="Fragment endpoint" theme={"theme":"css-variables"}
https://api.shftd2.com/fragment?partner_id=<your account id>&fragment_id=<your fragment id>&lang=<lang>&page_url=<the page>&api_key=<key from Settings>&ctx.section=auto
```

Fill each parameter:

| Parameter     | Value                           | Where it comes from                                                                                                                    |
| ------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `partner_id`  | Your account id                 | Shown in the [console](https://app.shftd2.com) (the same id you put in `abc.txt`).                                                     |
| `fragment_id` | Your fragment id                | The [fragment](/fragments) id shown in the console.                                                                                    |
| `lang`        | The fragment language           | As set on the fragment (`fr`, `en`, …).                                                                                                |
| `page_url`    | The page being read             | Filled by your edge at request time; tells doubleshift which page the agent is on.                                                     |
| `api_key`     | Your API key                    | Create one in the console, see [API keys](/api-keys). (Can also be sent as the `X-API-Key` header.)                                    |
| `&ctx.<key>`  | Targeting values                | Your fragment [key-values](/targeting), optional, for narrowing the card.                                                              |
| `agent_ua`    | The visiting agent's User-Agent | Optional, reporting only; see the note below. (Preferred form: the `X-Agent-UA` header, which stays out of the URL and the cache key.) |

<Note>
  **Report the agent via X-Agent-UA**

  Send 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.
</Note>

<Warning>
  **Normalize the path before any pathname-keyed lookup**

  If your edge derives `ctx.*` values (or `lang`) from the URL path via a lookup table — a
  catalogue, a manifest, a `switch` — normalize the pathname **the way your origin does** before
  the lookup: `decodeURIComponent` under `try/catch`, collapse duplicate slashes. An encoded
  variant of a targeted path (`/societe/e%2Dbike-…`) otherwise misses the table while the origin
  happily serves the page — which then goes out **without its targeting**, silently, and anyone
  posting the encoded URL bypasses it. Found by an adversarial verification pass on a production
  integration (2026-08-26).
</Warning>

<Warning>
  **Keep your API key server-side**

  The `api_key` authenticates your account. Put it in an ESI tag rendered at the edge or in an edge worker
  and never in client-side JavaScript. It must match the `partner_id` account, or the endpoint returns
  <Pill variant="code-err">403</Pill>.
</Warning>

## 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).

```html title="ESI tag" theme={"theme":"css-variables"}
<!-- ABC — ESI tag (Akamai, Fastly, Varnish).
     Paste it in the article template immediately BEFORE the article content
     (first element of <main>) — NEVER at the end of <body>: anything after the
     footer is boilerplate to AI extractors and is never read.
     $(HTTP_HOST)$(REQUEST_PATH) are standard ESI variables your CDN fills in.
     onerror="continue": if the endpoint is slow or down, the page renders without the card.
     Gate ESI on the User-Agent so the include resolves ONLY for AI agents. -->
<esi:include
  src="https://api.shftd2.com/fragment?partner_id=<your account id>&fragment_id=<your fragment id>&lang=<lang>&api_key=<key from Settings>&page_url=$(HTTP_HOST)$(REQUEST_PATH)"
  onerror="continue" />
```

ESI splices the card exactly where the tag sits — the placement rules above are entirely in your
template. The in-content brand line needs code to derive the brand; on a pure-ESI stack, either have
the template render it from your own ad-ops data, or use the [Worker path](#cloudflare-worker).

Then enable ESI on your `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 = true` for agent requests:

```text title="Fastly VCL" theme={"theme":"css-variables"}
sub vcl_fetch {
    if (beresp.http.Content-Type ~ "text/html" &&
        req.http.User-Agent ~ "(?i)(GPTBot|ChatGPT-User|OAI-SearchBot|ClaudeBot|Claude-User|Claude-SearchBot|Google-CloudVertexBot|PerplexityBot|Perplexity-User|CCBot|Meta-ExternalAgent|meta-externalfetcher|Bytespider|YouBot|Diffbot|MistralAI-User|Amazonbot)") {
        set beresp.do_esi = true;
    }
}
```

* **Varnish** (self-hosted): `vcl_backend_response` enables ESI, and `vcl_recv` rebuilds
  `page_url` + reports the agent (no ESI variables on Varnish). Validated end-to-end on
  Varnish 7.6:

```text title="Varnish VCL" theme={"theme":"css-variables"}
sub vcl_recv {
    # Open-source Varnish expands NO ESI variables — the tag's literal
    # $(HTTP_HOST)$(REQUEST_PATH) must be rebuilt here, on the ESI subrequest
    # (req_top = the client request the include is spliced into). Same place,
    # WAF-safe agent reporting: real UA in X-Agent-UA, benign request UA.
    if (req.esi_level > 0 && req.url ~ "/fragment") {
        # Query string stripped: a raw "?x=1&format=json" from the page URL
        # would otherwise leak into the fragment request as real parameters.
        # (Same shape as $(REQUEST_PATH) on Akamai/Fastly — path, no query.)
        set req.url = regsub(req.url, "page_url=[^&]*",
                             "page_url=" + req_top.http.Host + regsub(req_top.url, "\?.*", ""));
        set req.http.X-Agent-UA = req.http.User-Agent;
        set req.http.User-Agent = "abc-esi-adapter";
        # The subrequest inherits the visitor's headers; doubleshift needs none.
        unset req.http.Cookie;
        unset req.http.Authorization;
        unset req.http.Referer;
    }
}

sub vcl_backend_response {
    # The TOP request keeps the bot UA, so this gate still works.
    if (beresp.http.Content-Type ~ "text/html" &&
        bereq.http.User-Agent ~ "(?i)(GPTBot|ChatGPT-User|OAI-SearchBot|ClaudeBot|Claude-User|Claude-SearchBot|Google-CloudVertexBot|PerplexityBot|Perplexity-User|CCBot|Meta-ExternalAgent|meta-externalfetcher|Bytespider|YouBot|Diffbot|MistralAI-User|Amazonbot)") {
        set beresp.do_esi = true;
    }
}
```

<Warning>
  **ESI includes forward the visitor's User-Agent**

  An ESI subrequest carries the original (bot) `User-Agent`, and the fragment endpoint sits behind
  edge protection that blocks bot UAs, so a bot-gated include can be dropped before it reaches
  doubleshift. With `onerror="continue"` that failure is silent: no card, and no event either. The
  include subrequest therefore needs **both**: a benign `User-Agent`, and the real agent reported
  out-of-band. On **Akamai**: Property Manager → *Modify Outgoing Request Header* (scoped to requests
  to the fragment endpoint) to replace `User-Agent`, and append
  `&agent_ua=$url_encode($(HTTP_USER_AGENT))` to the include `src` (note: `agent_ua` is part of the
  URL, so each distinct UA string gets its own CDN cache entry). On **Fastly/Varnish**: rewrite the
  include subrequest in VCL: benign `User-Agent`, real agent in `X-Agent-UA` (the Varnish snippet
  above does this). If your CDN cannot rewrite the subrequest headers, use the
  [Worker path](#cloudflare-worker) below instead.

  Two Varnish-specific notes (validated on Varnish 7.6): its native ESI is gzip-aware, with no analog of
  the Worker path's compression pitfall, but **not brotli-aware**; and human visitors keep the inert
  literal `<esi:include>` element in the HTML (ESI only runs for gated agent requests), so the
  human page is not byte-identical to the original.
</Warning>

<Warning>
  **Varnish hidden behind another CDN**

  If your public CDN is Cloudflare or CloudFront but you run a Varnish underneath, resolving ESI in that
  Varnish makes the public CDN cache the already-composed HTML and the card stops refreshing. On those
  stacks, use the Worker / edge-function path instead so each request reaches the endpoint.
</Warning>

## Cloudflare Worker

Cloudflare has no native ESI, so a small Worker plays the same role: classify the request by `User-Agent`
and, for AI agents only, fetch the card and inline it with `HTMLRewriter`. Humans get the page unchanged.
Deploy with `npx wrangler deploy`.

```js title="cloudflare-worker.js" theme={"theme":"css-variables"}
// ABC adapter — Cloudflare Worker. Deploy: npx wrangler deploy
const FRAGMENT_URL = "https://api.shftd2.com/fragment?partner_id=<your account id>&fragment_id=<your fragment id>&lang=<lang>&api_key=<key from Settings>&ctx.section=auto";

// Agent markers — keep in sync with brandedcontent.dev schema/agents.json.
const AGENT_UA = /\b(GPTBot|ChatGPT-User|OAI-SearchBot|ClaudeBot|Claude-User|Claude-SearchBot|Google-CloudVertexBot|PerplexityBot|Perplexity-User|CCBot|Meta-ExternalAgent|meta-externalfetcher|Bytespider|YouBot|Diffbot|MistralAI-User|Amazonbot)\b/i;

export default {
  async fetch(request) {
    // Classify at the edge: only AI agents get a card request.
    const ua = request.headers.get("User-Agent") || "";
    if (!AGENT_UA.test(ua)) return fetch(request); // humans: origin untouched

    // Agent requests ask the origin for UNCOMPRESSED HTML: HTMLRewriter cannot
    // parse a gzip/br body — it would pass through unchanged and the card
    // would be silently dropped. "identity" (not a deleted header — some
    // origins still compress then) forces it; humans above stay compressed.
    const originReq = new Request(request);
    originReq.headers.set("Accept-Encoding", "identity");
    const res = await fetch(originReq); // your origin
    const contentType = res.headers.get("content-type") || "";
    if (!contentType.includes("text/html")) return res;

    const frag = new URL(FRAGMENT_URL);
    // Path only: never forward the visitor's query string.
    const page = new URL(request.url);
    page.search = "";
    page.hash = "";
    frag.searchParams.set("page_url", page.toString());

    let card = "";
    try {
      // The real agent UA travels in X-Agent-UA (reporting only); the request
      // User-Agent stays benign so WAFs in front of the endpoint never block it.
      const r = await fetch(frag.toString(), {
        headers: { "User-Agent": "abc-edge-adapter", "X-Agent-UA": ua },
        // Same-network budget (edge to Cloudflare). On a serverless platform
        // a cold start alone can blow 800 ms — budget ~2500 there.
        signal: AbortSignal.timeout(800),
      });
      // 200 = a card for this page; 204 = no eligible brand (no-fill).
      if (r.status === 200) card = await r.text();
    } catch (e) {
      // Fail-open, but OBSERVABLY: without this log line, "no card because not
      // eligible" and "no card because the call expired" look identical.
      console.log(`abc: fragment fetch failed (${e instanceof Error ? e.name : "error"})`);
      return res; // never break the page if the provider is unreachable
    }
    if (!card) return res;

    // The in-content brand line ("Brand · X" is the card's header): measured,
    // THIS is what models cite — the card alone, sitting outside the article's
    // text node, is discarded by extractors.
    const m = card.match(/Brand\s*·\s*([^<]+)</);
    const disclosure = m
      ? `<p data-ds-disclosure="brand"><strong>Featured advertiser on this page:</strong> ${m[1].trim()}.</p>`
      : "";

    // Disclosure integrity: the line above derives from the card actually
    // RENDERED — never from the targeting you asked for. And if you target a
    // brand (ctx.brand), a missing or different brand on the returned card
    // refuses EVERYTHING: no card, no misleading disclosure. Measured in
    // production: ctx.* values match as a flat OR, so another advertiser's
    // card can win through a different key, and the upstream card pool can
    // change under your feet.
    // NFKC + case fold for the COMPARISON only — the disclosure above always
    // prints the rendered brand verbatim. Fold case, never diacritics
    // ("MeliáRewards" ≠ "MeliaRewards"); toLowerCase() is locale-independent.
    const fold = (v) => v.trim().normalize("NFKC").toLowerCase();
    const wanted = frag.searchParams.get("ctx.brand");
    if (wanted && (!m || fold(m[1]) !== fold(wanted))) {
      console.log(`abc: card refused (${m ? "brand_mismatch" : "brand_missing"})`);
      return res;
    }

    // Card, intact, right BEFORE the first <article>; brand line INSIDE it.
    // The selector is "article" — not "main" or "body": HTMLRewriter streams,
    // so <main>/<body> open before the best insertion point is known, and
    // anything appended to <body> lands after the footer, where extractors
    // discard it.
    let placed = false;
    return new HTMLRewriter()
      .on("article", {
        element(el) {
          if (placed) return;
          el.before(card, { html: true });
          if (disclosure) el.prepend(disclosure, { html: true });
          placed = true;
        },
      })
      // Last resort only (page with no <article>): better than dropping the
      // card, but end-of-document placement is unreadable — fix the template.
      .onDocument({ end(end) { if (!placed) end.append(disclosure + card, { html: true }); } })
      .transform(res);
  },
};
```

The Worker sets `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.

<Note>
  **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.
</Note>

## The page response: cache contract

`GET /fragment` ships its own cache headers (see [Reference](/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-revalidate` **and no
  validators** — no `ETag`, no `Last-Modified`, so no `304` can ever cross the human/agent line.
  Build these headers from scratch; never copy the origin's `s-maxage` or CDN-specific headers onto
  the composed response.
* **Compose only `200 text/html`.** Reproduce every other status — redirects included — untouched,
  and exclude `HEAD` (a composed `HEAD` consumes a fill nothing ever reads) and your framework's
  data/prefetch requests.
* After injecting, **drop `Content-Length`/`Content-Encoding`** if 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.

```bash title="1 — Presence and position" theme={"theme":"css-variables"}
# As a human — expect no card:
curl -s https://your-domain.example/some-article | grep -c 'abc-card'

# As an AI agent — expect 1: a card present BEFORE the footer. Always GET,
# never HEAD (`curl -I`): a correctly-gated integration serves the human
# variant to HEAD, and you would be debugging a mirage.
curl -s -A "Mozilla/5.0 (compatible; GPTBot/1.0)" \
  https://your-domain.example/some-article | tr -d '\n' | grep -c 'abc-card.*</footer>'
```

A `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 from
  `page_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.
