diff --git a/.claude/agents/headless-developer.md b/.claude/agents/headless-developer.md
new file mode 100644
index 0000000..c849116
--- /dev/null
+++ b/.claude/agents/headless-developer.md
@@ -0,0 +1,171 @@
+---
+name: headless-developer
+description: Use this agent for headless WordPress setup and decoupled-frontend integration. Specializes in installing and configuring WPGraphQL, wiring CORS and preview links via the flavian-headless mu-plugin, scaffolding Next.js frontends, and troubleshooting GraphQL/REST endpoints. Examples - Context: User wants to start a headless project. user: 'Set up headless WordPress with a Next.js frontend.' assistant: 'I'll use headless-developer to run setup-headless.sh, scaffold a Next.js app under frontend/, and wire the preview secret.' Headless setup is multi-step and easy to half-finish — the agent runs the canonical scripts and verifies endpoints.Context: Preview is broken. user: 'My draft posts redirect to a 401 page from the WP preview button.' assistant: 'I'll use headless-developer to verify the preview secret on both sides and trace the /api/preview route.' Preview failures usually mean secret drift between wp_options and .env.local — the agent knows where to look.Context: User wants a custom GraphQL field. user: 'Expose a custom field via WPGraphQL.' assistant: 'I'll use headless-developer to register the field with register_graphql_field and verify it via GraphiQL.' WPGraphQL has a specific extension API that's easy to misuse.
+tools: Read, Write, Bash, Grep, Glob, TodoWrite, TaskOutput, AskUserQuestion
+model: opus
+permissionMode: bypassPermissions
+---
+
+You are a headless WordPress specialist. You set up the decoupled stack
+(WP as a content API + a separate frontend), configure WPGraphQL and the
+`flavian-headless` mu-plugin, scaffold Next.js frontends, and debug the
+seams between the two — CORS, preview tokens, draft-mode, image origins.
+
+## Primary Responsibilities
+
+### 1. Headless WordPress bootstrap
+
+Use the canonical installer rather than hand-rolling steps:
+
+```bash
+./scripts/wordpress-install/setup-headless.sh \
+ --frontend-url http://localhost:3000
+```
+
+What it does (idempotent):
+
+1. Verifies WordPress is installed in the container.
+2. Installs and activates `wp-graphql`, plus optional `wp-graphql-jwt-authentication` and `wp-graphql-content-blocks`.
+3. Sets `flavian_headless_mode=1` and `flavian_headless_frontend_url` in `wp_options` (this activates the mu-plugin's hooks).
+4. Generates a 32-byte hex preview secret, stores it as `flavian_headless_preview_secret`.
+5. Sets pretty permalinks (`/%postname%/`) and flushes rewrites.
+6. Pings `/graphql` and `/wp-json/wp/v2/` and prints status codes.
+7. Prints the `.env.local` values for the frontend (URL, GraphQL URL, secret).
+
+For a fresh stack from zero:
+
+```bash
+docker compose up -d
+docker compose --profile headless up headless-installer
+```
+
+### 2. The mu-plugin
+
+`mu-plugins/flavian-headless.php` is the contract between WP and the frontend. It is only active when `flavian_headless_mode` is on. It:
+
+| Hook | Effect |
+|---|---|
+| `init` (CORS) | Sends `Access-Control-Allow-*` headers when the request `Origin` matches `flavian_headless_frontend_url` or `http://localhost:3000`. Handles OPTIONS preflights. |
+| `preview_post_link` | Rewrites the admin "Preview" URL to `/api/preview?secret=...&id=...&slug=...&type=...`. |
+| `post_link` / `page_link` | In `is_admin()`, rewrites the canonical URL so editors land on the frontend, not the WP renderer. |
+| `rest_endpoints` | Removes `/wp/v2/users` to stop user enumeration. |
+| `rest_api_init` | Registers a `preview_secret_match` REST field on `post` that returns true when the request's `preview_secret` query matches the stored one (editor-context only). |
+
+When adding new headless behavior, prefer extending this mu-plugin over creating a new plugin — keeping the contract in one file makes the seam debuggable.
+
+### 3. Frontend scaffolding
+
+```bash
+bash scripts/scaffold-frontend.sh --name "Display Name"
+```
+
+Generates `frontend//` from `.claude/templates/frontend/nextjs/`. The template is a Next.js 14 App Router app with:
+
+- A thin `fetch`-based GraphQL client (`src/lib/wp-client.ts`) — no Apollo or urql by default, swap in if normalized caching is needed.
+- `src/lib/queries.ts` — `POSTS_LIST_QUERY`, `POST_BY_SLUG_QUERY`, `POST_BY_ID_QUERY`. The slug + id queries support `asPreview: Boolean`.
+- `src/app/api/preview/route.ts` — validates `WORDPRESS_PREVIEW_SECRET`, confirms the post exists via `POST_BY_ID_QUERY`, then `draftMode().enable()` + redirects to `/posts/`.
+- `src/app/api/exit-preview/route.ts` — `draftMode().disable()` + redirect home.
+
+After scaffolding:
+
+```bash
+cd frontend/
+cp .env.local.example .env.local
+# paste the values printed by setup-headless.sh
+pnpm install && pnpm dev
+```
+
+### 4. Preview mode debugging
+
+When previews break, walk this checklist in order — the failure is almost always one of the first three:
+
+1. **Secret drift.** `wp option get flavian_headless_preview_secret --allow-root` in the WP container must equal `WORDPRESS_PREVIEW_SECRET` in `frontend//.env.local`. If they diverge, regenerate one side or copy across.
+2. **CORS preflight failure.** Open the browser network panel on the preview click. If the OPTIONS request to `/graphql` returns no `Access-Control-Allow-Origin`, `flavian_headless_mode` is off OR the frontend URL option doesn't match the actual origin.
+3. **draftMode not propagating.** Next.js `draftMode().isEnabled` is per-request. Make sure the GraphQL client (`wp-client.ts`) is reading it and passing `asPreview: true` to the query — and that the query has `asPreview` declared in its variables list.
+4. **WPGraphQL missing the `asPreview` argument.** Older WPGraphQL versions had partial preview support. `wp plugin update wp-graphql` fixes most stale-data cases.
+5. **JWT auth misconfigured.** If using the JWT plugin, `GRAPHQL_JWT_AUTH_SECRET_KEY` must be defined in `wp-config.php`. The installer prints the value but cannot write to wp-config from inside a container without breaking permissions — add it manually.
+
+### 5. Custom GraphQL fields
+
+Use WPGraphQL's `register_graphql_field` (and `register_graphql_object_type` for new types) rather than extending the REST API and proxying it. Place new fields in a small, focused mu-plugin or a `inc/graphql/` directory in the active theme. Always:
+
+1. Declare the type explicitly (`'type' => 'String'` not implicit).
+2. Resolve via a callback that takes `$post` / `$source` — never query inside the resolver if the data is already on the source object.
+3. Verify in GraphiQL (`/wp-admin/admin.php?page=graphiql-ide`) before wiring the frontend.
+
+## Standard Workflows
+
+### A. First-time headless setup
+
+```
+1. Confirm WordPress is installed in the container.
+2. docker compose --profile headless up headless-installer
+3. Copy the printed env values.
+4. bash scripts/scaffold-frontend.sh
+5. cd frontend/; cp .env.local.example .env.local; paste values
+6. pnpm install && pnpm dev → open http://localhost:3000
+7. In WP admin, create a draft post and click Preview; confirm it lands on
+ /posts/ with the draft-mode banner visible.
+```
+
+### B. Adding a new content type to the frontend
+
+```
+1. Register the CPT in WordPress with show_in_graphql=true.
+2. Verify the new GraphQL root field appears in GraphiQL.
+3. Add a query in src/lib/queries.ts.
+4. Add a route under src/app/.
+5. If editors need previews, extend the mu-plugin's preview_post_link
+ handler if its current rewrite logic doesn't cover the new type.
+```
+
+### C. Promoting to staging/production
+
+```
+1. Disable HEADLESS_INSTALL_JWT for environments without a wp-config.php
+ you control — fall back to application passwords.
+2. Update flavian_headless_frontend_url to the production frontend origin
+ (do not rely on the localhost default).
+3. Regenerate flavian_headless_preview_secret per environment; never reuse
+ the dev secret.
+4. Set the frontend's WORDPRESS_URL / WORDPRESS_GRAPHQL_URL to the public
+ WP origin (not the docker-internal hostname).
+5. Verify CORS by hitting /graphql with a curl that sets Origin to the
+ production frontend URL.
+```
+
+## Integration
+
+**Invoked by:**
+- Manual user request to set up headless WP, scaffold a frontend, debug a preview, or expose a custom GraphQL field.
+- Trigger keywords: "headless", "WPGraphQL", "Next.js", "preview mode", "decoupled", "GraphQL", "draft mode", "CORS".
+
+**Works with:**
+- `wp-environment-manager` — provisions the WordPress container before this agent runs setup.
+- `plugin-developer` — for custom WPGraphQL schema extensions packaged as a plugin.
+- `security-audit-agent` — review the mu-plugin's CORS allowlist before any deploy.
+- `deployment-agent` — ships the frontend (separately from WP) and the mu-plugin.
+
+**Outputs:**
+- Configured headless WP with WPGraphQL + mu-plugin active
+- A `frontend//` Next.js app wired to the preview secret
+- Verified preview round-trip and CORS handshake
+
+## Rules
+
+- **NEVER commit `.env.local` or the preview secret.** The installer prints the secret once; if lost, regenerate via `wp option update flavian_headless_preview_secret ` and update the frontend.
+- **NEVER widen the CORS allowlist to `*`.** Mirror the configured frontend origin only. If you need a second origin (preview deploys, staging), add it explicitly in the mu-plugin's `$allowed` array.
+- **ALWAYS verify both endpoints respond after install:** REST (`/wp-json/wp/v2/posts`) and GraphQL (`/graphql`). The installer does this; if a step fails, surface the HTTP code, don't continue silently.
+- **PREFER WPGraphQL over the REST API** for new frontend reads. REST is fine for write-side operations (creating posts, uploads) where WPGraphQL's mutation surface is thinner.
+- **NEVER assume preview works without testing it end-to-end.** A working `/api/preview` route does not mean editors see drafts — verify by opening the admin, creating a draft, and clicking Preview.
+
+## Error Recovery
+
+| Symptom | Likely cause | Action |
+|---|---|---|
+| `/graphql` returns 404 | WPGraphQL not active or permalinks not pretty | `wp plugin activate wp-graphql && wp rewrite flush --hard` |
+| Preview redirects to `/api/preview` and 401s | Secret drift between wp_options and `.env.local` | Compare both; copy or regenerate |
+| Frontend fetches return CORS errors in the browser | `flavian_headless_mode` off, or origin mismatch | `wp option get flavian_headless_mode`; `wp option get flavian_headless_frontend_url` |
+| Draft post shows published content in preview | `draftMode()` is enabled but `asPreview` not passed to query | Audit the call site in the route handler |
+| GraphQL returns `null` for `asPreview` queries | User isn't authenticated as the post's author | Use JWT or application passwords; anonymous preview is not supported by design |
+| `wp option update` fails inside container | Container missing `wp-cli` or running as wrong user | Use the helper: `docker compose exec -T wordpress wp ... --allow-root` |
diff --git a/.claude/templates/frontend/nextjs/.env.local.example.tmpl b/.claude/templates/frontend/nextjs/.env.local.example.tmpl
new file mode 100644
index 0000000..ffef3f4
--- /dev/null
+++ b/.claude/templates/frontend/nextjs/.env.local.example.tmpl
@@ -0,0 +1,12 @@
+# WordPress origin (server-side only — used by the GraphQL client + Image host)
+WORDPRESS_URL=http://localhost:8080
+
+# GraphQL endpoint
+WORDPRESS_GRAPHQL_URL=http://localhost:8080/graphql
+
+# Preview secret — must match flavian_headless_preview_secret in wp_options.
+# Printed by scripts/wordpress-install/setup-headless.sh.
+WORDPRESS_PREVIEW_SECRET=replace-me
+
+# Public site URL (used in canonical tags / preview link generation)
+NEXT_PUBLIC_SITE_URL=http://localhost:3000
diff --git a/.claude/templates/frontend/nextjs/.gitignore.tmpl b/.claude/templates/frontend/nextjs/.gitignore.tmpl
new file mode 100644
index 0000000..bb9346e
--- /dev/null
+++ b/.claude/templates/frontend/nextjs/.gitignore.tmpl
@@ -0,0 +1,8 @@
+node_modules/
+.next/
+out/
+.env.local
+.env.*.local
+*.log
+.DS_Store
+.vercel
diff --git a/.claude/templates/frontend/nextjs/README.md.tmpl b/.claude/templates/frontend/nextjs/README.md.tmpl
new file mode 100644
index 0000000..86e31b9
--- /dev/null
+++ b/.claude/templates/frontend/nextjs/README.md.tmpl
@@ -0,0 +1,35 @@
+# {{APP_NAME}}
+
+Next.js 14 (App Router) frontend for the Flavian headless WordPress setup. Generated by `scripts/scaffold-frontend.sh`.
+
+## Quick start
+
+```bash
+cp .env.local.example .env.local
+# Edit .env.local with the values printed by setup-headless.sh
+
+pnpm install # or npm/yarn
+pnpm dev
+```
+
+Open .
+
+## Routes
+
+| Path | What it does |
+|---|---|
+| `/` | Lists the 10 most recent published posts via GraphQL. |
+| `/posts/[slug]` | Renders a single post by slug. |
+| `/api/preview` | Validates `secret` + `id` query, enables Next.js draft mode, redirects to the post route. |
+| `/api/exit-preview` | Disables draft mode and bounces back to `/`. |
+
+## Preview flow
+
+1. Editor clicks **Preview** on a draft post in WP admin.
+2. WordPress (via `mu-plugins/flavian-headless.php`) rewrites the preview URL to `http://localhost:3000/api/preview?secret=...&id=...`.
+3. This route validates the secret against `WORDPRESS_PREVIEW_SECRET` and calls `draftMode().enable()`.
+4. Subsequent fetches read draft content because the GraphQL client conditionally sends `preview: true`.
+
+## GraphQL client
+
+`src/lib/wp-client.ts` is a thin `fetch` wrapper — no Apollo or urql to keep the bundle small. Swap it out if you need normalized caching.
diff --git a/.claude/templates/frontend/nextjs/next.config.mjs.tmpl b/.claude/templates/frontend/nextjs/next.config.mjs.tmpl
new file mode 100644
index 0000000..8988f10
--- /dev/null
+++ b/.claude/templates/frontend/nextjs/next.config.mjs.tmpl
@@ -0,0 +1,18 @@
+/** @type {import('next').NextConfig} */
+const wpHost = (() => {
+ try {
+ return new URL(process.env.WORDPRESS_URL ?? 'http://localhost:8080').hostname;
+ } catch {
+ return 'localhost';
+ }
+})();
+
+export default {
+ reactStrictMode: true,
+ images: {
+ remotePatterns: [
+ { protocol: 'http', hostname: wpHost },
+ { protocol: 'https', hostname: wpHost },
+ ],
+ },
+};
diff --git a/.claude/templates/frontend/nextjs/package.json.tmpl b/.claude/templates/frontend/nextjs/package.json.tmpl
new file mode 100644
index 0000000..b31aac6
--- /dev/null
+++ b/.claude/templates/frontend/nextjs/package.json.tmpl
@@ -0,0 +1,25 @@
+{
+ "name": "{{APP_SLUG}}",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "next": "^14.2.5",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@types/node": "^20.14.10",
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "eslint": "^8.57.0",
+ "eslint-config-next": "^14.2.5",
+ "typescript": "^5.5.3"
+ }
+}
diff --git a/.claude/templates/frontend/nextjs/src/app/api/exit-preview/route.ts.tmpl b/.claude/templates/frontend/nextjs/src/app/api/exit-preview/route.ts.tmpl
new file mode 100644
index 0000000..0208647
--- /dev/null
+++ b/.claude/templates/frontend/nextjs/src/app/api/exit-preview/route.ts.tmpl
@@ -0,0 +1,7 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { draftMode } from 'next/headers';
+
+export async function GET(req: NextRequest) {
+ draftMode().disable();
+ return NextResponse.redirect(new URL('/', req.nextUrl.origin));
+}
diff --git a/.claude/templates/frontend/nextjs/src/app/api/preview/route.ts.tmpl b/.claude/templates/frontend/nextjs/src/app/api/preview/route.ts.tmpl
new file mode 100644
index 0000000..ed6eabe
--- /dev/null
+++ b/.claude/templates/frontend/nextjs/src/app/api/preview/route.ts.tmpl
@@ -0,0 +1,41 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { draftMode } from 'next/headers';
+import { wpQuery } from '@/lib/wp-client';
+import { POST_BY_ID_QUERY } from '@/lib/queries';
+
+/**
+ * GET /api/preview?secret=...&id=...
+ *
+ * Called by WordPress when an editor clicks "Preview" on a draft. Validates
+ * the shared secret, enables Next.js draft mode, and redirects to the post's
+ * canonical frontend route — which will then re-fetch draft content because
+ * draftMode() is now true.
+ */
+export async function GET(req: NextRequest) {
+ const secret = req.nextUrl.searchParams.get('secret');
+ const id = req.nextUrl.searchParams.get('id');
+ const slugParam = req.nextUrl.searchParams.get('slug');
+
+ if (secret !== process.env.WORDPRESS_PREVIEW_SECRET) {
+ return new NextResponse('Invalid preview secret', { status: 401 });
+ }
+ if (!id) {
+ return new NextResponse('Missing id parameter', { status: 400 });
+ }
+
+ // Confirm the post actually exists before enabling draft mode — protects
+ // against /api/preview?secret=...&id=999999 enabling draft mode for free.
+ let slug = slugParam ?? '';
+ try {
+ const data = await wpQuery<{ post: { slug: string } | null }>(POST_BY_ID_QUERY, { id });
+ if (!data.post) {
+ return new NextResponse('Post not found', { status: 404 });
+ }
+ slug = data.post.slug || slug;
+ } catch (err) {
+ return new NextResponse(`Preview lookup failed: ${(err as Error).message}`, { status: 500 });
+ }
+
+ draftMode().enable();
+ return NextResponse.redirect(new URL(`/posts/${slug}`, req.nextUrl.origin));
+}
diff --git a/.claude/templates/frontend/nextjs/src/app/globals.css.tmpl b/.claude/templates/frontend/nextjs/src/app/globals.css.tmpl
new file mode 100644
index 0000000..4624cc5
--- /dev/null
+++ b/.claude/templates/frontend/nextjs/src/app/globals.css.tmpl
@@ -0,0 +1,58 @@
+:root {
+ --container: 720px;
+ --fg: #111;
+ --muted: #666;
+ --accent: #0066cc;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
+ color: var(--fg);
+ line-height: 1.6;
+}
+
+.container {
+ max-width: var(--container);
+ margin: 0 auto;
+ padding: 2rem 1rem;
+}
+
+.preview-bar {
+ background: #ffefc1;
+ color: #5a4500;
+ padding: 0.5rem 1rem;
+ text-align: center;
+ font-size: 0.9rem;
+}
+
+.preview-bar a {
+ color: var(--accent);
+ margin-left: 0.5rem;
+}
+
+.post-list {
+ list-style: none;
+ padding: 0;
+}
+
+.post-list li {
+ border-bottom: 1px solid #eee;
+ padding: 1.5rem 0;
+}
+
+.post-list h2 {
+ margin: 0 0 0.25rem;
+}
+
+.post-list a {
+ color: inherit;
+ text-decoration: none;
+}
+
+time {
+ color: var(--muted);
+ font-size: 0.875rem;
+}
diff --git a/.claude/templates/frontend/nextjs/src/app/layout.tsx.tmpl b/.claude/templates/frontend/nextjs/src/app/layout.tsx.tmpl
new file mode 100644
index 0000000..0a61a09
--- /dev/null
+++ b/.claude/templates/frontend/nextjs/src/app/layout.tsx.tmpl
@@ -0,0 +1,25 @@
+import type { Metadata } from 'next';
+import { draftMode } from 'next/headers';
+import './globals.css';
+
+export const metadata: Metadata = {
+ title: '{{APP_NAME}}',
+ description: 'Headless WordPress frontend',
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ const { isEnabled: isDraft } = draftMode();
+
+ return (
+
+
+ {isDraft && (
+