# Installing agentsite

Four shipped install patterns — pick by stack shape.

By AgentSite · Updated 2026-05-28

## Installing @agentsite/snippet

## For agents (Claude, Cursor, Copilot, etc.) — read this first

You're here because a user pasted an install prompt of the shape **"Hey! I'd like to install agentsite.app — here's my token `asit_…`"**. Before you write any code:

1.  **Confirm the target site URL.** If the prompt doesn't name one, infer it from the repo (env vars, README, deployed-domain references in `package.json` / framework configs) and **ask the user to confirm**: _"Based on your codebase, this install will be for `<URL>`. Correct?"_ Do not proceed on a guess.
2.  **Confirm the integration pattern.** Pick from the five patterns below using the decision rule. State your choice back: _"You're running `<framework>` behind `<server>` — I'm going to use the `<pattern>` recipe."_ If multiple match (e.g. SPA behind nginx — Nginx vs Express), explain the trade-off and let the user pick.
3.  **Register the site.** Once URL is confirmed, call `POST https://api.agentsite.app/site` with `{"domain": "<the URL, host only>"}` and `Authorization: Bearer <token>`. The account token mints a per-site token in the response — use that for the snippet's `AGENTSITE_TOKEN` env var. The token in the install prompt is account-level (scope `sites:write`); the site-level token returned from create is what the deployed snippet authenticates with.
4.  **Show the diff, do not auto-commit.** Stage every change so the user can review file-by-file. Run any verification (build, type-check, smoke curl) you can without committing. Only commit when the user explicitly says ship it.

If anything in steps 1–2 is ambiguous, ask before reading the patterns below. The user wants the install to be right, not fast.

**Don't have a token yet?** Read [https://api.agentsite.app/agent.md](https://api.agentsite.app/agent.md) §1 — it walks the user through minting a PAT at [https://agentsite.app/settings/tokens](https://agentsite.app/settings/tokens) and explains the scope set (`sites:read`, `sites:write`, `reports:read`) you need for the install.

* * *

There are four install patterns shipping today, plus one more in pre-release. Pick the one that matches the shape of your stack.

**Shipping today:**

-   **Express — Static SPA in a container.** Your build produces a directory of static files (`dist/` or similar) and you can run a Node container in production. _Examples: Vue/React/Svelte/Solid SPAs deployed to Docker / k8s / [fly.io](http://fly.io) / [render.com](http://render.com) / Railway / VPS._
-   **Express-Sidecar — Non-streaming SSR framework in a container.** Your framework runs its own server process at request time, and you can run a Node sidecar alongside it. _Examples: Next.js pages router, Nuxt 2, classic Express/Koa/Hapi, Rails, Django, WordPress, PHP-FPM, deployed to Docker / k8s / fly / render / Railway._
-   **Nginx — Thin nginx (no code in your deploy).** Your static SPA sits behind nginx and you want zero code in your deploy. Add a handful of `location` + `proxy_pass` directives to your existing `nginx.conf`; agentsite owns the HTML byte stream including reach-back to `<host>/index.html` for the SPA shell. No Node sidecar, no edge function, no package to keep current. _Examples: Lovable / v0 / Bolt self-hosters on a VPS, mid-market shops with an existing nginx tier and no appetite for a Node sidecar._ See [Nginx install](#nginx--thin-nginx-no-code) below.
-   **SDK — Streaming SSR via the `/bundle` API.** Framework SDK for streaming-SSR shops where buffering the response in a proxy would break things (React Server Components, Next App Router default, Nuxt 3 streaming, SvelteKit streaming, Suspense streaming). The SDK calls our `/bundle` API in-process during SSR and folds the result into the rendered tree — no proxy in front, streaming preserved. Larger footprint than the middleware patterns (~9–10 SDK files committed into your repo, one config edit, one line per page) but framework-native. _Examples: Next.js App Router, Nuxt 3, SvelteKit streaming. See `demos/after/next/` and `demos/after/nuxt/` for runnable references._

**Coming soon — pre-release install available:**

-   **Edge — Edge runtime / PaaS that doesn't run arbitrary Node.** _Examples: Vercel (without your own container), Cloudflare Pages, Netlify, GitHub Pages with Cloudflare in front._ Use the Fetch-API port `agentsite-edge.mjs` + the platform's edge middleware shell. See [Edge install](#edge-install) at the bottom. **Pre-release:** the install works today; we're still hardening the rough edges. [Get in touch](https://agentsite.app/contact) and we'll wire you up.

Decision rule:

1.  Can you run a Node container in production? **No** → **Edge** (pre-release) or **Nginx**.
2.  Does your framework have a server-side runtime, and is the response **non-streaming** HTML? **Yes** → **Express-Sidecar**. **No** (response streams chunks — RSC / Suspense / Next App Router / Nuxt 3 streaming) → **SDK — Streaming SSR**. **No** (no server runtime) → **Express**, or **Nginx** if your build sits behind nginx and zero-code-in-deploy is a hard constraint.

**Nginx** vs **Express** target the same audience (static SPA). Choose Nginx when "no new process / no code in our deploy" is the hard constraint (audit-cautious CTO, ops team won't add a Node sidecar). Choose Express for the friendliest local-control + visible-failure story (snippet runs in your Node process, code you can audit line-by-line).

ISR / hybrid frameworks (Astro static + islands, Next.js pages router with mixed static+ISR, non-streaming SSR) → **Express-Sidecar**. The framework's own runtime needs to run; agentsite proxies in front.

> **UA-blind by design.** Every install pattern above returns identical response bytes to humans, GPTBot, ClaudeBot, PerplexityBot, and share-card scrapers. No User-Agent routing, no bot cloaking — the decision lives on the response, not on the requester. This is the cleaner posture vs. UA-switching middlewares like [prerender.io](http://prerender.io), and it sidesteps Google's spam-policy cloaking risk.

> **Fail-open, snippet-in-your-deploy.** Every pattern degrades to your unmodified site if agentsite is unreachable — Express / Express-Sidecar return your `index.html` unchanged, the SDK skips the `/bundle` call and renders without it, Nginx falls back to a `@raw_shell` location via `error_page 502 504`. **The Nginx pattern goes further: agentsite owns the entire HTML byte stream including reach-back to your origin for the SPA shell** — there is no agentsite library inside your codebase, no Node sidecar, no edge function, no package to keep current. Removing any of these is one config edit. Your site is never blocked on us.

> **Looking for njs / CloudFront / Python / PHP?** Those are on the [roadmap](#roadmap) — not yet in pre-release. Use one of the patterns above today.

* * *

## Express — Static SPA

agentsite is a small Node middleware that serves your SPA's `dist/` directory plus a per-request render bundle (title, meta, JSON-LD schema, markdown body) that AI crawlers read without running JavaScript.

Two deployment shapes — both use the same `agentsite/` directory + `server.mjs`; only the surrounding container topology differs:

-   **Replace your static server** — single Node container, agentsite is the only process. Simplest. Recommended when your existing static layer (nginx, Caddy, etc.) only serves files. **The walkthrough below is this shape.**
-   **Sidecar behind your reverse proxy** — keep nginx (or Caddy, HAProxy) in front for TLS termination, rate limits, or custom rewrites; agentsite runs on `127.0.0.1:3000` behind it. Recommended when your reverse proxy does more than serve files. See the "Static + Nginx (VPS) — keep Nginx (sidecar)" recipe at [https://agentsite.app/getting-started](https://agentsite.app/getting-started).

**Target layout** after install:

```
your-app/
├── dist/                 ← your existing build output (unchanged)
├── agentsite/            ← new
│   ├── agentsite.cjs     ← downloaded snippet
│   ├── server.mjs        ← Express bootstrap
│   └── package.json      ← express dep only
└── Dockerfile            ← replaces your nginx (or other) Dockerfile
```

> **Build output not `dist/`?** Many toolchains use different defaults — `build/` (Create React App), `out/` (Next static export), `public/` (some Astro configs), `_site/` (Eleventy, Jekyll). Adjust the `COPY dist …` line in the Dockerfile below and `DIST_DIR` env var to match. The convention here is the source path; whatever your toolchain produces goes in the same slot.
> 
> **Existing nginx doing more than serve-static?** If your nginx terminates TLS, applies rate-limits, or has custom rewrites you want to keep, use the sidecar variant: keep nginx in front, run agentsite on `127.0.0.1:3000` behind it, and have nginx proxy through. See the "Static + Nginx (VPS) — keep Nginx (sidecar)" recipe at [https://agentsite.app/getting-started](https://agentsite.app/getting-started).

### 1\. Add the `agentsite/` directory

```sh
mkdir -p agentsite
curl https://api.agentsite.app/snippet.js > agentsite/agentsite.cjs
```

### 2\. Write `agentsite/package.json`

```json
{
  "name": "agentsite-sidecar",
  "private": true,
  "type": "module",
  "engines": { "node": ">=20" },
  "main": "server.mjs",
  "scripts": { "start": "node server.mjs" },
  "dependencies": { "express": "^4.21.1" }
}
```

### 3\. Write `agentsite/server.mjs`

```js
// agentsite — serves the SPA dist with the snippet wired in.
// Replaces nginx (or any other static file server) for SPAs.
// Three env vars: AGENTSITE_SITE (your origin URL), AGENTSITE_TOKEN
// (from your dashboard), DIST_DIR (path to the Vite/etc. build output).

import express from 'express'
import { createRequire } from 'node:module'

const require = createRequire(import.meta.url)
const agentsite = require('./agentsite.cjs')

const app = express()
app.use(agentsite({
  distDir: process.env.DIST_DIR || '/app/dist',
  site: process.env.AGENTSITE_SITE,
  token: process.env.AGENTSITE_TOKEN,
}))

const PORT = parseInt(process.env.PORT || '3000', 10)
app.listen(PORT, () => {
  console.log(`[agentsite] :${PORT} serving ${process.env.DIST_DIR || '/app/dist'}`)
})
```

> Why the `createRequire` line: the snippet ships as CommonJS for the widest install compatibility (curl-download into existing Express apps). Your server.mjs is ES Modules. `createRequire` is the standard interop. A native ESM build of the snippet is on the roadmap; this will simplify to one import line.

### 4\. Replace your Dockerfile

```dockerfile
FROM node:20-alpine

# Sidecar deps (just express).
COPY agentsite/package.json /app/agentsite/package.json
RUN cd /app/agentsite && npm install --omit=dev --no-package-lock

# Built SPA bundle (pre-built before docker build).
COPY dist /app/dist

# Agentsite server + snippet.
COPY agentsite/agentsite.cjs /app/agentsite/agentsite.cjs
COPY agentsite/server.mjs /app/agentsite/server.mjs

ENV NODE_ENV=production
ENV PORT=3000
ENV DIST_DIR=/app/dist

EXPOSE 3000

CMD ["node", "/app/agentsite/server.mjs"]
```

> **If you already have a custom Dockerfile** (multi-stage build, asset compilation, custom base image), don't replace it wholesale. Keep your existing build stages and integrate these four points: (1) `agentsite/` directory COPY'd into `/app/agentsite/`, (2) `express` installed in `/app/agentsite/`, (3) your SPA build output COPY'd into `/app/dist/`, (4) the final `CMD` runs `node /app/agentsite/server.mjs`.

### 5\. Build + run

```sh
# Build your SPA — whatever your toolchain calls (vite build, etc).
npm run build

# Build the image.
docker build -t your-app .

# Run with the token + your site URL.
docker run --rm -p 3000:3000 \
  -e AGENTSITE_TOKEN=asit_xxxxxxxx \
  -e AGENTSITE_SITE=https://your-site.com \
  your-app
```

That's it. Visit `http://localhost:3000/` in a browser — you see your SPA. Curl the same URL without a JavaScript engine — you see the head enriched with title, meta, JSON-LD, and a markdown body for AI crawlers.

* * *

## Express-Sidecar — Non-streaming SSR framework

> ⚠️ **Streaming SSR is not supported.** If your framework streams HTML chunks (React Server Components, Next.js App Router with default React 18 streaming, Nuxt 3 streaming, SvelteKit streaming, or any `Transfer-Encoding: chunked` HTML response) — **do not use this pattern**. agentsite buffers the upstream response to inject head/body tags; buffering negates the streaming benefit and regresses TTFB to the slowest server-side compute. See [Not yet supported](#not-yet-supported) for context and honest workarounds.
> 
> Express-Sidecar is shippable today for **non-streaming SSR**: Next.js pages router, Nuxt 2, classic Express/Koa/Hapi apps, Rails, Django, WordPress, PHP-FPM apps.

Your framework has its own non-streaming runtime that does meaningful work per request (server-rendered HTML, server actions, ISR). agentsite sits in front as a reverse-proxy sidecar — same container, two processes, with `entrypoint.sh` starting both.

**Target layout** after install:

```
your-app/
├── (your framework files — unchanged)
├── agentsite/            ← new
│   ├── agentsite.cjs     ← downloaded snippet
│   ├── server.mjs        ← Express bootstrap
│   └── package.json      ← express dep only
├── entrypoint.sh         ← new
└── Dockerfile            ← modified (additive)
```

### 1\. Add the `agentsite/` directory

```sh
mkdir -p agentsite
curl https://api.agentsite.app/snippet.js > agentsite/agentsite.cjs
```

### 2\. Write `agentsite/package.json`

```json
{
  "name": "agentsite-sidecar",
  "private": true,
  "type": "module",
  "engines": { "node": ">=20" },
  "main": "server.mjs",
  "scripts": { "start": "node server.mjs" },
  "dependencies": { "express": "^4.21.1" }
}
```

### 3\. Write `agentsite/server.mjs`

```js
// agentsite — sidecar that proxies a framework runtime through the
// snippet's head / JSON-LD / markdown injection pipeline. Use when the
// framework needs its own runtime (Next standalone, Nuxt, etc.).
// Three env vars: AGENTSITE_SITE, AGENTSITE_TOKEN, UPSTREAM_ORIGIN.

// IMPORTANT: Express-Sidecar does not handle streaming SSR. If your
// framework uses React Server Components, Suspense streaming, or any
// chunked-HTML response pattern, the sidecar would have to buffer the
// upstream response before injection — which breaks TTFB and may
// break the framework's streaming guarantees. Use the streaming-SSR
// SDK install (see `demos/after/next/` and `demos/after/nuxt/`) for
// those stacks. For pure non-streaming SSR, this sidecar is the right fit.

import express from 'express'
import { createRequire } from 'node:module'

const require = createRequire(import.meta.url)
const agentsite = require('./agentsite.cjs')

const app = express()
app.use(agentsite({
  upstream: process.env.UPSTREAM_ORIGIN || 'http://127.0.0.1:3001',
  site: process.env.AGENTSITE_SITE,
  token: process.env.AGENTSITE_TOKEN,
}))

const PORT = parseInt(process.env.PORT || '3000', 10)
app.listen(PORT, () => {
  console.log(`[agentsite] :${PORT} → ${process.env.UPSTREAM_ORIGIN || 'http://127.0.0.1:3001'}`)
})
```

### 4\. Write `entrypoint.sh`

```sh
#!/bin/sh
# Two processes in one container: your framework on 127.0.0.1:3001
# (loopback only), agentsite sidecar on :3000 (the only exposed port).
set -e

# Replace this with your framework's startup command.
# Whatever it is, bind it to 127.0.0.1:3001.
PORT=3001 HOSTNAME=127.0.0.1 node server.js &
FRAMEWORK_PID=$!

# If the framework dies before agentsite starts, fail the container fast.
trap "kill $FRAMEWORK_PID 2>/dev/null || true" EXIT

exec node /app/agentsite/server.mjs
```

### 5\. Update your Dockerfile

Append the agentsite layer at the end of your existing Dockerfile. The framework build / install above stays exactly as it is. Then:

```dockerfile
# --- agentsite sidecar add-on ---

COPY agentsite/package.json /app/agentsite/package.json
RUN cd /app/agentsite && npm install --omit=dev --no-package-lock

COPY agentsite/agentsite.cjs /app/agentsite/agentsite.cjs
COPY agentsite/server.mjs /app/agentsite/server.mjs

COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENV PORT=3000
ENV UPSTREAM_ORIGIN=http://127.0.0.1:3001

EXPOSE 3000
CMD ["/entrypoint.sh"]
```

If your existing Dockerfile already had `EXPOSE 3000` and a `CMD`, replace those with the lines above. agentsite owns `:3000` now; your framework moves to `:3001` (loopback only, never exposed).

### 6\. Build + run

```sh
# Build your framework — whatever your toolchain calls (next build, etc).
npm run build

docker build -t your-app .

docker run --rm -p 3000:3000 \
  -e AGENTSITE_TOKEN=asit_xxxxxxxx \
  -e AGENTSITE_SITE=https://your-site.com \
  your-app
```

* * *

## Verification

After running the container, check the snippet wired correctly:

```sh
# 1. Human view — your SPA loads as normal.
open http://localhost:3000/

# 2. Agent view — same URL, no JavaScript. You should see your title,
#    meta description, JSON-LD schema, and a markdown body. By default
#    the markdown sits inside <div id="agentsite-preboot">…<pre>…</pre></div>
#    (hidden via inline style + JS toggle so humans never see it; agents
#    that don't run JS read the <pre>). Set bodyMode: 'noscript' if you
#    prefer the legacy <noscript><pre> wrapper.
curl -sL http://localhost:3000/ | head -50

# 3. Markdown mirror — append .md to any route for raw markdown.
curl -sL http://localhost:3000/pricing.md

# 4. Headers — Link advertises llms.txt + sitemap.xml.
curl -sI http://localhost:3000/ | grep -i '^link:'
```

If `curl` returns an empty `<div id="app"></div>` with no head enrichment, the snippet didn't wire. Common causes:

-   `AGENTSITE_TOKEN` missing or wrong — sign up at [https://agentsite.app](https://agentsite.app) to get one. **Note:** since `v0.2.0` the snippet throws at boot if `AGENTSITE_TOKEN` is missing and `AGENTSITE_SITE` looks like a production URL. If you're running a public-demo allow-listed host, pass `tokenOptional: true` to the snippet opts. Localhost / 127.0.0.1 / 0.0.0.0 sites are auto-suppressed.
-   `AGENTSITE_SITE` doesn't match what you registered in the dashboard.
-   Your `DIST_DIR` doesn't actually contain `index.html` (Express) or your framework isn't listening on `127.0.0.1:3001` (Express-Sidecar). Check the container logs.

> **CSP note (Content-Security-Policy).** Every enriched HTML response now carries a `<script data-agentsite="webmcp">` inline IIFE (~2 KB) that registers WebMCP tools (`navigator.modelContext.registerTool`) for MCP-capable browsers and no-ops elsewhere. If your site runs a strict CSP, the inline script needs `'unsafe-inline'` in `script-src`, or you must allow the script via a nonce/hash. There is no `src=` attribute — the script body is generated server-side and varies per `AGENTSITE_SITE`, so a static `script-src` hash is not workable today. Sites without CSP need no action; sites with CSP report-only violations after install are almost certainly seeing this script.

> **Express-only: in-process response cache.** The Express snippet (used by both `Express` and `Express-Sidecar`) caches `/bundle` for 5 min, `/opinionated`, `/sitemap`, `/llms-index` for 1 hour, and `/robots-policy` for 24 hours — entirely in-process. In a single-pod deploy this absorbs most repeat traffic for free. In a **multi-pod deploy each pod warms its own cache independently** — N pods = up to N cold-misses per resource immediately after a deploy or rollover. The Nginx and Edge install patterns sidestep this entirely because their CDN/edge layer caches upstream. If multi-pod cache convergence matters for your traffic profile, front Express with a shared cache layer (nginx, Varnish, CloudFront) the same way those patterns already do. There is no shared-cache hook on the Express adapter today.

## Paths the snippet generates

Under the canonical single-mount install (`app.use(agentsite({distDir, site, token}))` is the entire HTTP handler), the snippet sees every request and decides what to serve. For paths the snippet has an opinion on, your `dist/<path>` is **input** to that decision:

-   **merge** — the snippet reads your file, augments with required additions, serves merged.
-   **customer-wins** — the snippet reads your file, serves it unchanged. (The snippet is a conduit; you asked to win.)
-   **no customer file** — the snippet generates from scratch, serves.

| Path | Behavior | If you ship `dist/<path>` |
| --- | --- | --- |
| `/env.js` | Built from `process.env` filtered by `opts.envAllowList`. `AGENTSITE_TOKEN` hard-blocked. | customer-wins |
| `/robots.txt` | Generated granular AI-bot policy. | **merge** — your directives preserved verbatim; snippet adds `Content-Signal:`, `Sitemap:`, and AI-bot stanzas only when you didn't include them. Your `Content-Signal: ai-train=no` wins if you set it. |
| `/llms.txt`, `/llms-full.txt` | Generated from cached render rows. | customer-wins (hand-rolled llms.txt is curator intent — never auto-merged) |
| `/sitemap.xml` | Generated from cached render rows. | customer-wins for now (XML urlset structural merge is on the roadmap) |
| `/ai.txt` | Generated default. | customer-wins |
| `/.well-known/api-catalog` | Generated (RFC 9727 linkset). | customer-wins for now (linkset+json structural merge is on the roadmap) |
| `/.well-known/openid-configuration` | Generated stub. | customer-wins (OIDC issuer is load-bearing for your auth tier — never rewritten) |
| `/.well-known/oauth-protected-resource` | Generated (RFC 9728). | customer-wins |
| `/.well-known/mcp/server-card.json` | Generated stub. | customer-wins (tool registration is yours) |
| `/.well-known/ai-agent.json` | Generated. | customer-wins |
| `/.well-known/agents.json` | Generated. | customer-wins |
| `/.well-known/agent-skills/index.json` | Generated dynamically from registered skills. | customer-wins |
| `*.md` (suffix or `Accept: text/markdown`) | Markdown mirror of the HTML page. | customer routes registered **before** the snippet's catch-all win (e.g. `/proposal/:id.md` proxies) |
| `/assets/*`, everything else under `dist/` | n/a — snippet has no opinion. | snippet streams from disk straight through, with the right Content-Type by file extension. |

The snippet also adds a default `Link:` response header on every route — `</llms.txt>; rel="describedby"; type="text/plain", </sitemap.xml>; rel="sitemap"`. Override with `linkHeaders: [string]`, append with `linkHeadersExtra: [string]`, or disable with `linkHeaders: false`.

**Telemetry is opt-in.** Pass `telemetry: true` to the factory if you want bot-fetch counts to flow back to your dashboard's "fetches this week, by crawler" tile. The dashboard can also toggle this server-side per site; the snippet `opts.telemetry` value (when set) always wins over the server preference. Humans never hit the buffer either way; only normalized bot signals reach the cloud (no IPs, full UAs, cookies, or query strings).

Per-site overrides for the merge/customer-wins decision are on the dashboard roadmap; today's defaults are hardcoded per the table above.

Upgrades may add new opinionated paths; release notes call out additions in a "Breaking surface area" section so you can audit before pulling.

## Keeping the snippet current

The snippet evolves; new install pain points get closed in subsequent releases. Pin the curl in a Makefile target so re-pulls are one command — CI can run `make snippet` pre-build to keep installs current:

```make
snippet:
	curl -sSf https://api.agentsite.app/snippet.js > agentsite/agentsite.cjs
```

Then `make snippet && docker build -t your-app .` is the canonical refresh. Inspect the bundle script tag's `data-agentsite-version` attribute in your rendered HTML to confirm the version that's running.

## Reference implementations

These exist as runnable demos in the agentsite source repo. The SPA / Nginx demos are byte-for-byte the result of following the instructions above — kept in sync by `devops/scripts/regenerate-demo-after.sh`. The streaming-SSR demos are hand-maintained (the SDK install touches each page file, which is too coupled to overlay cleanly from a `before/` baseline). They're the test that these instructions actually work:

| Pattern | Reference demo |
| --- | --- |
| Express — Static SPA | [`demos/after/vue/`](https://github.com/agentsite/integration/tree/main/demos/after/vue), [`demos/after/react/`](https://github.com/agentsite/integration/tree/main/demos/after/react) |
| Express-Sidecar — Non-streaming SSR | No reference demo yet — follow the install steps in [Express-Sidecar](#express-sidecar--non-streaming-ssr-framework) above. |
| SDK — Streaming SSR | [`demos/after/next/`](https://github.com/agentsite/integration/tree/main/demos/after/next) — Next.js 14 App Router with RSC, `@agentsite/next` helpers (`agentsite/` dir inside the demo). [`demos/after/nuxt/`](https://github.com/agentsite/integration/tree/main/demos/after/nuxt) — Nuxt 3 streaming SSR with the `@agentsite/nuxt` Nuxt module. Three-mode contract: hard load blocks on the bundle, in-app routing is non-blocking with async lazy fill, `X-AgentSite: none` bypass works across all paths. Footprint: ~9–10 files added under `agentsite/`, one config edit (`next.config.mjs` rewrites for Next, `modules` entry for Nuxt), one line per page (`<AgentReadable />` + a metadata helper). The SDK packages are committed into the demo directory today; `@agentsite/next` and `@agentsite/nuxt` will publish to npm shortly. |
| Nginx — Containerized | [`demos/after/nginx-container/`](https://github.com/agentsite/integration/tree/main/demos/after/nginx-container) — `nginx:alpine` image + `default.conf.template` + `NGINX_ENVSUBST_FILTER`. Byte-exact result of following Nginx §3a. |
| Nginx — Bare-metal / VPS | [`devops/pattern-f-test/`](https://github.com/agentsite/integration) — docker-compose fixture (nginx + api-stub + 4-file SPA + 20-assertion test script). Equivalent to bare-metal §3b in shape (literal token in the config) — runs the same nginx directives against an in-network api-stub. |

To see the diff against the same project without agentsite, compare to the `demos/before/<framework>/` sibling — that diff is what the install adds.

## Edge install

For platforms where you can't run an arbitrary Node container — Vercel (without your own container build), Cloudflare Pages, Netlify, GitHub Pages fronted by Cloudflare.

agentsite ships a Fetch-API port (`agentsite-edge.mjs`) that runs in any web-standard edge runtime, plus a small platform-specific shell that wires it into the platform's middleware contract.

| Platform | Fetch these two files | Drop them at | Set env vars on |
| --- | --- | --- | --- |
| **Cloudflare Pages** | `agentsite-edge.mjs` + `edge/cloudflare-worker.mjs` | `functions/agentsite-edge.mjs` + `functions/_middleware.js` | Pages Settings → Environment Variables (mark Encrypted) |
| **Vercel (non-Next Vite SPA)** | `agentsite-edge.mjs` + `edge/vercel-edge-middleware.ts` | project root: `agentsite-edge.mjs` + `middleware.ts` | Vercel Project → Environment Variables (TOKEN as Sensitive) |
| **Netlify Edge Functions** | `agentsite-edge.mjs` + `edge/netlify-edge-function.ts` | `netlify/edge-functions/` + add `[[edge_functions]]` block to `netlify.toml` | Site Settings → Environment Variables |
| **GitHub Pages / S3 / Lovable / Bolt / v0 / Webflow / Framer / Squarespace** | — | n/a directly | Point custom domain through Cloudflare DNS, then use the Cloudflare Pages recipe in front. OR eject (Lovable/Bolt/v0 export) and redeploy as **Express**. |

All edge files are served from `https://api.agentsite.app/agentsite-edge.mjs` and `https://api.agentsite.app/edge/<platform>-…`. The shape: `handleAgentsite(request, { site, token, onPassthrough })` — the platform shell wires the `onPassthrough` callback to your static origin. The Edge handler covers head enrichment, JSON-LD, `.md` content negotiation, `/llms.txt` + `/llms-full.txt` + `/robots.txt` + `/sitemap.xml`, the eight opinionated `/.well-known/*` paths via the `/opinionated` umbrella, the `Link:` discovery header, the WebMCP `registerTool` script, and opt-in bot UA telemetry (pass `telemetry: true`). Platform Cache API integration is on the roadmap.

### SSR framework as the Worker (no separate origin)

The recipes above assume the edge function sits in front of a _separate_ static origin (a CF Pages build, an S3 bucket, etc.). If your framework runs on the edge runtime itself — TanStack Start on Cloudflare Workers, Nuxt with `nitro.preset = 'cloudflare'`, SvelteKit with `adapter-cloudflare`, Next.js with `runtime = 'edge'` — _your Worker is the origin_. There's nothing to proxy to. Skip the `_middleware.js` shell and call `handleAgentsite` directly from your existing `fetch` entry, wiring `onPassthrough` to the same handler.

```bash
# Drop the edge lib next to your server entry.
curl https://api.agentsite.app/agentsite-edge.mjs > src/agentsite-edge.mjs
```

```js
// src/server.ts (or wherever your Worker's `fetch` lives)
import { handleAgentsite } from './agentsite-edge.mjs'
import { yourFrameworkHandler } from './your-framework-entry'

export default {
  async fetch(request, env, ctx) {
    return handleAgentsite(request, {
      site: env.AGENTSITE_SITE,
      token: env.AGENTSITE_TOKEN,
      onPassthrough: () => yourFrameworkHandler(request, env, ctx),
      waitUntil: (p) => ctx.waitUntil(p),
    })
  },
}
```

Env wiring on Cloudflare Workers: `AGENTSITE_SITE` (e.g. `https://example.com`) goes in `wrangler.jsonc`'s `vars` block. `AGENTSITE_TOKEN` arrives via `wrangler secret put AGENTSITE_TOKEN` — in CI, run that step against `$AGENTSITE_TOKEN` from your repository secrets before `wrangler deploy`, so the token never lands in `wrangler.jsonc`. Same shape applies on other edge platforms that host SSR frameworks: the lib's contract is just "give me an `onPassthrough` that produces the response your framework would have returned."

## Nginx — Thin nginx (no code)

For nginx-fronted static SPAs. Your nginx stays the front door; agentsite is just another upstream a few paths route to. No code in your deploy beyond a paste of `location` + `proxy_pass` directives.

**Two deployment shapes** — same nginx directives, different way the token reaches them:

-   **Containerized** (§3a, primary) — nginx ships inside a Docker image (k8s / Fly / Railway / [render.com](http://render.com) / Lovable+Bolt+v0 exports). The token can't be baked into the image; the official `nginx:alpine` entrypoint templates it at boot via `envsubst`. **Most modern SPA deploys are this shape.**
-   **Bare-metal / VPS** (§3b) — nginx is a long-lived file on disk (`/etc/nginx/...`) that you edit and reload. The token is a literal string in the config.

The directives in §2 are identical for both shapes. Only the token placeholder differs.

### 1\. Get your site token

Sign in to [https://agentsite.app](https://agentsite.app), register the site, and copy its token from the dashboard. Without the token, render calls 401 and the site degrades to the unenriched shell. The token is `<SITE_TOKEN>` in §2 below — for **Containerized**, you'll write `${AGENTSITE_TOKEN}` and pass the value at runtime; for **Bare-metal / VPS**, you'll inline the literal token string.

### 2\. The agentsite directives

```nginx
# Nginx install — paste into your existing nginx server { } block.
# Your existing listen / server_name / root / ssl stays as-is. Make sure
# `root` is set (server-level if it isn't already) so the bypass branch
# below can serve files from disk.

# Required: nginx defers DNS resolution to request time whenever a
# proxy_pass URL contains a variable (we have $host and $request_uri
# below). Without an explicit resolver every request fails with
# "no resolver defined to resolve api.agentsite.app" and the site
# falls through to graceful-failure (raw shell, no enrichment) — your
# logs will show 200s but no enrichment headers. Public DNS is the safe
# paste-and-go default; substitute your cluster / VPC resolver
# (`127.0.0.11` inside Docker, kube-dns at `169.254.20.10`, AWS VPC at
# `169.254.169.253`) if you prefer.
resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
resolver_timeout 5s;

# 0) Internal-only bypass dispatch — MUST come before the regex
#    locations below. Nginx matches regex locations in source order;
#    if the `\.md$` block came first it would re-match the rewritten
#    `/__raw/foo.md` URI and create an infinite rewrite loop (nginx
#    hits its rewrite cycle limit and returns 500, which agentsite's
#    renderer then caches as the page content). Two variants for the
#    two reach-back probe shapes:
#
# (1) Strict bypass — customer-wins probes (agentsite asking whether
#     YOU ship your own /robots.txt, /sitemap.xml, /llms*.txt,
#     /.well-known/*, *.md). File-or-404 only; do NOT fall back to
#     the shell, or agentsite would treat your shell HTML as if it
#     were the customer-shipped robots.txt content.
location ~ ^/__raw/(robots\.txt|sitemap\.xml|llms(-full)?\.txt|\.well-known/|.*\.md$) {
    internal;
    rewrite ^/__raw(.*)$ $1 break;
    try_files $uri =404;
}

# (2) SPA-aware bypass — agentsite's headless renderer loading your SPA
#     page like a real browser. Real file if it exists (CSS/JS/images);
#     otherwise fall back to /index.html so the SPA's JS router can
#     render the requested route. =404 only when even the shell is
#     missing. `internal;` blocks external traffic — only reachable
#     via the rewrites below.
location /__raw/ {
    internal;
    rewrite ^/__raw(.*)$ $1 break;
    try_files $uri /index.html =404;
}

# 1) Special paths — agentsite serves these UNLESS the request carries
#    `X-Agentsite: none` (agentsite's own reach-back probes for the
#    customer-source fetcher / snippet probe / asset extraction). On
#    bypass, serve from disk if present, 404 if not (agentsite then
#    generates). Prevents loops when agentsite needs to inspect what
#    you actually ship.
#
#    Reach-back URL hardcodes `https://` (not `$scheme`) so pods behind
#    TLS-terminating ingress — where `$scheme` is `http` — still send
#    agentsite the public origin. `proxy_ssl_server_name on;` is required
#    because api.agentsite.app is multi-tenant TLS — SNI picks the right
#    cert on the handshake.
location ~ ^/(robots\.txt|sitemap\.xml|llms(-full)?\.txt|\.well-known/) {
    if ($http_x_agentsite = "none") { rewrite ^ /__raw$uri last; }
    proxy_pass https://api.agentsite.app/render?url=https://$host$request_uri;
    proxy_set_header Authorization "Bearer <SITE_TOKEN>";
    proxy_ssl_server_name on;
}

# 2) Markdown content negotiation.
location ~ \.md$ {
    if ($http_x_agentsite = "none") { rewrite ^ /__raw$uri last; }
    proxy_pass https://api.agentsite.app/render?url=https://$host$request_uri;
    proxy_set_header Authorization "Bearer <SITE_TOKEN>";
    proxy_ssl_server_name on;
}

# 3a) Static-asset extensions short-circuit: served from disk OR 404.
#     Never falls through to @agentsite. Without this, a stale Vite
#     hash referenced by an old client (e.g. /assets/naive-ui-Ctnud1oY.js
#     after a redeploy) falls through, gets rendered as a "page" by
#     agentsite's Playwright, returns empty markdown, and pollutes
#     /llms.txt + /sitemap.xml as a junk entry. Live dogfood scan
#     2026-05-27 caught it.
location ~* \.(js|mjs|css|map|woff|woff2|ttf|otf|eot|ico|svg|png|jpe?g|gif|webp|avif|mp4|webm)$ {
    try_files $uri =404;
}

# 3) Real files served from disk; SPA fallback → agentsite (injected HTML).
location / {
    try_files $uri @agentsite;
}

location @agentsite {
    if ($http_x_agentsite = "none") { rewrite ^ /__raw$uri last; }
    proxy_pass https://api.agentsite.app/render?url=https://$host$request_uri;
    proxy_set_header Authorization "Bearer <SITE_TOKEN>";
    proxy_ssl_server_name on;
    proxy_connect_timeout 2s;
    proxy_read_timeout 5s;
    # Graceful failure for the real-failure case (DNS / connect / read
    # timeout / upstream 5xx). agentsite's own deliberate bypass on
    # Cache-Control: no-cache returns 200 + the shell HTML inline, so
    # nothing special is needed for that path. `proxy_intercept_errors
    # on;` makes error_page also catch upstream-returned 5xx (not just
    # nginx-side failures), so a backend that genuinely 502s still
    # surfaces the raw shell to the visitor instead of a bare error page.
    proxy_intercept_errors on;
    error_page 502 504 = @raw_shell;
}

location @raw_shell {
    try_files /index.html =404;
}
```

> **Two ways the token reaches the `Authorization:` header above:**
> 
> -   **Containerized (§3a)** → write the line as `proxy_set_header Authorization "Bearer ${AGENTSITE_TOKEN}";`. The literal `${AGENTSITE_TOKEN}` stays in the file; `envsubst` replaces it at container boot from the env var of the same name.
> -   **Bare-metal / VPS (§3b)** → replace `<SITE_TOKEN>` with the literal token string from your dashboard. The file on disk contains the actual token.

### 3a. Apply — Containerized

Two files in your repo: a templated nginx config + a Dockerfile. The reference implementation in [`demos/after/nginx-container/`](#reference-implementations) is the byte-exact result of following the steps below.

#### `default.conf.template`

Take the block from §2, wrap it in a `server { listen 80 default_server; server_name _; root /usr/share/nginx/html; index index.html; ... }` if you don't have one already, and write the token line as `proxy_set_header Authorization "Bearer ${AGENTSITE_TOKEN}";` (literal — `envsubst` replaces it at boot).

#### `Dockerfile`

```dockerfile
FROM nginx:1.27-alpine

# Built SPA bundle (pre-built before docker build — `npm run build` etc).
COPY dist /usr/share/nginx/html

# Templated nginx config. Path matters: ONLY files under
# /etc/nginx/templates/ go through envsubst. Anywhere else (including
# /etc/nginx/conf.d/ directly) → ${AGENTSITE_TOKEN} stays literal,
# every request 401s.
COPY default.conf.template /etc/nginx/templates/default.conf.template

# CRITICAL: scope envsubst to only AGENTSITE_* env vars. Without this
# filter, envsubst expands nginx's own variables ($host, $uri,
# $request_uri, $http_x_agentsite, ...) to empty strings and the
# config silently breaks. See the footgun note below.
ENV NGINX_ENVSUBST_FILTER=^AGENTSITE_

EXPOSE 80
```

> **Footgun — the env var is `NGINX_ENVSUBST_FILTER` (a regex), not `_VARS` / `_NAMES` / a list.** The official `nginx:alpine` image documents one templating var: `NGINX_ENVSUBST_FILTER`, which takes a regex matched against env var names. There is no `NGINX_ENVSUBST_FILTER_VARS` and no comma-separated variant despite what the name suggests. **If you leave it unset, envsubst expands every `$var` in the template — including nginx's own variables (`$host`, `$uri`, `$request_uri`, `$http_x_agentsite`, `$scheme`, `$status`, ...) to empty strings. The config will load and serve 200s with no enrichment and no error.** The regex `^AGENTSITE_` matches the only env var we ever want substituted.

#### Build + run

```sh
# Build your SPA — whatever your toolchain calls (vite build, etc).
npm run build

docker build -t your-app .

docker run --rm -p 8080:80 \
  -e AGENTSITE_TOKEN=asit_xxxxxxxx \
  your-app
```

#### Smoke-test the substitution before you ship

Catches every silent-failure mode of the recipe in 60 seconds. Run it locally; if any output other than the green row appears, **do not deploy**:

```sh
docker run --rm -e AGENTSITE_TOKEN=asit_test your-app \
  cat /etc/nginx/conf.d/default.conf | grep Bearer
```

| Output | Meaning |
| --- | --- |
| `Bearer asit_test` | ✓ Substitution worked. Ship it. |
| `Bearer ${AGENTSITE_TOKEN}` | Template at the wrong path. Confirm the `COPY` puts it at `/etc/nginx/templates/default.conf.template` (not `/etc/nginx/conf.d/`). Also confirm you haven't overridden the image's `ENTRYPOINT` / `CMD` — that skips `/docker-entrypoint.d/20-envsubst-on-templates.sh`. |
| `Bearer` (empty, no token) | Env var didn't reach the container (missing `-e AGENTSITE_TOKEN=...`), OR `NGINX_ENVSUBST_FILTER` regex doesn't match `AGENTSITE_TOKEN`. Confirm both. |
| (no `Bearer` line at all) | Token line missing from the template. Re-check §2 — the `proxy_set_header Authorization` line must be inside every `location` block that proxies to `api.agentsite.app`. |

Visit `http://localhost:8080/` in a browser — you see your SPA. Curl the same URL without a JavaScript engine — you see the head enriched with title, meta, JSON-LD, and a markdown body for AI crawlers.

### 3b. Apply — Bare-metal / VPS

Replace `<SITE_TOKEN>` in the §2 block with the literal token string from your dashboard, paste into your existing `server { }` directive in `nginx.conf` (or whichever file your distro's nginx loads), and reload:

```sh
nginx -t          # verify the config parses
nginx -s reload   # apply
```

That's the install. Visit a page in a browser — you see your SPA. Curl the same URL without a JavaScript engine — you see the head enriched with title, meta, JSON-LD, and a markdown body for AI crawlers.

### How it works

-   **Special paths** (`/robots.txt`, `/sitemap.xml`, `/llms*.txt`, `/.well-known/*`, `.md`): nginx forwards to agentsite, which generates the final bytes from your cached renders + any customer overrides you ship on disk.
-   **SPA HTML routes**: nginx serves real files (CSS, JS, images, etc.) from disk; on a miss (SPA route), it forwards to agentsite. agentsite reaches back to `<host>/<indexPath>` (default `/index.html`) to fetch your shell, runs `applyBundle` server-side, and returns the final injected HTML.
-   **Loop prevention via `X-Agentsite: none`**: agentsite carries this header on every outbound request to your origin — shell fetch, customer-source-fetcher probes for robots/sitemap/llms/well-known customer-wins, snippet probe, asset extraction. The Nginx config honors that header on every location and reroutes those requests to an internal `/__raw/` location that serves from disk (404 if not present). Without this branch, agentsite's customer-wins probes would loop indefinitely through nginx → us.
-   **Graceful failure**: if agentsite is unreachable, nginx falls back to the raw shell via `error_page 502 504 = @raw_shell`. Your site stays up; agents temporarily see the unenriched version.

### SPA shell path override

agentsite reaches back to `<host>/index.html` by default. If your build puts the shell elsewhere (e.g. `/build/index.html`, `/app.html`), set the **SPA shell path** on the site dashboard (Site settings → SPA shell path). Empty string or `/` means "fetch host root" (for setups that serve the shell from `/`).

### If your shell isn't a real file on disk

If your shell is generated dynamically at `/` rather than served from a real `/index.html` file, the bypass `try_files $uri =404` in the `/__raw/` block will 404 on shell reach-back. Tell nginx where to find the shell instead — replace the `/__raw/` block with:

```nginx
location ^~ /__raw/ {
    internal;
    rewrite ^/__raw(.*)$ $1 break;
    try_files $uri /index.html =404;   # falls back to your shell entry point
}
```

### Verifying the install

```sh
curl -sA 'AgentSiteBot/1.0' https://yoursite.com/ \
  | grep -E '<title>|og:description|application/ld\+json'
```

Updated `<title>`, `og:description`, and at least one `application/ld+json` block = install is working. If you see the unmodified shell, check that the `Authorization: Bearer <SITE_TOKEN>` header made it into every Nginx `location` block.

For offline verification, the integration repo ships a docker-compose fixture at `devops/pattern-f-test/`:

```sh
git clone https://github.com/agentsite/integration
cd integration/devops/pattern-f-test
docker compose up -d
./test.sh                  # 20-assertion curl matrix
docker compose down
```

## Local development & testing

You want to verify the install end-to-end before pointing your real domain at it. The honest version: **agentsite is a request-path runtime, so it needs your origin to be reachable from the public internet to fetch your SPA shell and render it**. There's no localhost-only mode for the full render path. What works today is a tunnel.

### The flow

1.  **Run your SPA locally** with the install applied — Dockerfile built, agentsite snippet wired in, server listening on `localhost:3000` (or whatever port your install uses).
    
2.  **Open a public tunnel to that port.** Any tunnel works; the three we've verified:
    
    -   **ngrok** (free tier; URL changes each restart on free):
        
        ```sh
        ngrok http 3000
        # → https://abcd-1234.ngrok-free.app
        ```
        
    -   **Cloudflare quick tunnel** (free, no signup):
        
        ```sh
        cloudflared tunnel --url http://localhost:3000
        # → https://random-words.trycloudflare.com
        ```
        
    -   **Tailscale Funnel** (stable URL, free for personal):
        
        ```sh
        tailscale funnel 3000
        # → https://your-machine.tail-scale.ts.net
        ```
        
3.  **Register the tunnel URL as a site** at [https://agentsite.app/dashboard/sites/new](https://agentsite.app/dashboard/sites/new) — use the full HTTPS tunnel URL (e.g. `https://abcd-1234.ngrok-free.app`) as the domain. Today the dashboard accepts these the same as any registered domain.
    
4.  **Set the env vars** on your local container with the tunnel URL:
    
    ```sh
    docker run --rm -p 3000:3000 \
      -e AGENTSITE_TOKEN=asit_xxxxxxxx \
      -e AGENTSITE_SITE=https://abcd-1234.ngrok-free.app \
      your-app
    ```
    
5.  **Verify** — hit the tunnel URL with a bot-like UA:
    
    ```sh
    curl -sA 'AgentSiteBot/1.0' https://abcd-1234.ngrok-free.app/ \
      | grep -E '<title>|og:description|application/ld\+json'
    ```
    
    Same success criteria as a production install — enriched `<title>`, `og:description`, at least one `application/ld+json` block.
    
6.  **Delete the test site** from the dashboard when you're done — it counts against your plan's site cap until you do.
    

### Caveats (it's not great, we know)

-   **Tunnel URLs change.** Free ngrok and Cloudflare quick tunnels give you a new hostname each run; you'll re-register or update the site each time. Tailscale Funnel and ngrok paid tier keep stable URLs.
-   **The tunnel adds a real-world hop.** Latency you see won't match production — usually 100–400ms slower per round-trip.
-   **Renders count against your plan units.** Test traffic draws from the same pool as production. Free tier is 1k units / week, which is plenty for verification.
-   **Test sites count against your plan site cap.** Today there's no "test site" flag — a tunnel registration is the same as a production registration. **A first-class test-site flow (no plan-cap cost, auto-expire, accept tunnel hostnames natively) is in `workspace/2026-05-18-test-site-flag-spec.md` — not yet shipped.** Until then, delete after testing.

### What we don't support yet

-   **Localhost-only testing without a tunnel.** The render path requires fetching the SPA shell from the URL you register; `localhost` and `127.0.0.1` are explicitly blocked at the snippet level (`AGENTSITE_TOKEN` is also auto-suppressed on those hosts, so the snippet returns the raw shell unmodified). This is by design — `localhost` rendering would require the renderer to run inside your network or expose secrets, neither of which is on the roadmap.
-   **A `--mock-api` mode.** Useful for snippet wiring tests (`does my Dockerfile actually copy the snippet?`) without exercising the render path. Not built; track interest in [mailto:[email protected]](mailto:[email protected]).

## Roadmap

**Coming soon — pre-release install available today.** Both work; we're still hardening the rough edges. Get in touch and we'll wire you up.

-   **Edge** — Fetch-API port (`agentsite-edge.mjs`) for Cloudflare Pages, Vercel edge, Netlify edge, and similar runtimes that don't run arbitrary Node. Drop in the platform's edge-middleware shell + the port. See [Edge install](#edge-install) at the bottom for the current shape.
-   **SDK — Streaming SSR** — pure-function library (`@agentsite/sdk`) you call from your framework's render hook: `head(path)`, `markdown(path)`, `sitemap()`, `robots(localRobots)`, `llmsTxt()`, `wellKnown(name)`. Aimed at streaming-SSR shops (React Server Components, Next.js App Router default, Nuxt 3 streaming, SvelteKit streaming, Suspense streaming) where the framework already runs at request time and a sidecar can't buffer the response without killing the streaming benefit. Tracking under SPEC Chunk 44.

**Further out — not yet in pre-release.** On the list when customer pull plus implementation cost line up.

-   **njs** — port of the snippet's local decision layer to nginx's njs runtime. Customer adds a few `js_import` / `js_body_filter` directives to an existing `nginx.conf` — no node sidecar, no edge function, no replacement of nginx. Architecturally inverted from **Nginx** (the shipped pattern, where agentsite runs everything and nginx forwards). Tracking under SPEC Chunk 45.
-   **CloudFront** — CloudFront Functions / Lambda@Edge shell that fits the same shape as **Edge** but lives inside the customer's AWS account, so the trust relationship and observability stay on their side. Waiting on customer pull. Spec at `workspace/2026-05-13-cloudfront-edge-install-spec.md`.
-   **Python** — middleware for Django / Flask / FastAPI (and similar request-cycle frameworks). Today these stacks have to run **Express-Sidecar** in front; a native middleware would let agentsite live inside the framework's process and avoid the extra container. Not greenlit; on the list because Python-served sites are a meaningful slice of the long-tail SSR audience.
-   **PHP** — PHP-FPM hook / WordPress plugin. Same shape as Python — native middleware so PHP shops don't need a Node sidecar. WordPress in particular has a natural plugin slot. Not greenlit; on the list to give us a credible answer when WordPress / Laravel shops ask.

These are roadmap entries, not commitments. Happy to bump priorities for a customer who needs one — get in touch.

## What next

-   **Get your token.** [https://agentsite.app](https://agentsite.app) — free tier covers one site, no card.
-   **Register your site.** The dashboard validates your domain and lets you set per-page settings (titles, schema types, skip patterns). **The `AGENTSITE_SITE` value you set in your deploy env must exactly match the URL you register here**, or the snippet's render calls will 401 and agentsite will silently degrade to serving the unenriched shell.

## Concepts

The install above ships the technical layers of [agent readability](/agent-readability). The framework the layers map to is documented in [the five layers of AEO](/five-layer-aeo). Two common pre-install failure modes — pages that look fine to a browser but fail Layer 1 — are diagnosed in [SSR-junk and bot walls](/ssr-junk-bot-wall); running those curl probes against your site before installing tells you what you're starting from.