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

# Install and Configure the Bluesky Next.js Edge Middleware

> Step-by-step guide to installing and configuring the Bluesky AEN middleware.ts in your Next.js project on Vercel Edge.

The Bluesky Agent Edge Network (AEN) installs as a Next.js middleware — either the
[`@trybluesky/aen`](https://www.npmjs.com/package/@trybluesky/aen) npm package (two lines) or a
single self-contained `middleware.ts` file you paste in. Either way it runs on Vercel Edge and
handles agent detection, serving, and telemetry. This page covers both, every configuration knob,
and what happens at runtime.

<Note>
  This is the shipped v0 form factor — an edge middleware you install in your own app, not a
  reverse proxy in front of your site. It verifies answer-engine crawlers by published IP ranges.
  A Cloudflare Worker adapter (see [Cloudflare Worker](/guides/cloudflare-worker)) and a zero-code
  reverse-proxy are also available / on the roadmap.
</Note>

## Prerequisites

* A Next.js project deployed on Vercel (or running locally with `next dev`)
* An edge token from **Agent Analytics → Settings** in the Bluesky app (format `aen_...`),
  provisioned per site and revocable

## Install

<Tabs>
  <Tab title="npm package (recommended)">
    <Steps>
      <Step title="Install the package">
        ```bash theme={null}
        npm i @trybluesky/aen
        npx @trybluesky/aen init
        ```

        `init` creates `middleware.ts` (or `proxy.ts` on Next.js 16+) wired to the package — a
        two-line file. The real logic lives in the package, so you get updates without re-pasting code.
      </Step>

      <Step title="Add your edge token">
        In Vercel → your project → **Settings → Environment Variables**, add `AEN_EDGE_TOKEN` with
        the key from **Agent Analytics → Settings**. For local dev, add it to `.env.local`.
      </Step>

      <Step title="Deploy, then go live">
        Deploy. It starts in **shadow** mode (logs only, serves your normal page). Watch for the
        first verified hit in **Agent Analytics**, then set `AEN_SERVE_MODE=default` in your env and
        redeploy to start serving. See [Serve modes](/guides/serve-modes).
      </Step>
    </Steps>
  </Tab>

  <Tab title="Copy-paste (no dependency)">
    <Steps>
      <Step title="Create the middleware file">
        Create `middleware.ts` at the **root of your Next.js project** (same level as `package.json`,
        not inside `src/` or `app/`) and paste the complete file from
        [The complete middleware.ts](#the-complete-middlewarets) below.
      </Step>

      <Step title="Add your edge token">
        Add `AEN_EDGE_TOKEN` (from **Agent Analytics → Settings**) in Vercel →
        **Settings → Environment Variables**, and to `.env.local` for local dev.
      </Step>

      <Step title="Deploy, then go live">
        Deploy — it ships in `shadow` mode. Once you see verified hits, change `SERVE_MODE` to
        `"default"` in the file and redeploy. See [Serve modes](/guides/serve-modes).
      </Step>
    </Steps>

    <Warning>
      Never commit the token. The middleware reads it from the environment; if `AEN_EDGE_TOKEN`
      is empty it passes every request straight through, as if the file weren't there.
    </Warning>
  </Tab>
</Tabs>

## Existing middleware

Already running a middleware — next-intl, auth, redirects? **Don't replace it.** The package
exports a composable `handle()` that returns a `Response` when it served a verified AI crawler,
or `null` to fall through to your own code. Add two lines at the top of your middleware:

```ts middleware.ts (next-intl example) theme={null} theme={null}
import createMiddleware from "next-intl/middleware";
import type { NextRequest } from "next/server";
import { locales, defaultLocale } from "./i18n";
import { handle } from "@trybluesky/aen";

const intl = createMiddleware({ locales, defaultLocale });

export default async function middleware(req: NextRequest) {
  const served = await handle(req);   // verified AI crawler → AEN serves the artifact
  if (served) return served;
  return intl(req);                   // everyone else → your normal handling
}

export const config = { matcher: [/* keep your existing matcher */] };
```

`handle()` fires telemetry in every mode and returns `null` in shadow mode, so your site behaves
exactly as before until you set `AEN_SERVE_MODE=default`. Running `npx @trybluesky/aen init` in a
project that already has a middleware prints this recipe automatically instead of overwriting your file.

## The complete `middleware.ts`

Paste this entire file at the root of your Next.js project, then set `AEN_EDGE_TOKEN` as an
environment variable (see the step above). This is the shipped v0 drop-in — zero dependencies
beyond `next/server`, running on Vercel Edge.

```ts middleware.ts theme={null} theme={null}
// middleware.ts — Bluesky Agent Edge Network (drop-in, self-contained, zero deps).
// Put this at the root of your Next.js app. Runs on Vercel Edge.
//
// Setup:
//   1. In Vercel → your project → Settings → Environment Variables, add:
//        AEN_EDGE_TOKEN = <the edge token from Agent Analytics → Connect>
//   2. Deploy. Start in "shadow" mode (SERVE_MODE below) = logs only, serves the
//      normal page to everyone. Flip to "default" once you've seen hits come in.
//   3. Ask ChatGPT/Perplexity (with browsing) something that makes it fetch your site.
//      Its verified crawler IP is served the AEO artifact; you see it in Agent Analytics.

import { NextRequest, NextResponse } from "next/server";

const AEN_BASE = "https://app.trybluesky.com";
const EDGE_TOKEN = process.env.AEN_EDGE_TOKEN ?? "";
const SERVE_MODE: "shadow" | "default" = "shadow"; // ← flip to "default" to actually serve
const ENABLED = new Set(["USER_TRIGGERED", "RETRIEVAL"]);

export const config = { matcher: ["/((?!_next/|api/|favicon.ico).*)"] };

// ---- tiny cached control-plane fetches --------------------------------------
type Bot = { uaToken: string; purpose: string; cidrs?: string[]; isSearchIndex?: boolean };
let botsetCache: { at: number; bots: Bot[] } | null = null;

async function getBots(): Promise<Bot[]> {
  if (botsetCache && Date.now() - botsetCache.at < 3_600_000) return botsetCache.bots;
  try {
    const r = await fetch(`${AEN_BASE}/api/v1/edge/botset`, { headers: auth() });
    const j = await r.json();
    const bots: Bot[] = j?.botset?.bots ?? [];
    botsetCache = { at: Date.now(), bots };
    return bots;
  } catch {
    return botsetCache?.bots ?? [];
  }
}

function auth() {
  return { authorization: `Bearer ${EDGE_TOKEN}` };
}

// ---- detection (UA + IP verify) ---------------------------------------------
function matchBot(ua: string, bots: Bot[]): Bot | null {
  const low = ua.toLowerCase();
  let best: Bot | null = null;
  for (const b of bots) {
    const t = b.uaToken.toLowerCase();
    if (new RegExp(`(?:^|[^a-z0-9])${escapeRe(t)}(?:[^a-z0-9]|$)`, "i").test(low)) {
      if (!best || b.uaToken.length > best.uaToken.length) best = b;
    }
  }
  return best;
}
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

function ipInCidrs(ip: string, cidrs: string[] = []): boolean {
  for (const c of cidrs) {
    try {
      const [net, bitsRaw] = c.split("/");
      const bits = Number(bitsRaw);
      if (ip.includes(":") || net.includes(":")) continue; // IPv4 only in this drop-in
      const a = ipToInt(ip), b = ipToInt(net);
      if (a === null || b === null) continue;
      const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
      if ((a & mask) === (b & mask)) return true;
    } catch { /* skip */ }
  }
  return false;
}
function ipToInt(ip: string): number | null {
  const p = ip.split(".");
  if (p.length !== 4) return null;
  let n = 0;
  for (const o of p) { const v = Number(o); if (!Number.isInteger(v) || v < 0 || v > 255) return null; n = (n << 8) | v; }
  return n >>> 0;
}

async function sha256hex(s: string): Promise<string> {
  const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
  return [...new Uint8Array(d)].map((x) => x.toString(16).padStart(2, "0")).join("");
}

// ---- middleware -------------------------------------------------------------
export async function middleware(req: NextRequest): Promise<NextResponse> {
  if (!EDGE_TOKEN) return NextResponse.next();

  const ua = req.headers.get("user-agent") ?? "";
  const ip = (req.headers.get("x-forwarded-for") ?? req.headers.get("x-real-ip") ?? "").split(",")[0].trim();
  const bots = await getBots();
  const bot = matchBot(ua, bots);
  if (!bot) return NextResponse.next();

  // CLOAKING FIREWALL: classic search bots (Googlebot/Bingbot/Applebot) → always the human page.
  if (bot.isSearchIndex) return NextResponse.next();

  const verified = ipInCidrs(ip, bot.cidrs);
  const eligible = verified && ENABLED.has(bot.purpose);

  const url = req.nextUrl.href;
  let served: "VARIANT" | "PASSTHROUGH" = "PASSTHROUGH";
  let resp = NextResponse.next();

  if (eligible && SERVE_MODE === "default") {
    try {
      const uh = await sha256hex(url);
      const ar = await fetch(`${AEN_BASE}/api/v1/edge/artifact?u=${uh}`, { headers: auth() });
      if (ar.ok) {
        const { artifact } = await ar.json();
        if (artifact?.markdown) {
          served = "VARIANT";
          resp = new NextResponse(artifact.markdown, {
            status: 200,
            headers: { "content-type": "text/markdown; charset=utf-8", "x-aen-served": "variant", "cache-control": "no-store" },
          });
        }
      }
    } catch { /* fall through to passthrough */ }
  }

  // fire-and-forget telemetry (never blocks the response)
  const hit = {
    botName: bot.uaToken, path: req.nextUrl.pathname, statusCode: 200, userAgent: ua, botIp: ip,
    trustLevel: verified ? "IP_VERIFIED" : "UA_ONLY", purposeClass: bot.purpose,
    verificationMethod: verified ? "cidr" : null,
    served: eligible ? (SERVE_MODE === "default" ? served : "PASSTHROUGH") : "PASSTHROUGH",
  };
  fetch(`${AEN_BASE}/api/v1/edge/telemetry`, {
    method: "POST", headers: { ...auth(), "content-type": "application/json" }, body: JSON.stringify({ hits: [hit] }),
  }).catch(() => {});

  return resp;
}
```

## Configuration constants

All configuration lives at the top of `middleware.ts`. You do not need to touch anything else
in the file.

```ts middleware.ts (configuration section) theme={null} theme={null}
const AEN_BASE = "https://app.trybluesky.com";
const EDGE_TOKEN = process.env.AEN_EDGE_TOKEN ?? "";
const SERVE_MODE: "shadow" | "default" = "shadow"; // ← flip to "default" to actually serve
const ENABLED = new Set(["USER_TRIGGERED", "RETRIEVAL"]);

export const config = { matcher: ["/((?!_next/|api/|favicon.ico).*)"] };
```

| Constant     | Type                    | Description                                                                                                                      |
| ------------ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `AEN_BASE`   | `string`                | Base URL of the Bluesky control plane (`https://app.trybluesky.com`). Do not change this.                                        |
| `EDGE_TOKEN` | `string`                | Read from `process.env.AEN_EDGE_TOKEN`. The middleware passes through all requests if this is empty.                             |
| `SERVE_MODE` | `"shadow" \| "default"` | `"shadow"` = log only, always serve the normal page. `"default"` = serve the variant to verified, eligible agents.               |
| `ENABLED`    | `Set<string>`           | Purpose classes eligible for serving. Defaults to `USER_TRIGGERED` (e.g. `ChatGPT-User`) and `RETRIEVAL` (e.g. `OAI-SearchBot`). |

<Note>
  This drop-in file exposes only `shadow` and `default`. The full AEN adapter additionally
  supports `canary:<pct>` (deterministic percentage ramp). See [Serve modes](/guides/serve-modes).
</Note>

### The config matcher

```ts theme={null} theme={null}
export const config = { matcher: ["/((?!_next/|api/|favicon.ico).*)"] };
```

This tells Next.js to run the middleware on **every public page route** while skipping:

* `/_next/` — Next.js build assets and hot-reload
* `/api/` — your API routes
* `/favicon.ico` — the favicon

You should not need to change the matcher. If you have additional paths that must never be
intercepted, add them to the negative lookahead.

## How the middleware works

You do not need to understand the implementation to use it, but knowing the runtime flow
helps you reason about edge cases and telemetry data.

<Steps>
  <Step title="Guard on missing token">
    If `EDGE_TOKEN` is empty, the middleware calls `NextResponse.next()` immediately and exits.
    Your site behaves exactly as if the middleware file were not there.
  </Step>

  <Step title="Extract UA and IP">
    The middleware reads `User-Agent` from request headers, then resolves the client IP from
    `x-forwarded-for` (first value) or `x-real-ip`.
  </Step>

  <Step title="Fetch the bot registry (cached)">
    It calls `GET /api/v1/edge/botset` with your edge token as a `Bearer` credential. The
    response is cached in-process for one hour. On cache miss or error, the previous successful
    response is reused as a fallback.
  </Step>

  <Step title="Match UA against known bots">
    The middleware looks for the bot whose `uaToken` matches the incoming `User-Agent`, with the
    longest matching token winning. If no bot matches, the request passes through.
  </Step>

  <Step title="Apply the cloaking firewall">
    If the matched bot has `isSearchIndex: true` (Googlebot, Bingbot, Applebot), the request
    passes through **unconditionally** — the variant is never served to a search-index crawler.
    See [Cloaking firewall](/guides/cloaking-firewall).
  </Step>

  <Step title="Verify the source IP">
    The middleware checks whether the client IP falls inside the bot's published CIDR list. If it
    does, the request is `IP_VERIFIED`. If not, it is `UA_ONLY` — unverified — and always receives
    your normal page. (This drop-in verifies IPv4 CIDRs only.)
  </Step>

  <Step title="Fetch and serve the artifact">
    If the request is verified, the bot's purpose is in `ENABLED`, and `SERVE_MODE` is `"default"`,
    the middleware hashes the full request URL with SHA-256 and calls
    `GET /api/v1/edge/artifact?u=<hash>`. If the response's `artifact` contains a `markdown` field,
    it is returned directly with `Content-Type: text/markdown` and header `x-aen-served: variant`.
    Otherwise the normal page is served.
  </Step>

  <Step title="Fire telemetry (non-blocking)">
    Regardless of what was served, one hit is posted to `POST /api/v1/edge/telemetry`. The call is
    fire-and-forget — errors are silently swallowed and the response never waits on it.
  </Step>
</Steps>

<Note>
  The artifact is generated offline by Bluesky and read from cache — there is no LLM call on the
  request path. A missing artifact simply degrades to passthrough; the middleware never fabricates
  a page.
</Note>

## Telemetry hit shape

Every matched request generates one telemetry hit, posted inside a `{ hits: [...] }` body:

```ts theme={null} theme={null}
{
  botName:            string;   // the matched uaToken, e.g. "OAI-SearchBot"
  path:               string;   // req.nextUrl.pathname
  statusCode:         number;   // 200 in this drop-in
  userAgent:          string;   // raw User-Agent header value
  botIp:              string;   // resolved client IP
  trustLevel:         "IP_VERIFIED" | "UA_ONLY";
  purposeClass:       string;   // e.g. "RETRIEVAL" or "USER_TRIGGERED"
  verificationMethod: "cidr" | null;
  served:             "VARIANT" | "PASSTHROUGH";
}
```

<Note>
  In `shadow` mode, `served` is always `"PASSTHROUGH"` even for eligible bots, because no artifact
  is fetched or returned. This lets you measure how many crawlers *would* be served before you
  commit to `"default"` mode.
</Note>

These fields feed the [Agent Analytics](/agent-analytics) breakdowns — hits grouped by
purpose class and by trust level, the total hit count, the number of served URLs, and the
share of served pages later cited.

## Verification levels

| Trust level   | What it means                                                                                           |
| ------------- | ------------------------------------------------------------------------------------------------------- |
| `IP_VERIFIED` | The source IP is inside the bot's published CIDR range. Eligible for serving.                           |
| `UA_ONLY`     | UA matches but IP is outside published ranges. Treated as unverified — always receives the normal page. |

<Note>
  The AEN detection model also defines `SIGNED` (Web Bot Auth — RFC 9421 HTTP Message Signatures
  over Ed25519) and `DNS_VERIFIED` tiers. Those are implemented in the AEN core and full adapter;
  this drop-in `middleware.ts` verifies by published IP ranges only.
</Note>

## Troubleshooting

<AccordionGroup>
  <Accordion title="No hits appear in Agent Analytics">
    1. Confirm the `middleware.ts` file is at the **project root**, not inside `src/` or `app/`.
    2. Confirm `AEN_EDGE_TOKEN` is set in your Vercel environment and you have **redeployed** after
       adding it.
    3. Make sure the crawler you are testing is one the bot registry recognises — unverified,
       UA-only requests are not shown as verified hits.
  </Accordion>

  <Accordion title="Middleware is running but the variant is never served">
    Check that `SERVE_MODE` is set to `"default"` (not `"shadow"`). Also confirm Bluesky has
    generated an artifact for the URL you are testing — you can check this in Agent Analytics.
  </Accordion>

  <Accordion title="I see hits with served: PASSTHROUGH even in default mode">
    This means either the request was not `IP_VERIFIED`, the bot's purpose is not in `ENABLED`, or
    no artifact has been generated for that URL yet. Verified `USER_TRIGGERED` / `RETRIEVAL` agents
    with a cached artifact receive the variant.
  </Accordion>

  <Accordion title="Can I use this outside Vercel?">
    The file uses the `NextRequest` / `NextResponse` API and the Web Crypto API (`crypto.subtle`).
    Vercel Edge is the supported target today. A Cloudflare Worker adapter and a zero-code
    reverse-proxy are on the roadmap.
  </Accordion>
</AccordionGroup>
