Scaffold the zone app

A zone app is a normal Next.js App Router project with three platform-specific config tweaks: basePath, assetPrefix, and serverActions.allowedOrigins.

1. Create the project

bash
npx create-next-app@latest my-agent-zone \
  --ts --app --eslint --tailwind --src-dir --import-alias "@/*"
cd my-agent-zone
npm install @cxpa/sdk

@cxpa/sdk/zone ships everything needed to verify the JWT, read the session, and call platform endpoints. jose is pulled in transitively — do not install it directly.

Do not add an auth library. A zone never authenticates users itself — identity arrives in the platform-signed JWT, and adding a second auth system (Supabase Auth, NextAuth, Clerk) creates a competing source of identity that the platform will not honour.

A database client is a different matter and is expected if the zone owns domain data — a list to render, records to write. Install whatever driver that database needs, use it server-side only, and keep it strictly separate from identity, which still comes from the session. See Zone data and tenancy.

2. Folder layout

The source tree is flat. basePath (configured below) is what makes the platform serve app/page.tsx at /{orgSlug}/{agentSlug}/app.

There is no app/api/ directory, and adding one will not work. A client-side fetch('/api/…') resolves against the platform's origin — the one in the browser's address bar — so the request never reaches the zone. Every client-to-server call goes through a server action instead.

my-agent-zone/
├── src/
│   ├── app/
│   │   ├── page.tsx              landing surface (e.g. input form)
│   │   ├── runs/[runId]/page.tsx optional run detail
│   │   ├── layout.tsx
│   │   ├── icon.png              browser tab icon (see §4)
│   │   └── globals.css
│   ├── components/
│   ├── lib/
│   │   └── actions.ts            'use server' — triggerRun / pollRun / ...
│   └── middleware.ts
├── next.config.ts
├── .env.example
└── package.json

3. Configure next.config.ts

The zone is mounted under /{orgSlug}/{agentSlug}/app/* on the platform. basePath and assetPrefix ensure links and static assets resolve correctly behind the proxy.

ts
import type { NextConfig } from 'next'

const orgSlug = process.env.ZONE_ORG_SLUG
const agentSlug = process.env.ZONE_AGENT_SLUG
if (!orgSlug || !agentSlug) {
  throw new Error(
    'ZONE_ORG_SLUG and ZONE_AGENT_SLUG must be set — they compose the zone basePath and assetPrefix.',
  )
}

const nextConfig: NextConfig = {
  basePath: `/${orgSlug}/${agentSlug}/app`,
  assetPrefix: `/assets/${orgSlug}/${agentSlug}`,
  experimental: {
    serverActions: {
      allowedOrigins: [process.env.PLATFORM_ORIGIN!],
    },
  },
}

export default nextConfig

Three points:

  1. basePath is built from the slugs. Next.js prepends it to every route, asset URL, <Link> href, and router.push() call — so the source tree stays flat. The slugs are read at build time; throwing on missing values is intentional, because a build with the wrong basePath silently 404s in production.
  2. assetPrefix is a path, not a URL, and is scoped by (orgSlug, agentSlug) to avoid collisions between agents that share a slug across orgs. A cross-origin assetPrefix breaks hydration silently.
  3. serverActions.allowedOrigins must include the platform's origin. Server actions run on the zone but the browser POSTs them from the platform's host; without the allow-list Next.js rejects the request as cross-origin.

A single zone deployment binds to exactly one (orgSlug, agentSlug) pair. Build separate deployments for separate agents.

4. Favicon and app icons

Put icons in src/app/ using Next.js file conventions — icon.png (or icon.svg), and apple-icon.png when you have a large enough master:

src/app/
├── icon.png          32x32 or larger — the browser tab icon
├── apple-icon.png    180x180 — optional
└── layout.tsx

Next prefixes these with the zone's basePath automatically, so the emitted tag is <link rel="icon" href="/{orgSlug}/{agentSlug}/app/icon.png?abc123">. That path is on the platform origin and the platform proxies it back to the zone, so it resolves correctly.

Do not hand-write metadata.icons with root-relative URLs. This is the one form that breaks silently:

ts
// ✗ Broken behind a basePath
export const metadata: Metadata = {
  icons: { icon: '/favicon.png' },
}

Next emits metadata.icons URLs verbatim — it applies neither basePath nor metadataBase to them. A root-relative URL therefore resolves against the platform's origin rather than the zone, so the icon either 404s or silently picks up the platform's own mark. Worse, a hand-written block overrides the file convention, so adding one disables src/app/icon.png.

If a zone genuinely needs metadata.icons — several sizes, media queries — every url must carry the basePath:

ts
const basePath = `/${process.env.ZONE_ORG_SLUG}/${process.env.ZONE_AGENT_SLUG}/app`

export const metadata: Metadata = {
  icons: { icon: [{ url: `${basePath}/icon.png`, sizes: '32x32', type: 'image/png' }] },
}

Two more things worth knowing:

  • Files in the zone's public/ produce no link tag on their own. They are served at {basePath}/<file>, but nothing points a browser at them. The src/app/ conventions are the reliable route.
  • Prefer icon.png over favicon.ico inside a zone. Next's internal favicon check matches the literal path /favicon.ico, so under a basePath a zone's app/favicon.ico is not hoisted to first-icon position and is emitted as an ordinary icon link. Harmless, but icon.png has no such quirk. Note that create-next-app scaffolds src/app/favicon.ico for you — replace that file rather than adding metadata alongside it.

A zone that ships no icon at all is fine: the browser falls back to probing /favicon.ico on the platform origin, and inherits the CXP mark.

5. Environment variables

The zone needs the following at runtime:

VariableExamplePurpose
ZONE_ORG_SLUGacmeOrg slug the zone is bound to. Read at build time.
ZONE_AGENT_SLUGintake-botAgent slug the zone is bound to. Read at build time.
ZONE_JWT_SECRET(provided by platform admin)HS256 secret used to verify x-zone-token.
AUDIENCE_ID(provided by platform admin)Audience claim the platform mints with.
PLATFORM_API_BASE_URLhttps://platform.example.comBase URL for outbound platform calls.
PLATFORM_ORIGINhttps://platform.example.comUsed by Server Actions allow-list.

PLATFORM_API_BASE_URL and PLATFORM_ORIGIN are typically the same value. Strip trailing slashes from both — a stray / produces //assets/... in the proxy destination and triggers a 308 loop on Vercel.

6. Next steps

Continue with Auth to wire JWT verification.