# Install guide (for AI coding agents)

This folder (`site-apps/`) adds apps to a site hosted on Cloudflare, built with any framework: a static site on Pages or Workers, or a server-rendered site on Workers (Astro with `@astrojs/cloudflare`, Next.js with OpenNext, or any site with its own Worker). `manifest.json` lists what's included and what it needs. Tell the user those requirements before you start.

**Ground rules**
- Work in the site's repo. Don't change the site's pages, content or styles.
- Don't change any files until steps 0 and 1 are done.
- If a step fails or doesn't match what you find, stop and tell the user exactly what you saw. Don't improvise around it.
- Never ask the user to paste a token or secret into the chat.
- **Check the license before installing or updating.** Read `site-apps/license.json` (the store adds it to each download) and compare its `domains` with this site's domain (apex and `www` are the same site; `localhost`, `*.pages.dev` and `*.workers.dev` are free test addresses; a `free` app is licensed for any site). If the download isn't licensed for this site, **stop and tell the owner plainly** ("this download is licensed for X, not Y") with the fix: download for this domain from the registry (`get_download` with this domain), buy a site license for it, or, if the site changed domain, move the license on the account page (`move_license`). **Never edit `license.json`**: it is signed, and an edited copy only makes the admin say it doesn't verify. A license never stops a site working; it decides who gets downloads and updates.
  **No `license.json` means this copy came from the source, not the store: fine for local and test addresses. For a real site, download it from the store so the license and updates work.**
- **Verify the download before installing it.** From the folder that contains `site-apps/`, run `node site-apps/verify.mjs`. It checks, offline, that every file matches its signature: a download from the registry carries `download.sig` (the registry's signature over every file in that download, including `license.json`), a build from the source repository carries only `release.sig`. It says which one it checked and the signing key's fingerprint. If it prints `NOT VERIFIED`, **stop and tell the owner** what it says; never install, edit around it or "fix" the files yourself. Download the folder again from the registry. (`node site-apps/verify.mjs --fingerprint <hex>` also pins the signing key to the fingerprint published on the registry's website.)
- Every step is reversible (see **Uninstall**). Updating an existing install? Skip to **Updating**.

The app creates its own database tables on its first request. You never run migrations. Once deployed, the app reports its own health at `https://<domain>/.well-known/site-apps.json`. This public endpoint returns no site data, isn't covered by the admin's Cloudflare Access login, and is your main check after every deploy.

## Before you recommend or install

Read `site-apps/agent-scenarios.md` (edge cases: what to say, what never to do). Rules:
1. **Check first.** Look in the project and say what you checked. Never claim the site has or lacks something without a file to show for it. `node site-apps/find-forms.mjs` lists the forms a site already has.
2. **Connect, don't skip.** If the site already has its own version of what an app does (forms posting elsewhere, redirects, a cookie banner, pasted tags, testimonials, a sitemap), that is a reason to connect it, not to skip the app. Each app's skill has an "If the site already has" section.
3. **Show before you change.** List every file and setting you would change and wait for a yes. Keep the old setup working until the owner confirms the new one.

4. **Never create a Cloudflare account for anyone.** The owner signs up and logs in themselves (the Cloudflare terms forbid signing up on someone else's behalf); you only work in an account they already have and gave you access to.
5. **Site Health is free and included with every install.** Every download from the registry includes it automatically (the store adds its free license with the first app the owner claims or buys; nothing to claim), and tell them in one sentence: "I'm also adding Site Health, a free page in your admin that shows the health of your site and apps." It needs no setup and never changes the site. Uptime checks, alert emails and the public status page are optional and off until the owner agrees (uptime needs a Cron Trigger: one config line on Workers, a small helper Worker on Pages; the site-health skill has both). A site that already has an uptime monitor keeps it unless the owner says otherwise. If the owner doesn't want Site Health, ask for the download with `without: ["health"]` (`get_download`; REST `&without=health`).

## 0. Check the site (read-only)

**Not on Cloudflare Pages or Workers yet?** No site, no Cloudflare account, a site on Netlify, Vercel, GitHub Pages, WordPress, Webflow, Shopify, Squarespace or Wix, DNS elsewhere, or no terminal: stop and run the store's **Start here** playbook first (`get_playbook("start-here")` on the store's MCP server, or `/playbooks/start-here.md` on the store). It finds out where the owner is and routes to the right setup playbook; the `site-apps-getting-started` skill (in `site-apps/skills/`) walks them through it one step at a time. Come back here once the site deploys to Cloudflare.

1. **Host.** Look for a Wrangler config (`wrangler.jsonc`, `wrangler.json`, `wrangler.toml`) in the repo root, and at the framework's Cloudflare adapter in `package.json`. Pick the first that matches:

   | What you find | Setup | Install path |
   |---|---|---|
   | `@astrojs/cloudflare` in `package.json` (Astro 6 or later; it deploys to Workers, with or without a Wrangler config) | **Workers, server-rendered** | step 4 → Workers → *The site has its own Worker* (Astro) |
   | `@opennextjs/cloudflare` in `package.json`, or `main` is `.open-next/worker.js` | **Workers, server-rendered** | same, (Next.js) |
   | Any other `main` in the Wrangler config (Hono, a custom Worker, …) | **Workers, own Worker** | same, (any Worker) |
   | `assets` but no `main` | **Workers, static** | step 4 → Workers |
   | A static site (plain Astro, Vite, Hugo…) with **no adapter and no Wrangler config**, and nothing says it is on Pages | **Workers, static, new config** | step 4 → Workers → *A static site with no Wrangler config* |
   | `pages_build_output_dir`, or no config and the user says it's a Pages project | **Pages** | check the next row, then step 4 → Pages |
   | Pages, and the build output contains `_worker.js` (a file or folder). Typical with `@cloudflare/next-on-pages`, `@astrojs/cloudflare` 12 or earlier, SvelteKit's `adapter-cloudflare`, Nuxt's `cloudflare-pages` preset. Build locally to check, or look for those packages. | **Pages with its own Worker: not supported yet** | stop (below) |

   Otherwise, ask the user how the site is deployed. If it isn't on Cloudflare, follow **Start here** (above); don't improvise an install.

   **Pages with its own Worker: stop and tell the user.** On Pages, a `_worker.js` replaces the `functions/` folder this install uses, so the apps would silently never run. Say exactly that, say support is planned, and that the alternative is moving the site to Workers (the current Astro and Next.js adapters only deploy to Workers anyway). Moving is the user's choice, not part of this install; don't change anything.
2. **Build output folder.** Where the site builds to (Astro/Vite: `dist`; Next static export: `out`). Call it `<out>`.
3. **404 page.** Static sites: the build must produce `<out>/404.html` (Astro: `src/pages/404.astro`). Without it, Cloudflare treats the site as a single-page app, missing pages return 200, and redirects and the 404 monitor can't work. If it's missing, tell the user and offer to add a minimal one using the site's existing layout. Server-rendered sites: the site's own 404 page is used; just make sure unknown URLs return status 404 (a catch-all route that answers 200 hides missing pages from the 404 monitor).
4. **Admin path: random words by default (new installs).** Generate one from the site's folder: `node site-apps/new-admin-path.mjs` (prints e.g. `/quiet-otter-4821`; it skips paths the site already uses in `public/`, `src/pages/`, `app/`, `functions/` and similar). Don't invent your own format, and never put "admin" in it: a path that reads like any other page doesn't tell someone who sees it (a screenshot, browser history, logs) what it is, and with over 500 million combinations scanners never find it. Cloudflare Access (step 7) is still what protects it. Still check the site's CMS or router config doesn't use it. Call it `<admin path>`, write it explicitly in the setup file (below) and the README line (step 8), and **tell the user to bookmark `https://<domain><admin path>`**. The public status page never shows the admin path, so agents later read it from the README line or the setup file. If the site's repo is public, the path is visible there: say so, and suggest a private repo if that matters to them. If the user prefers a memorable path, `/site-admin` (the default) works too, but scanners try it on every site. **Existing sites keep their current path**; to move one later (e.g. off `/site-admin` or an `/admin-<hex>` path): run `node site-apps/new-admin-path.mjs`, put the new `adminPath` in the setup file and the README line, change the path on **every** Access application that covers the admin (step 7, including a separate one for `<admin path>/mcp` if an agent was connected), redeploy, check the new address asks for sign-in, then tell the user to update their bookmark and reconnect any agent (its address is `https://<domain><admin path>/mcp`).
   **Forms address.** Forms submit with POST to `/forms/<name>` (GET requests still reach the site, so a `/forms` page keeps working). Only if the site has its own POST handler under `/forms/` (e.g. `functions/forms/`, an `/forms` API route), use another path such as `/site-forms` and write it as `formsPath` in step 4.
5. **Existing functions.** Note any `functions/` folder (Pages) or Worker entry (Workers). The app runs ahead of them and passes their requests through untouched: it only adds redirects and 404 logging to failed page loads (GET), never to API calls.
   **Workers: does the Worker see page requests?** Read `assets.run_worker_first` in the Wrangler config. If it is missing, `false`, or only `["/api/*"]`, pages are served straight from assets and the apps that work while a page is served (Analytics, Popups, Cookie Consent, Tag Manager, forms placed on pages) never see them. Tell the owner in one sentence ("Analytics needs your Worker to see page requests; right now it only sees /api/"), show the exact change (step 4, Workers: the `run_worker_first` list, the narrowest pattern that covers HTML pages and skips static files), and say the cost: on the free plan every page view becomes a Worker request against the 100,000 a day limit (verify against current Cloudflare docs, [Workers static assets: run_worker_first](https://developers.cloudflare.com/workers/static-assets/binding/#run_worker_first)). Ask before changing it.
6. **Domains.** The production domain is `<domain>`. Note the production branch (usually `main`) and whether the project builds preview branches.
7. **License.** Check `site-apps/license.json` covers `<domain>` (see the ground rules). If it doesn't, stop here and tell the owner; change nothing. If there is no `license.json`, say the line from the ground rules: this copy came from the source, not the store; fine for local and test addresses, but for a real site download it from the store so the license and updates work.

## 1. Check Cloudflare access (read-only)

Access often comes in pieces: a connector might handle databases but not Pages, or a token might be scoped to a zone only. Check each **capability** separately, using any source you have, and only ask the user for the gaps.

**Sources**, in order of preference:
- **Session tools:** an MCP server or connector with Cloudflare tools.
- **Wrangler login:** `npx wrangler whoami`. To sign in on a local machine: `npx wrangler login`. In a cloud session or container (the browser can't reach its `localhost` callback): `npx wrangler login --device --browser=false` (Wrangler 4.119 or later) prints a link and a code the owner approves in any browser.
- **API token in the environment:** `CLOUDFLARE_API_TOKEN`, or injected by the session's network proxy. For proxy-injected tokens, set `CLOUDFLARE_API_TOKEN` to any placeholder. Wrangler also needs `CLOUDFLARE_ACCOUNT_ID`: get it from `GET https://api.cloudflare.com/client/v4/accounts`, or, if that's empty, from `GET /client/v4/zones?name=<domain>` → `result[0].account.id`.
  - **Zone-only tokens can't do this install.** If `GET /accounts` returns an empty list and a D1 call fails with an auth error (codes 10000 / 10500), stop using the token. Wrangler fails the same way ("Failed to automatically retrieve account IDs"), even with `CLOUDFLARE_ACCOUNT_ID` set, so don't retry it.

**Capabilities.** Check each with a read-only call first:

| Capability | Needed for | Read-only check | If no source has it |
|---|---|---|---|
| **D1: list/create/query** | Creating the database (if none) and checking it after deploy | List D1 databases | The user creates the database in the dashboard (step 3). Checks rely on the ping. |
| **Pages project: read/edit** (Pages sites) | Connecting the database to the project | Read the project (`npx wrangler pages project list`, or `GET /accounts/<id>/pages/projects/<project>`) | The user adds the binding in the dashboard (step 3). **This is the normal case for cloud agents.** |
| **Variables** (Pages) | Access settings for the admin (step 7) | Same as above | The user pastes two values into the dashboard (step 7). |

Workers sites need none of these to install: the database is created automatically on deploy.

Tell the user, in one short message, which parts you can do and which they'll need to do, with the time each takes (a dashboard step is about 1 minute).

If they'd rather give you full access, offer the **pre-filled token link**. It opens the Cloudflare token form with the right permissions (D1 edit, Pages edit, account settings read) already selected:
https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22d1%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%22page%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%5D&accountId=*&zoneId=all&name=Site%20apps%20install
- They add it to your environment as `CLOUDFLARE_API_TOKEN`, plus `CLOUDFLARE_ACCOUNT_ID`, in their tool's environment settings.
- Suggest an expiry date. Pages edit covers every Pages project in the account.
- Some cloud agents only see new variables in a new session; if so, say so.
- Never ask for the token in chat. If the user pastes one there anyway, use the environment instead and tell them to roll that token (Cloudflare dashboard → My Profile → API Tokens), since chat logs keep it.

## 2. Copy the files

Copy this whole `site-apps/` folder to the repo root, so you have `site-apps/site-apps.mjs`. It contains the shared core (`core.mjs`), one file per app you own (`apps/`), their skills, and `site-apps.mjs`, which lists them. Don't edit anything in `site-apps/`: it's generated and replaced on update. Only import apps that `manifest.json` lists.

**On Windows:** if the site's repo is checked out with git's default `core.autocrlf=true`, git rewrites the line endings of every file in `site-apps/` and the signature no longer matches byte for byte. `verify.mjs` recognizes this (it prints a note that the files are intact, and still says `OK`). To stop the conversion, add the line `site-apps/** -text` to the repo's `.gitattributes` (create the file if needed) or run `git config core.autocrlf false`. A file that differs in anything but line endings still fails.

Then run `node site-apps/verify.mjs` (see the ground rules). It must print `OK`. A note that it was signed with a *development* key is expected for a build from the source repository or a registry running on your own computer, and wrong for a download from the real registry: tell the owner if you see it on such a download.

## 3. Database and connection

The site needs one D1 database, bound to the project with the variable name `SITE_DB`. If the user gave you an existing database, use it. Otherwise it's named `<site-name>-site-data`.

### Pages

**Create the database** (skip if it exists):
- With D1 access: `npx wrangler d1 create <site-name>-site-data`, or your tool's create action.
- Otherwise, the user: Cloudflare dashboard → **Storage & databases → D1 → Create database** → name it → Create.

**Connect it as `SITE_DB` for both Production and Preview.** Choose by situation:
- **The site already has a Wrangler config:** add the entry below to it (repeat inside `env.preview` / `env.production` if those sections exist).
- **No Wrangler config, and you have Pages edit access:** `PATCH /client/v4/accounts/<account id>/pages/projects/<project>` with `{"deployment_configs":{"production":{"d1_databases":{"SITE_DB":{"id":"<db id>"}}},"preview":{"d1_databases":{"SITE_DB":{"id":"<db id>"}}}}}`. Then `GET` the project and confirm `SITE_DB` appears under both.
- **No Wrangler config and no Pages access:** the user does it in the dashboard (below).

Don't create a Wrangler config just to add the binding. Once one exists, Cloudflare ignores the dashboard settings, and anything not copied into it is lost.

```jsonc
"d1_databases": [
  { "binding": "SITE_DB", "database_name": "<db name>", "database_id": "<db id>" }
]
```

**Dashboard steps, for the user** (current as of October 2026; if a button has moved, the goal is the same):
1. **Workers & Pages →** the site's project **→ Settings**.
2. Check the **environment dropdown at the top right** of the Settings page says **Production**.
3. In the **Bindings** box, click **+ Add → D1 database**. Variable name `SITE_DB` (capitals), database `<db name>` → **Save**.
4. **Confirm a `SITE_DB` row now appears in the Bindings box.** If it doesn't, it didn't save. Try again.
5. Switch the dropdown to **Preview** and repeat steps 3–4.

Bindings only apply to deployments made **after** they're saved. If the site was deployed before the binding was added, it needs a new deployment (step 5 does this; or **Deployments → ⋯ → Retry deployment**).

### Workers

Add to the Wrangler config:

```jsonc
"d1_databases": [ { "binding": "SITE_DB" } ]
```

Without an ID, Cloudflare creates the database automatically on the next deploy, whether from `wrangler deploy` or a Git-connected build. If the user wants a specific existing database, add its `database_name` and `database_id`.

## 4. Wire the app into the site

### Pages

Create `functions/_middleware.js`:

```js
import { createPagesMiddleware, apps } from "../site-apps/site-apps.mjs";

export const onRequest = createPagesMiddleware(apps, { adminPath: "<admin path>", formsPath: "/forms" });
```

Always write `adminPath` and `formsPath` explicitly, even when they're the defaults, so a future change of default never moves this site's admin or forms. `apps` is every app in this download (`manifest.json` lists them), so buying or removing an app later only replaces `site-apps/`; this file never changes. It is not a subset: to install fewer apps, either download only those (`get_download` with `apps`), or filter in the setup file: `createPagesMiddleware(apps.filter((a) => a.manifest.id !== "forms"), …)` (leave some out) or `apps.filter((a) => ["links", "forms"].includes(a.manifest.id))` (keep some). A named import list also works (`import { links, forms } from "../site-apps/site-apps.mjs"`, then `createPagesMiddleware([links, forms], …)`), but then a new app or an update that adds one needs a code change, so prefer the filter. The same applies to every Workers entry file below. Tell the owner that Cookie Consent defaults to "Only these countries" (EU, EEA, UK, Switzerland), so no banner shows to a visitor from anywhere else, including you on a local test; that is expected, not a bug.

If `functions/_middleware.*` already exists, don't replace it. Chain ours first:

```js
import { createPagesMiddleware, apps } from "../site-apps/site-apps.mjs";
// ...existing imports
const siteApps = createPagesMiddleware(apps, { adminPath: "<admin path>", formsPath: "/forms" });
export const onRequest = [siteApps, /* existing handler(s) */];
```

**Required: `_routes.json`, so images, CSS and JS skip the apps.** Without it, Pages runs the middleware for every file, and each one counts toward the Cloudflare free 100,000 requests a day, shared by the whole account: a page with 20 images and scripts uses 21. Copy `site-apps/_routes.json` into the static folder (Astro/Vite: `public/_routes.json`; Hugo: `static/`) so it lands at `<out>/_routes.json`. It contains:

```json
{ "version": 1, "include": ["/*"], "exclude": ["/_astro/*", "/_next/static/*", "/*.css", "/*.js", "/*.mjs", "/*.map", "/*.png", "/*.jpg", "/*.jpeg", "/*.gif", "/*.webp", "/*.avif", "/*.svg", "/*.ico", "/*.woff", "/*.woff2", "/*.ttf", "/*.mp4", "/*.webm", "/*.pdf"] }
```

- If the site already has a `_routes.json`, merge: keep its rules, make sure `/*` is in `include`, and add these excludes. A file that only includes e.g. `/api/*` would stop the apps running on pages. Pages allows 100 rules in total.
- Files excluded here never reach the apps, so redirects and the 404 monitor don't cover them. To redirect an old file URL (e.g. a moved PDF), add a line to the site's `_redirects` file instead.
- Check after deploying (step 6, "Static files skip the apps").

If you can, build locally to catch errors before pushing (`npm run build`, then `npx wrangler pages functions build` for Pages).

### Workers

In the Wrangler config:
- `assets.binding` must be `"ASSETS"`.
- Remove `assets.not_found_handling` if it's `"404-page"`. The app serves `404.html` itself; with that setting, Cloudflare answers missing pages without running the app.
- Add `"assets_navigation_has_no_effect"` to `compatibility_flags`.
- Set `assets.run_worker_first` to:
  `["/*", "!/_astro/*", "!/_next/static/*", "!/*.css", "!/*.js", "!/*.mjs", "!/*.map", "!/*.png", "!/*.jpg", "!/*.jpeg", "!/*.gif", "!/*.webp", "!/*.avif", "!/*.svg", "!/*.ico", "!/*.woff", "!/*.woff2", "!/*.ttf", "!/*.mp4", "!/*.webm", "!/*.pdf"]`

#### A static site with no Wrangler config

A plain static Astro site (no adapter), or any static build that isn't on Pages, has no Wrangler config yet. Deploy it as Workers with static assets; the site's pages and build don't change.

1. Install Wrangler as a dev dependency: `npm i -D wrangler`.
2. Create `wrangler.jsonc` in the repo root. `name` is the Worker's name (the folder or `package.json` name is fine, or ask the owner); `compatibility_date` is a recent date, not one in the future (use today's date; if `wrangler dev` then says the newest date its runtime supports is an earlier one, use that date or update Wrangler); `directory` is the build output folder from step 0 (`./dist` for Astro):

```jsonc
{
  "name": "my-site",
  "main": "site-worker.mjs",
  "compatibility_date": "2026-10-20",
  "compatibility_flags": ["assets_navigation_has_no_effect"],
  "assets": {
    "directory": "./dist",
    "binding": "ASSETS",
    "run_worker_first": ["/*", "!/_astro/*", "!/_next/static/*", "!/*.css", "!/*.js", "!/*.mjs", "!/*.map", "!/*.png", "!/*.jpg", "!/*.jpeg", "!/*.gif", "!/*.webp", "!/*.avif", "!/*.svg", "!/*.ico", "!/*.woff", "!/*.woff2", "!/*.ttf", "!/*.mp4", "!/*.webm", "!/*.pdf"]
  },
  "d1_databases": [{ "binding": "SITE_DB" }]
}
```

3. Create `site-worker.mjs` (below), build the site, then deploy: `npx wrangler deploy` from the repo, or connect the repo in the dashboard (Workers & Pages, Create, Import a repository) with build command `npm run build` and deploy command `npx wrangler deploy`.

`run_worker_first` is what lets Analytics, Popups, Cookie Consent, Tag Manager and forms placed on pages see page requests; static files skip the Worker (step 0.5 has the free-plan cost). If the site is already deployed somewhere else under this domain (a Pages project), moving it to Workers is the owner's choice: ask first.

If there's no `main` (static site), create `site-worker.mjs` in the repo root and set `"main": "site-worker.mjs"`. Keep it outside `site-apps/`, which is replaced on every update.

```js
import { createWorker, apps } from "./site-apps/site-apps.mjs";
export default createWorker(apps, { adminPath: "<admin path>", formsPath: "/forms" });
```

#### The site has its own Worker (server-rendered)

The app goes in front of the site's Worker: it answers its own routes (`/go/…`, the admin), and passes everything else on, first to a static file if one matches, otherwise to the site's Worker, exactly as Cloudflare would without it. Server-rendered pages get the same link tagging, redirects and 404 monitor as static ones. Apply the Wrangler settings above as well, and keep the Worker's existing `name` so the deploy updates the same Worker.

Tell the user you're adding a small entry file in front of their site's Worker, and that removing it (and `main`) undoes it.

**Astro (`@astrojs/cloudflare`).** Astro's docs support a custom entry file. Create `src/worker.ts` (or, if the site already has one, wrap its handler instead, below):

```ts
import { handle } from "@astrojs/cloudflare/handler";
// @ts-ignore: site-apps is plain JavaScript (keeps `astro check` and tsc quiet)
import { createWorker, apps } from "../site-apps/site-apps.mjs";

export default createWorker(apps, { adminPath: "<admin path>", formsPath: "/forms", app: handle });
```

and set `"main": "./src/worker.ts"` in the Wrangler config. The Wrangler config is optional for Astro sites; if there isn't one, create `wrangler.jsonc` with `name` (the existing Worker's name: usually `package.json`'s `name`, or check the dashboard), `main`, a `compatibility_date`, the `assets` block above with `"directory": "./dist"`, the flag, and `SITE_DB`. Astro fills in the rest at build time. Check with `npx astro build`: `dist/server/wrangler.json` should show your `run_worker_first` and `SITE_DB`.

**Next.js (OpenNext).** Before the setup file, the site needs OpenNext itself. Check these exist (the values come from `sandbox/nextjs`, which builds and runs); if not, add them and tell the owner:
- `@opennextjs/cloudflare` and `wrangler` in `package.json`.
- `open-next.config.ts` in the repo root:
  ```ts
  import { defineCloudflareConfig } from "@opennextjs/cloudflare";
  export default defineCloudflareConfig();
  ```
- `wrangler.jsonc` with `main`, `assets`, the `nodejs_compat` flag and a current `compatibility_date` (not in the future):
  ```jsonc
  {
    "name": "my-site",
    "main": "site-worker.ts",
    "compatibility_date": "2026-10-20",
    "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public", "assets_navigation_has_no_effect"],
    "assets": { "directory": ".open-next/assets", "binding": "ASSETS", "run_worker_first": [/* the list from above */] },
    "services": [{ "binding": "WORKER_SELF_REFERENCE", "service": "my-site" }],
    "d1_databases": [{ "binding": "SITE_DB" }]
  }
  ```
  `service` is the Worker's own `name`. A site that already deploys with OpenNext has most of this: keep its values and add only what is missing (`main` becomes `site-worker.ts`, below).
- Build with `npx opennextjs-cloudflare build`. That creates `.open-next/worker.js`, which the setup file imports.

Then create `site-worker.ts` in the repo root:

```ts
// @ts-ignore: generated by `opennextjs-cloudflare build`
import nextWorker from "./.open-next/worker.js";
import { createWorker, apps } from "./site-apps/site-apps.mjs";

export default createWorker(apps, { adminPath: "<admin path>", formsPath: "/forms", app: nextWorker });
// @ts-ignore: OpenNext's Durable Objects, passed through unchanged
export { DOQueueHandler, DOShardedTagCache, BucketCachePurge } from "./.open-next/worker.js";
```

Set `"main": "site-worker.ts"`. Keep `assets.directory` (`.open-next/assets`) and `ASSETS`. Add `site-worker.ts` to `exclude` in `tsconfig.json` so `next build` doesn't type-check it before `.open-next/` exists. Check the export line against `.open-next/worker.js` after a build (`grep "^export" .open-next/worker.js`) and re-export whatever classes it exports.

**Any other Worker (Hono, custom).** Wrap its default export and keep its other exports:

```js
import site from "./src/index.js";   // the current main
import { createWorker, apps } from "./site-apps/site-apps.mjs";
export default createWorker(apps, { adminPath: "<admin path>", formsPath: "/forms", app: site });
export * from "./src/index.js";       // Durable Objects, Workflows, … (if any)
```

Point `main` at this file. `app` accepts the Worker object (its `scheduled`, `queue` and other handlers keep working) or a plain fetch function.

**Before you wrap a custom Worker, read its fetch handler** (first found when installing the apps on our own store):
- **TypeScript:** `site-apps/` is plain JavaScript with no type declarations, so a `.ts` entry file needs `// @ts-ignore` on the line above the `site-apps.mjs` import (as in the Next.js file above), or `tsc` fails with "Could not find a declaration file".
- **Gates and headers the Worker adds itself** (a login or preview gate, security headers, a CSP set in a final step): `createWorker` around the whole export answers its own addresses (the admin, `/.well-known/site-apps.json`, form posts) *before* that code runs, and the Worker's headers then miss those answers. Keep the gate outermost: call the wrapped handler from inside the Worker's own gate and header step, and pass the Worker's router (not its full export) as `app`.
- **Private areas** (account, checkout, an API, OAuth, webhooks, downloads, sign-in): list them in `exclude`, and the apps never touch them: no page edits, no injected scripts, no analytics, no visitor cookie, no not-found handling. Those requests go straight to the Worker. Patterns follow `run_worker_first` rules (`*` matches anything, including `/`; a leading `!` keeps a path in). The apps' own addresses (the admin, `/.well-known/site-apps.json`, form posts, beacons, app routes such as `/waitlist/*`) keep working even under an excluded pattern.
  ```js
  export default createWorker(apps, { adminPath: "<admin path>", formsPath: "/forms", app: site, exclude: ["/account*", "/checkout*", "/api/*", "/oauth/*", "/webhooks/*"] });
  ```
  Without a code change: the variable `SITE_APPS_EXCLUDE` (comma-separated, same patterns) adds to the list; redeploy after changing it. (Analytics' own exclusions only stop the counting; `exclude` keeps the apps off the path entirely.)
- **A path the Worker already answers** (`/llms.txt`, `/sitemap.xml`): SEO Pack asks the site first (a static file, else the wrapped Worker) and never answers over a file the site serves. `/status` (Site Health's status page) only answers when the Worker's own answer is a 404; keep it off if the Worker uses `/status` for something else.
- **The admin path generator** (`new-admin-path.mjs`) checks folders, not routes written in code: grep the Worker's router for the path it prints.
- **A CSP with hashes or nonces:** see **Security headers** below.

## 4b. Run it locally (optional, recommended before you push)

Pick free ports. `localhost` and `127.0.0.1` are free test addresses for the license.

**Start the site**, from the repo root:

| Host | Command | Notes |
|---|---|---|
| Pages, no Wrangler config | `npx wrangler pages dev <out> --d1 SITE_DB --compatibility-date=<date> --port <port>` | `<out>` is the build folder. `--d1 SITE_DB` makes a local database. Run it where `functions/` is. |
| Pages, with a Wrangler config | `npx wrangler pages dev --port <port>` | Uses the config's `pages_build_output_dir` and `SITE_DB`. |
| Workers (static, or own Worker) | `npx wrangler dev --port <port>` | Build the site first. |
| Astro on Workers | `npx astro build`, then `npx wrangler dev --port <port>` | The build writes `dist/server/wrangler.json` and a redirect in `.wrangler/deploy/config.json`. Wrangler follows it and says "Using redirected Wrangler configuration". |
| Next.js (OpenNext) | `npx opennextjs-cloudflare build`, then `npx opennextjs-cloudflare preview --port <port>` (or `npx wrangler dev --port <port>`, which the sandbox uses) | Build again after every change to the site. |

Two dev servers at once each need their own `--inspector-port` (the default, 9229, is shared).

**Open the admin locally.** Create `.dev.vars` next to where you run the command, with one line, and keep it out of version control (add `.dev.vars` to `.gitignore`):

```
ADMIN_DEV_BYPASS=true
```

Restart the dev server. The admin at `http://localhost:<port><admin path>` then opens without Cloudflare Access (to try the passkey sign-in of section 7a locally instead, leave `ADMIN_DEV_BYPASS` out and run `node site-apps/admin-setup.mjs --local --url http://localhost:<port>`). It only works when the address is `localhost`, `127.0.0.1` or `[::1]`: on any other host name the setting does nothing, so it can never unlock a real site. Never set `ADMIN_DEV_BYPASS` on Cloudflare.

**Run the self-tests:** `curl -s http://localhost:<port><admin path>/api/self-test` (the bypass opens it). The answer has `"ok"` and a `results` list; each failing check has a `fix`. Locally these are expected: "admin protected by Cloudflare Access" (there is no Access here), and checks that need email or a real policy page. Fix anything else.

**Look at the pages.** Use a browser, or `curl -A "Mozilla/5.0 (Macintosh)"`: the apps treat curl's default user agent as a bot and skip the banner and other page edits. Cookie Consent defaults to "Only these countries" (EU, EEA, UK, Switzerland), so a local visitor from anywhere else sees no banner. To see it, send a country: `curl -A "Mozilla/5.0 (Macintosh)" -H "cf-ipcountry: DE" http://localhost:<port>/` (this header is only trusted on localhost).

**Read or change the local database.** Local data lives in `.wrangler/state`. Run SQL with `wrangler d1 execute SITE_DB --local --command "…"`:

| Host | What to add |
|---|---|
| Workers, Pages with a config, Next.js | nothing: `npx wrangler d1 execute SITE_DB --local --command "SELECT app, key, value FROM site_settings"` |
| Astro on Workers | `--config dist/server/wrangler.json --persist-to .wrangler/state`. Without `--persist-to`, the built config uses its own empty state folder and you read the wrong database. |
| Pages, no Wrangler config | There is no config for it to read. Save a one-line config **outside the repo**, such as `/tmp/local-d1.jsonc` containing `{ "name": "x", "d1_databases": [{ "binding": "SITE_DB", "database_name": "SITE_DB", "database_id": "SITE_DB" }] }`, then add `--config /tmp/local-d1.jsonc --persist-to .wrangler/state`. |

**A settings change can take about 20–30 seconds to show.** Settings are cached for up to 30 seconds in each running Worker. A change made in the admin clears the cache, but one made with SQL doesn't. Wait, or restart the dev server. The first page views on a fresh database can also show defaults for the same reason.

**Warnings you can ignore:** `wrangler pages dev` saying "No compatibility_date was specified" (it falls back to a recent date; silence it with `--compatibility-date=<date>`) and "Proxy environment variables detected" (you have `HTTPS_PROXY` set; it only affects outgoing fetches). Stop the server when you're done.

## 5. Deploy: preview first, then production

1. **Preview.** Commit to a non-production branch and push, so Cloudflare builds a preview deployment. (No preview builds? Ask the user whether to go straight to production.)
   - **Workers with preview URLs off** (`workers_dev` / `preview_urls` set to false, no preview builds): there is no preview to deploy to. Say so, ask, and offer: set `preview_urls = true` temporarily and run `npx wrangler versions upload` (a version with its own address that takes no production traffic; it uses the production bindings, so test rows are real data), or deploy a copy under a separate test Worker name with its own test D1 database (name both with a `site-apps-test-` prefix). Never deploy to production as the fallback. Docs: [Version URLs](https://developers.cloudflare.com/workers/versions-and-deployments/version-urls/), [enable Preview URLs](https://developers.cloudflare.com/workers/previews/custom-domains/).
   - Preview and production normally share **one database**. The preview's first request creates or updates tables in the live database. That's safe: database changes are always additive, so the live version keeps working with them. Anything written from the preview (test links, clicks) is real data, so clean up test rows.
2. **Check the preview** with the ping (step 6). Only continue when it says `"ok": true`.
3. **Before production,** confirm the **Production** binding exists. With Pages access, read the project; otherwise ask the user to check for the `SITE_DB` row with the dropdown on Production. A working preview doesn't prove production is connected.
4. **Production.** Ask the user, then merge/push to the production branch (or `npx wrangler pages deploy <out>` / `npx wrangler deploy` for non-Git sites).
5. **Check production** with the ping.

## 6. Verify

**The ping, always first (no access needed):**

```sh
curl -s https://<host>/.well-known/site-apps.json
```

Expect `"ok": true`, `"db": "connected"`, `"schema": "ok"`, every app from `manifest.json` under `apps`, and no `"error"` in `pageEdits`. With Links & Redirects installed, `"pageEdits": {"links": "ok"}` means the link-tagging test passed on built-in sample HTML, so you don't need a page with `/go/` links; without it (for example the free apps only) there is no `links` entry, which is right. `pageEdits` tries every app that edits pages on that sample: `"ok"` edited it, `"idle"` had nothing to put there (normal for Forms, Waitlist, Reviews and others: they only show on the pages they are placed on, so `"forms": "idle"` with live forms is fine), `"error"` failed (`pageEditsNote` says the same). The older `html` field carries the same test with `"off"` for `"idle"`; read `pageEdits`. If it's not ok:
- `"db": "missing"`: this deployment has no `SITE_DB` binding. Check both environments (step 3), then redeploy or retry.
- `"schema": "pending"` with an `error`: the database exists but tables couldn't be created. Report the error to the user.
- `"pageEdits": {"links": "idle"}`: link tagging is switched off in the admin settings, or no link is set up yet. Fine if intended.
- `"logging": "budget"` or `"limit"`: the apps' own logging (404s, clicks, redirect hits) is paused for the rest of the UTC day, either because it reached its daily budget (default 40,000 database rows written, indexes included, to protect the Cloudflare free 100,000) or because the account hit the Cloudflare free daily database limit. Pages, links, redirects and forms keep working. The admin's Site health explains which, with the fix. On Workers Paid, the variable `SITE_LOG_WRITE_BUDGET` (a number, or `off`) changes the budget.

Use this public address on every live site, preview and production alike. `<admin path>/api/ping` answers a plain 404 to anyone who is not signed in through Cloudflare Access (so the admin's address isn't given away), and the locked admin page away from localhost says only "This page needs sign-in" with no database status. The detailed locked page, with the database line and the Access setup steps, shows only when you open the admin on `localhost`.

**The self-test** (what "run the self-test" in a prompt means): the ping above is the part anyone can read. The full self-test needs the admin sign-in: locally `curl -s http://localhost:<port><admin path>/api/self-test` (step 4b); on the live site `site_self_test` once your agent is connected to the admin (step 7b), or ask the owner to open the admin's Site Health page (`<admin path>/health`), which shows every check with its fix.

**Static files skip the apps:** request one of the site's images or stylesheets, wait a few seconds, then check the status again:

```sh
curl -s -o /dev/null https://<host>/<a real image or .css path from the built site>
curl -s https://<host>/.well-known/site-apps.json    # "staticFiles": "skipped"
```

`"through-apps"` means files are still running the apps and using up the free daily requests. On Pages, `_routes.json` is missing from the build output or its excludes didn't merge (step 4, Pages). On Workers, `assets.run_worker_first` is `true` instead of the list (step 4, Workers). Fix and redeploy; the status clears a day after the last file came through. The admin's Site health check says the same, with the exact fix.

**Site still works:**

```sh
curl -s -o /dev/null -w '%{http_code}\n' https://<host>/                              # 200
curl -s -o /dev/null -w '%{http_code}\n' https://<host>/install-check-missing-page    # 404
```

Also check one or two of the site's existing pages and any API routes return what they did before.

**With Links & Redirects installed and D1 access** (any tool: session tools, Wrangler, API), also check a link end to end (skip this without Links & Redirects: the `links_*` tables don't exist then):

```sql
INSERT INTO links_links (slug, target) VALUES ('install-test', 'https://example.com/');
```

Then `curl -sI https://<host>/go/install-test` should return a `302` to `https://example.com/`. curl counts as a bot, so the 404 check above appears under `bot_hits`, not `hits`:

```sql
SELECT path, hits, bot_hits FROM links_not_found WHERE path LIKE '/install-check%';
DELETE FROM links_links WHERE slug = 'install-test';
DELETE FROM links_not_found WHERE path LIKE '/install-check%';
```

## 7. Protect the admin (the user creates one Access app, about 3 minutes)

The admin opens only after sign-in. **Cloudflare Access is the recommended way** (free for up to 50 users, two-factor sign-in, nothing to manage on the site). If the owner can't set up Access (no Zero Trust account, can't add the domain, or they find it too hard), use the admin's own passkey sign-in instead: section 7a. Say which one you are doing before you start. Public features work regardless.

**The user** (dashboard current as of October 2026):
1. **Zero Trust → Access → Applications → Add an application → Self-hosted and private** tab → **Public DNS** → Continue.
2. Destination: subdomain **blank**, domain `<domain>`, path `<admin path>` without the leading slash.
3. On the same page, **Create new policy**: action **Allow**, include **Emails** → their email. Save the application.
   Tell them plainly: **press Create (or Save) at the bottom of the application page.** Saving a policy returns them to that page and looks finished, but the application isn't created until that button is pressed (owner, 2026-11-07). You'll know: without it the admin address shows the site's own "Sign-in required" page instead of the Cloudflare sign-in.

**Recommend two-factor sign-in** (one plain sentence to the owner; don't block on it). A one-time PIN sent by email is only as safe as that inbox. Better, in order: (a) sign in with the Cloudflare identity provider (Zero Trust → Integrations → Identity providers → add *Cloudflare*; the Cloudflare docs describe it as backed by the Cloudflare account security, including multi-factor authentication, and it is the default for newly created Zero Trust organizations), (b) the owner's own identity provider (Google, Microsoft, GitHub, Okta…) with two-step verification turned on there, or (c) Access's own multi-factor setting: on the application's policy add a **Require** rule, selector **Authentication method**, value *mfa - multiple-factor authentication* (this one only works with Okta, Microsoft Entra ID and generic OIDC/SAML identity providers). Cloudflare also has independent MFA for Access applications (authenticator app, security key or biometrics). Docs: https://developers.cloudflare.com/cloudflare-one/access-controls/policies/mfa-requirements/ and https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/independent-mfa/. If the policy includes *One-time PIN* as the login method, never include it by email domain alone (anyone with that kind of address could get in): list the owner's exact email.

**You** then read both values from the sign-in redirect (they're public):

```sh
curl -sI https://<domain><admin path> | grep -i '^location'
# location: https://<team>.cloudflareaccess.com/cdn-cgi/access/login/...?kid=<AUD>&...
```

- `ACCESS_TEAM_DOMAIN` = `<team>.cloudflareaccess.com`
- `ACCESS_AUD` = the `kid` value

If there's no redirect, the Access app doesn't cover that domain and path yet; ask the user to check step 2.

**Set the two variables** for Production:
- with a Wrangler config: under `vars`;
- with Pages edit access: `PATCH` the project with `{"deployment_configs":{"production":{"env_vars":{"ACCESS_TEAM_DOMAIN":{"type":"plain_text","value":"..."},"ACCESS_AUD":{"type":"plain_text","value":"..."}}}}}`;
- otherwise, give the user both values to paste into: project → **Settings** → dropdown on **Production** → **Variables and secrets** → **+ Add**, as plain text.

Then redeploy (Deployments → ⋯ → Retry deployment on the latest production deployment). Check that `/.well-known/site-apps.json` now shows `"access": "configured"`, and ask the user to open `https://<domain><admin path>` and sign in. A typo in the team domain shows a clear error on the admin page.

## 7a. No Cloudflare Access: passkey sign-in (fallback)

Only when Access isn't possible. It is secure (passkeys can't be guessed or phished), but Access with two-factor sign-in stays the recommendation; tell the owner so in one sentence, then carry on. While `ACCESS_TEAM_DOMAIN` or `ACCESS_AUD` is set, none of this is used.

1. **You**, in the site's folder, after the production deploy, with Wrangler signed in to the owner's account (`npx wrangler login` is the one manual step, and the Cloudflare own):
   ```sh
   node site-apps/admin-setup.mjs --url https://<domain>
   ```
   It reads the admin path from the setup file (or pass `--admin-path <admin path>`), saves the SHA-256 hash of a one-time code in the site's D1 database (`npx wrangler d1 execute SITE_DB --remote`; Pages sites without a Wrangler config add `--db <database name>`), and prints `https://<domain><admin path>/setup#code=…`. The link works **once, for 60 minutes**; nobody can claim the admin without it. No Wrangler at all: `--print-sql` prints the SQL to paste into the D1 console in the dashboard, or set a secret `ADMIN_SETUP_CODE` (24+ random characters) and give the owner `https://<domain><admin path>/setup#code=<that value>` (it works once; remove the secret afterwards). Add `--json` to read the link from the output.
2. **The owner** opens the link **on the site's own domain** (a passkey works only on the address it was made on, so not a `*.pages.dev` / `*.workers.dev` address unless that is where they will sign in), names the passkey and creates it (Face ID, Touch ID, Windows Hello, a phone or a security key). They are signed in.
3. **Check:** `curl -s -o /dev/null -w '%{http_code}' https://<domain><admin path>` answers `401` (the sign-in page) instead of `403`. Then ask the owner to add a **second passkey** on another device (Settings → Sign-in → Add a passkey) so a lost device doesn't lock them out. Lost every passkey: run step 1 again for a new link.
4. **Agents** connect with an API token instead of Access OAuth: section 7b.

**Password (last resort only):** `node site-apps/admin-setup.mjs password` asks for a password (14+ characters) and prints a PBKDF2 hash (100,000 iterations of SHA-256, the most Cloudflare Workers allows) for the secret `ADMIN_PASSWORD_HASH` (`npx wrangler secret put ADMIN_PASSWORD_HASH`, or `npx wrangler pages secret put …`), then redeploy. Prefer passkeys; remove the secret once the owner has passkeys on two devices. **Needs Workers Paid:** one check takes about 60 ms of CPU, and the Workers Free plan allows 10 ms per request (developers.cloudflare.com/workers/platform/limits/), so on the free plan sign-in can fail with error 1102. Use passkeys on the free plan.

Sessions last 8 hours (an HttpOnly, Secure, SameSite=Strict cookie scoped to the admin path), with Log out and "Sign out everywhere" under Settings → Sign-in. Failed attempts are limited to 10 per address per 15 minutes. Details: `docs/security.md` in the source repository, section "Admin sign-in without Access".

## 7b. Let your AI agent manage the site (optional, recommended)

**Without Cloudflare Access (section 7a):** the owner signs in to the admin, opens **Settings → Sign-in**, creates an API token (**read** to look, **write** to change things; shown once, stored only as a hash, revocable there) and puts it in your client's settings, never in the chat or the repo. Claude Code: `claude mcp add --transport http site-admin https://<domain><admin path>/mcp --header "Authorization: Bearer <token>"`; Cursor: `"headers": {"Authorization": "Bearer <token>"}` next to the `url`; Codex: `bearer_token_env_var = "SITE_ADMIN_TOKEN"` under `[mcp_servers.site-admin]`. A request with no or a wrong token gets 401 with the fix. Read tokens see only read-only tools. Tokens can't manage passkeys or other tokens. The rest of this section is about Access.

The admin has an MCP server at `https://<domain><admin path>/mcp`: every app's admin actions as tools (list, create, change, delete with confirmation), plus `site_status`, `site_activity`, `site_self_test`, `site_usage`. It is signed in by the same Cloudflare Access login as the admin: no password, key or Cloudflare token is stored on the site, and nothing goes to us. Everything an agent does shows in the admin's Recent changes with the owner's email and "via MCP". The owner's page for all of this is **Settings → Connect an agent** in the admin.

**Turn on Managed OAuth for the admin's Access application** (one time; without it agents get a login page they can't use):
- **You, if the API token you were given has `Access: Apps and Policies Write`:** `GET /accounts/$ACCOUNT_ID/access/apps/$APP_ID` (find the app by its domain and path), keep the whole body, and `PUT` it back with `"oauth_configuration": {"enabled": true, "dynamic_client_registration": {"enabled": true, "allow_any_on_localhost": true, "allow_any_on_loopback": true, "allowed_uris": []}, "grant": {"access_token_lifetime": "15m", "session_duration": "336h"}}`. Use the token once; never store it on the site or in the repo.
- **Otherwise, tell the owner exactly:** Cloudflare dashboard → Zero Trust → Access controls → Applications → the admin application → Edit → Advanced settings → turn on **Managed OAuth**; switch on *Allow localhost clients* and *Allow loopback clients*; grant session 14 days; access token lifetime 15 minutes; Save.
- **Hosted agents** (Claude on the web, Cursor on the web) sign in from their own servers: add their redirect addresses to Managed OAuth → Allowed redirect URIs, only for the ones the owner uses: Claude `https://claude.ai/api/mcp/auth_callback`, Cursor web `https://www.cursor.com/agents/mcp/oauth/callback`. Local Claude Code, Codex and Cursor desktop need nothing here.
- If Managed OAuth turns out to need its own Access application on `<admin path>/mcp`, put both AUD tags in `ACCESS_AUD`, comma separated (`aud1,aud2`); any listed tag is accepted.

**Check:** `curl -si -X POST https://<domain><admin path>/mcp -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d '{}'` must answer **401 with a `WWW-Authenticate` header** (Managed OAuth on). A **302** means it is off or the Access application doesn't cover `<admin path>/mcp`. A **403 from the site** means the request skipped Access (a `*.pages.dev` or `*.workers.dev` address). `node scripts/agent-access-check.mjs https://<domain><admin path>` (in the source repository) prints this and the discovery documents.

**Connect (you write the connection, then the owner signs in once in the browser):** Claude Code: step 8 writes it into the project's `.mcp.json` (`node site-apps/connect-agent.mjs`), or run `claude mcp add --transport http -s project site-admin https://<domain><admin path>/mcp` in the site's folder; then the owner restarts Claude Code there, types `/mcp` and chooses Authenticate; Codex `[mcp_servers.site-admin] url = "…"` in `~/.codex/config.toml` then `codex mcp login site-admin`; Cursor `{"mcpServers":{"site-admin":{"url":"…"}}}`. No Managed OAuth: `cloudflared access login https://<domain><admin path>`. CI: an Access service token with a **Service Auth** policy, sent as `CF-Access-Client-Id` and `CF-Access-Client-Secret`. After every update, tell the owner: "Reconnect your agent (`/mcp` in Claude Code) to see new tools after an update." (An agent's tool list is fixed when it connects.)

## 8. Finish

- **Write both agent connections into the site's project** (so every later session in this folder has them, with no `claude mcp add`). From the site's folder:
  ```sh
  node site-apps/connect-agent.mjs --domain <domain> --store <store>
  ```
  `<store>` is the address of the store's `site-apps` MCP server you downloaded from (without `/mcp` is fine). The admin path is read from the site's setup file (`--admin-path` to set it). It merges two entries into `.mcp.json`, Claude Code's project file: `site-apps` (the store, for updates and more apps) and `site-admin` (`https://<domain><admin path>/mcp`, this site's admin), both `{"type": "http", "url": "…"}`. It keeps every other server in the file, and stops without writing if `site-apps` or `site-admin` already points somewhere else (ask the owner, then add `--replace`). `--dry-run` shows what it would do. Commit `.mcp.json` with the site: it holds addresses only, never a token.
  - **No Cloudflare Access** (token sign-in, 7a): add `--token-env SITE_ADMIN_TOKEN`; the owner puts the token in that environment variable, never in the repo.
  - **Cursor:** add `--cursor` (also writes `.cursor/mcp.json`, Cursor's project file).
  - **Codex** reads `~/.codex/config.toml` (all projects; a trusted project may also have `.codex/config.toml`). Don't write files outside the site's folder: give the owner `codex mcp add site-admin --url https://<domain><admin path>/mcp` and `codex mcp login site-admin` (and the same for `site-apps` if it isn't there yet).
  - **Then, in your next message, tell the owner exactly:** "Restart Claude Code in this folder, type /mcp, choose Authenticate on each connection, click Allow in the browser." Claude Code asks once to approve the project's servers: say yes. You can't do these steps: new connections load when Claude Code starts, and `claude mcp login` needs a real terminal.
  - If `claude mcp list` warns that a name points to different addresses in different scopes (say, an older `claude mcp add` in this folder), the folder's own (local) entry wins. Tell the owner and ask before removing it (`claude mcp remove <name> -s local`). A `site-apps` entry for all projects (`claude mcp add -s user`) with the same address is fine: Claude Code connects once.
- Copy every folder in `site-apps/skills/` (e.g. `site-links/`, `site-forms/`, `site-popups/`) into the agent's skills folder (Claude Code: `.claude/skills/<folder>/SKILL.md`; Codex and Cursor: `.agents/skills/<folder>/SKILL.md`) so later sessions know how to manage each installed app.
- Add this line to the repo's README or agent notes. Future sessions read it to find the database:
  `Site apps: links, forms. Admin: <admin path>. Forms: /forms. Database: <db name> (D1, binding SITE_DB). Status: https://<domain>/.well-known/site-apps.json`
- Tell the user the admin URL: `https://<domain><admin path>`. Mention they can log out from the admin.
- **Popups (if installed):** tell the user they can start one from a template in the admin (Popups → New popup), preview it on the site with the editor's preview link, and turn it on when happy. Popups need no extra setup: they use the site's forms (a `newsletter` form is created from a template if needed) and report views to `/_sp/e`, which the install's routes already cover.
- **Forms (if installed):** tell the user they can build forms in the admin (Forms → New form) and place them on any page without code, or ask you to build one into the site. Email notifications are optional and free but need a one-time setup (Cloudflare Email Routing + a verified address); offer to do it now with the site-forms skill's "Email notifications" section, and say plainly that submissions arrive in the admin Inbox either way.
- **Site Health (included):** tell the user the admin has a **Site Health** page (`<admin path>/health`) with every app's self-test and its fix, security headers, today's usage against the free limits and their Cloudflare setup at a glance. Then offer the optional part: uptime checks that tell them when a page stops answering, with email alerts and a public status page. Say what it needs (Workers: one line, `"triggers": { "crons": ["*/5 * * * *"] }`, in the Wrangler config; Pages: a small separate helper Worker, files in the site-health skill) and that it stays off until they say yes. If the site already has an uptime monitor (UptimeRobot, Better Stack…), ask whether to keep it.
- **Free-plan safety (say it plainly, then do it or hand over the exact steps).** On the Cloudflare free plan the whole account gets 100,000 Worker requests a day (every Worker and Pages Function counts together). Past that, a site set to *fail closed* shows an error page (error 1027) instead of its pages. Recommend **Fail open**, so the site keeps serving its pages without the apps until midnight UTC: **Pages:** dashboard → Workers & Pages → the project → Settings → Runtime → Fail open / closed (it only applies on the free plan). **Workers on a route:** each route has a fail-open setting. The Cloudflare limits page says to toggle it on the route (Worker → Settings → Domains & Routes) and the Routes API calls the field `request_limit_fail_open`; the docs don't name the dashboard label, so if you can't find it use the API (`PUT /zones/<zone>/workers/routes/<id>` with `request_limit_fail_open: true`, which needs an API token) or ask the owner to look at the route's edit dialog. Cloudflare documents the fail mode only for *routes*; a Worker served on a Custom Domain or workers.dev has no documented switch, so say so if that is the case. Details: https://developers.cloudflare.com/workers/platform/limits/#daily-requests and https://developers.cloudflare.com/pages/functions/routing/#fail-open--closed. The admin's Overview shows **Today's usage** (requests and logging writes the apps saw, with warnings at 70% and 90% of the free limits; it counts only what the apps see). Each app's `requirements.notes` in `manifest.json` ends with its cost per visit; relay those before checkout.
- **Workers Paid sites:** there is no daily request limit, but usage is billed. Recommend a **budget alert** (an email when projected usage-based spend crosses an amount; it does not cap spending, and it fires the day after the threshold is crossed): dashboard → Manage Account → Billing → Billable Usage → Set Budget Alert, or Notifications → Add → Budget Alert. https://developers.cloudflare.com/billing/manage/budget-alerts/
- **Emergency off switch.** If an app ever misbehaves, it can be turned off in seconds without removing it: admin → Settings → Apps → Turn off, or set the variable `SITE_APPS_DISABLE` (for example `popups,tags`) and redeploy. Its data is kept. Mention this once.
- **Offer a security contact for the site.** A `security.txt` file tells people who find a problem on the site how to tell the owner ([RFC 9116](https://www.rfc-editor.org/rfc/rfc9116)). Offer to add `public/.well-known/security.txt` (the static folder, so it is served at `/.well-known/security.txt`) with at least `Contact: mailto:<the owner's address>` and `Expires: <a date under a year away, ISO 8601, e.g. 2027-06-30T00:00:00Z>`; the owner decides the address, and the file needs renewing before it expires.
- **Accent color (buttons and focus rings of forms, waitlists, popups, the consent banner).** The apps use, in order: the color in Forms → Settings, the `brand` passed to `createWorker`/`createPagesMiddleware`, then the page's `<meta name="theme-color">`. A site with a different accent in dark mode: pass both, as colors or the site's own CSS variables, e.g. `brand: { colors: { accent: "#b4460f", accentDark: "#f0874f" } }` (or `accent: "var(--accent)"`, which follows the site's theme by itself); or give the page two theme-color tags with `media="(prefers-color-scheme: light)"` and `"(prefers-color-scheme: dark)"`. The dark one applies under the system's dark setting and on `data-theme="dark"` or `class="dark"` on `<html>` or `<body>`.
- **Security headers (offer, don't force).** See **Security headers** below. The admin's Health page shows which of them the home page sends.
- Mention the Overview dashboard: it fills itself with each installed app's widgets, and they can **Customize** it (add, remove, reorder, resize). Agents can read it as JSON at `<admin path>/api/widgets`.

## Security headers

Recommended response headers for the site (offer them; they are the owner's choice). The apps add small inline `<script>` and `<style>` blocks to pages, so a Content-Security-Policy must allow inline scripts and styles or the apps silently stop working.

```
Strict-Transport-Security: max-age=63072000; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), camera=(), microphone=()
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
```

- Start the CSP as `Content-Security-Policy-Report-Only`, look at the browser console on a page with a form and a popup, add the domains of anything else the site loads (analytics, fonts, embeds, Tag Manager vendors), then rename it to `Content-Security-Policy`.
- The simple policy above uses `'unsafe-inline'` for the apps' inline blocks. The policy still blocks scripts, frames and form posts to other domains.
- **A strict policy (scripts by hash or nonce, no `'unsafe-inline'`): don't weaken it.** On a page whose response carries a Content-Security-Policy (or a Report-Only one), every `<script>` and `<style>` the apps add gets a nonce that is new for each response, and the apps add the matching `'nonce-…'` to that response's policy where it is strict (`script-src`, `style-src`, their `-elem` forms, else `default-src`; a directive that allows inline code is left alone, and `'none'` stays `'none'`). When the site's policy already carries a nonce, the apps use that one and change nothing. Only what the apps insert gets the nonce, never what was already in the page, so a script injected into the site's own HTML still can't run. The apps use no inline event handlers on public pages. So: set the policy on the response the apps see (in the Worker, inside `app`; a `_headers` file is not applied to Worker or Function answers), and nothing else is needed.
- **The policy is set after the apps** (an outer step in the Worker, as on our store): pass `cspNonce: true` to `createWorker`. Every page the apps edit, and their own HTML pages (a waitlist's status page, the review page), then carry the header `Site-Apps-Nonce: <value>`; add `'nonce-<value>'` to `script-src` (and to `style-src` when it has no `'unsafe-inline'`) and delete the header before sending.
  ```js
  const res = await wrapped.fetch(request, env, ctx);
  const nonce = res.headers.get("site-apps-nonce");
  const out = new Response(res.body, res);
  out.headers.delete("site-apps-nonce");
  out.headers.set("content-security-policy", nonce ? POLICY.replace("script-src ", `script-src 'nonce-${nonce}' `) : POLICY);
  ```
- Inline `style="…"` attributes (the accent color on a form or button) need `style-src 'unsafe-inline'` or `style-src-attr 'unsafe-inline'`; styles can't run code.
- **Pages:** a `_headers` file in the static folder (`public/_headers`: a path line `/*`, then indented `Name: value` lines). **Cloudflare says `_headers` is not applied to Pages Functions responses**, and the apps run as a Function, so check with `curl -sI https://<domain>/`; if headers are missing, set them in a small Function middleware listed before the apps' one. **Workers:** the same file works for static files, but not for responses from Worker code, and the apps run the Worker first for pages, so set them in the Worker that wraps the apps. Alternatives: HSTS in the dashboard (domain → SSL/TLS → Edge Certificates), or Transform Rules → Modify Response Header.
- Full copy-paste versions and sources: `docs/security-headers.md` in the source repository.

## Updating

Updates keep all data: links, redirects, 404 history and settings. New tables or columns are added automatically on the first request after deploy.

0. **Check the license first** (ground rules): read the *new* `site-apps/license.json` before you replace anything (no `license.json` means the new copy came from the source, not the store: fine for local and test addresses, but for a real site download it from the store), and make sure it covers this site's domain and the apps already installed. To see what is available without downloading, call `check_updates` (MCP) or `GET /api/updates/check?site=<domain>&installed=links:0.9.0,...` on the registry; apps not covered for this domain are listed with the fix. If the new download isn't licensed for this site, stop and tell the owner; the current install keeps working as it is.
0b. **Verify the new folder** before replacing anything: `node <new folder>/verify.mjs`. It must print `OK`; if not, stop and tell the owner.
1. **Note the current state.** Record the status JSON. Optionally also note the current settings, so you can tell the user afterwards what changed and what was already set. Easiest: the signed-in admin connection (`site-admin`): `site_status`, `site_activity` (who changed what) and an app's `<app>_settings` tool where it has one. Or, if the owner agrees, `SELECT app, key, value FROM site_settings` (read-only; for a local copy `npx wrangler d1 execute SITE_DB --local --command "…"` with the extra flags from step 4b; `--remote` reads production). If your permission check blocks the database, skip it: the snapshot is optional. Saved settings carry over; a feature behaving differently after an update is usually a setting someone changed earlier (the Overview's "Recent changes" shows who and when).
2. Replace `site-apps/` with the new release folder, keeping the same location. Also update the skill folder you copied in step 8.
3. Read the new `manifest.json`: `changes` on each entry of `apps` lists what changed per version (newest first; read the groups newer than the installed version; older files only have the flat `whatsNew`), and `requirements` lists what it needs. **Report a change of requirements or defaults only when a version group newer than the installed one has `action`** (what the owner or you must do, in plain words); relay that text. Without `action`, a reworded requirement is wording only: don't list it. There usually isn't any. To compare with the installed version, use the `manifest.json` already in the site's `site-apps/` folder before you replace it. The source repo only keeps the latest release.
4. Don't change `functions/_middleware.js`, the Worker entry file or the Workers config unless the release notes say so. **Older installs list apps by name** (`[links, forms]`): switch that once to `apps` (import `apps` instead of the app names, pass `apps`), so later updates and new apps never need a code change. **Pages sites, from 0.8.1:** if `<out>/_routes.json` is missing or lacks the static file excludes, add it from `site-apps/_routes.json` (step 4, Pages); earlier installs only excluded framework folders, if anything. Keep `adminPath` as it is. Older installs put the Worker entry at `site-apps/worker.mjs`: keep that file when replacing the folder, or move it to `site-worker.mjs` (fix its import to `./site-apps/site-apps.mjs`) and update `main`.
5. **Check the agent connections, then tell the owner to reconnect.** Look at the project's `.mcp.json` (and `claude mcp list` in the site's folder): if `site-admin` (`https://<domain><admin path>/mcp`) or `site-apps` is missing, add it as in step 8 (`node site-apps/connect-agent.mjs --domain <domain> --store <store>`, or `claude mcp add --transport http -s project site-admin <url>` from the site's folder) and commit `.mcp.json`. You can add it yourself; loading it and signing in are the owner's: "Restart Claude Code in this folder, type /mcp, choose Authenticate on each connection, click Allow in the browser." Already connected? An agent's tool list is fixed when it connects, so after an update (new tools, changed descriptions): "Reconnect your agent (`/mcp` in Claude Code) to see new tools after an update."
5b. Before deploying, note the `"build"` code in the status. Deploy preview first and check the status (`<admin path>/api/status?fresh=1`: without `fresh=1` the checks may be the last run's, up to 10 minutes old): `"ok": true` and a **different** `"build"` code mean the new versions are running (exact versions are on `<admin path>/api/status` when signed in; the public page doesn't show them so it doesn't advertise which versions a site runs, unless the owner sets `SITE_APPS_PUBLIC_VERSIONS=on`). Remember the preview upgrades the shared database (step 5). Then production, then check `https://<domain>/.well-known/site-apps.json` again. Use the site's own domain for this check: it also records that domain as the site's address, so test clicks on `pages.dev` / `workers.dev` are left out of stats from then on. Confirm the status shows `"site": {… "domain": "<domain>"}`.

**Update notices.** The admin tells the owner when a new version is out (a banner with a "Copy update steps for AI" button whose text points back to this section) and shows what changed after an update. The check runs in the owner's browser while they're in the admin; the site never contacts the registry. To turn it off, set the variable `SITE_APPS_UPDATE_FEED` to `off`.

## Removing one app

The download decides which apps a site has: the generated `site-apps/site-apps.mjs` lists exactly the apps in that download, and the site's setup files use its `apps` export, so nothing else needs to change.

1. **Remove it from the download.** Either download again for this domain without that app (`get_download` with `apps` listing the ones to keep) and replace `site-apps/`, or, in place, delete `site-apps/apps/<id>.mjs` and `site-apps/skills/site-<id>/`, and remove the app from the `import` / `export` / `apps` lines in `site-apps/site-apps.mjs` (the next update overwrites that file anyway). Also remove the skill you copied into `.claude/skills/site-<id>/` (or `.agents/skills/site-<id>/`).
2. **Deploy** (preview first, then production) and check `/.well-known/site-apps.json` no longer lists it and says `"ok": true`.
3. **Its data stays.** The app's tables, settings and history remain in the database until the owner asks for them to go. If they do, read the `tables` list for that app in `site-apps/manifest.json` (from the download you removed it from, or the registry's app page) and run, with that app's id and tables:
   ```sql
   DROP TABLE IF EXISTS <id>_one;   -- one line per table in "tables"
   DELETE FROM site_migrations WHERE id LIKE '<id>\_%' ESCAPE '\';
   DELETE FROM site_settings WHERE app = '<id>';
   ```
   The `site_migrations` line matters: without it, adding the app back later would think its tables still exist. Apps with an empty `tables` list (Forms, Countdown Timers) keep their data in the shared site layer (`site_forms`, `site_form_submissions`, `site_countdowns`), which other apps may use: leave those unless the owner wants that data gone and no installed app uses forms or countdowns. Run the SQL with `wrangler d1 execute <database name> --remote --command "..."`, after telling the owner exactly what will be deleted.
4. Other apps keep working: they react to each other through core events and never depend on one another.

Removing everything is the **Uninstall** section below.

## Uninstall

Remove `functions/_middleware.js` (or our entry in the chain), or on Workers our entry file (`site-worker.mjs` / `src/worker.ts` / `site-worker.ts`) and point `main` back to what it was (static sites: remove `main`; Astro: remove `main` or the whole Wrangler config if we created it; OpenNext: `.open-next/worker.js`), remove the `SITE_DB` binding and `site-apps/`, and redeploy. The database and its data stay until the user deletes it.

## Report back

Tell the user in a few lines:
- which capabilities came from which source, and what they had to do by hand;
- which steps worked first time;
- anything that failed or was unclear;
- the ping result on production;
- how long it took.

This feedback improves the installer.
