A zone is a separate Next.js application that supplies the UI for one agent. The platform proxies /{orgSlug}/{agentSlug}/app/* to it and mints a per-request JWT carrying the visitor's identity, so the zone gets platform auth without implementing any.
This page is the build; the Zone App Guide covers each piece in more depth, and Register the zone is the platform side.
1. Scaffold
npx create-next-app@latest web --typescript --app
cd web
npm install @cxpa/sdk
The zone lives at web/ in the repository from Project layout, and it is its own npm project.
2. next.config.ts
Three settings, all load-bearing:
import type { NextConfig } from 'next'
import path from 'path'
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 platformOrigin = process.env.PLATFORM_ORIGIN || ''
const nextConfig: NextConfig = {
basePath: `/${orgSlug}/${agentSlug}/app`,
assetPrefix: `/assets/${orgSlug}/${agentSlug}`,
turbopack: { root: path.resolve(__dirname) },
experimental: {
serverActions: { allowedOrigins: [platformOrigin] },
},
}
export default nextConfig
basePath must equal the path the platform proxies. It is baked in at build time, which is why the org and agent slugs are build inputs and why renaming either one requires a rebuild. Throwing when they are missing is deliberate — the alternative is a build that succeeds and then 404s on every request.
assetPrefix must be a path, not a full URL. The browser fetches assets from the platform's origin, which proxies them to the zone; making this cross-origin breaks that proxy.
allowedOrigins must contain the platform's origin. The browser is on the platform's origin, so every server action POST looks cross-origin to Next.js and is rejected without this.
3. Verify the JWT in middleware
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { createZoneMiddleware } from '@cxpa/sdk/zone'
let cached: ReturnType<typeof createZoneMiddleware> | null = null
function zoneMiddleware() {
if (!cached) {
cached = createZoneMiddleware({
secret: process.env.ZONE_JWT_SECRET!,
audience: process.env.AUDIENCE_ID!,
})
}
return cached
}
export function middleware(request: NextRequest) {
// Local dev: no platform on the other side to mint tokens.
if (process.env.DISABLE_ZONE_AUTH === 'true') {
return NextResponse.next()
}
return zoneMiddleware()(request)
}
export const config = {
matcher: ['/:path*'],
}
Two departures from the minimal SDK snippet, both of which you want:
The matcher is /:path*, not the full platform path. With basePath configured, Next.js applies the matcher to the path after stripping the base path. A matcher written as /:orgSlug/:agentSlug/app/:path* therefore matches nothing, and the result is a zone that serves every page unauthenticated. This is the single easiest way to build an insecure zone, and it fails open.
The middleware is built lazily inside a function, so a missing ZONE_JWT_SECRET does not throw at import time. Without this, local development with DISABLE_ZONE_AUTH=true crashes before reaching the bypass.
Any request without a valid token gets a 401. That is the zone's entire authentication story.
4. Read the session
Wrap getSession once, so the dev bypass lives in exactly one place:
import { getSession, type ZoneSession } from '@cxpa/sdk/zone'
export async function getZoneSession(): Promise<ZoneSession> {
if (process.env.DISABLE_ZONE_AUTH === 'true') {
return {
claims: {
userId: 'dev',
userName: 'Dev User',
userEmail: 'dev@example.com',
orgId: 'dev',
orgSlug: 'dev',
agentId: Number(process.env.DEV_AGENT_ID ?? '0'),
role: 'admin',
},
token: 'dev',
}
}
return getSession()
}
The claims give you userId, userName, userEmail, orgId, orgSlug, agentId, and role — enough to attribute work, authorise by role, and address the agent. Never read identity from anywhere else; a query parameter or a client-supplied field is not trustworthy.
If your zone has its own database and wants to show names, mirror the identity in on each request rather than storing it once — renames on the platform then propagate for free:
await db.from('user_profiles').upsert(
{
user_id: session.claims.userId,
user_name: session.claims.userName ?? null,
user_email: session.claims.userEmail,
},
{ onConflict: 'user_id' },
)
5. Use server actions, not route handlers
Do not add app/api/* route handlers to a zone. A client-side fetch('/api/...') resolves against the platform's origin, because that is the origin in the browser's address bar — so the request never reaches your zone. It will 404 or, worse, hit an unrelated platform route.
Put every client-to-server call in a server action. Build the client once and reuse it:
'use server'
import { createZoneClient, type Run } from '@cxpa/sdk/zone'
import { getZoneSession } from '@/lib/zone-session'
async function client() {
const session = await getZoneSession()
return createZoneClient({
baseUrl: process.env.PLATFORM_API_BASE_URL!,
token: session.token,
agentId: session.claims.agentId,
})
}
createZoneClient is scoped by the JWT to one agent, so it cannot reach another agent's runs even if a caller supplies a different id.
6. Trigger a run and read the result back
This is the core loop of a zone app: start a run, then follow it to a terminal state. Both halves are server actions over the same client.
export async function triggerRun(
url: string,
): Promise<{ runId: number } | { error: string }> {
try {
const cxpa = await client()
const { runId } = await cxpa.runs.create({ payload: { url } })
return { runId }
} catch (err) {
return { error: err instanceof Error ? err.message : 'Unknown error' }
}
}
export async function pollRun(
runId: number,
): Promise<{ run: Run } | { error: string }> {
try {
const cxpa = await client()
const run = await cxpa.runs.get(runId)
return { run }
} catch (err) {
return { error: err instanceof Error ? err.message : 'Unknown error' }
}
}
Returning { error } rather than throwing is deliberate: a server action that throws gives the client a generic digest in production, and the zone loses the platform's message just when it is most useful.
runs.create returns as soon as the run is queued. runs.get returns the whole run row — status, output_data, error, duration_ms, created_at — so one call drives the entire result UI.
Poll until terminal
'use client'
const POLL_INTERVAL_MS = 1500
const TERMINAL: RunStatus[] = ['completed', 'failed', 'cancelled']
export function RunPoller({ runId }: { runId: number }) {
const [run, setRun] = useState<Run | null>(null)
const stoppedRef = useRef(false)
useEffect(() => {
stoppedRef.current = false
let cancelled = false
async function tick() {
if (stoppedRef.current || cancelled) return
const result = await pollRun(runId)
if (cancelled) return
if ('error' in result) return
setRun(result.run)
if (TERMINAL.includes(result.run.status)) stoppedRef.current = true
}
void tick()
const id = setInterval(() => {
if (stoppedRef.current) return clearInterval(id)
void tick()
}, POLL_INTERVAL_MS)
return () => {
cancelled = true
clearInterval(id)
}
}, [runId])
// …render from run.status
}
Three details that matter more than they look:
- Stop on the terminal set.
completed,failed,cancelled. Note thatwaitingis not terminal — a human-in-the-loop run returns torunningafter someone responds, so a poller that stops there will never show the result. - Fire once immediately, then on the interval. Otherwise the first render is empty for a full interval even when the run is already done.
- Guard the unmount. The
cancelledflag plusclearIntervalstops an in-flight request writing state into a component the user has navigated away from.
Wire it to the ?run= convention
The zone's landing page can serve both halves by branching on the query parameter:
export default async function AppPage({
searchParams,
}: {
searchParams: Promise<{ run?: string }>
}) {
const [, { run }] = await Promise.all([getZoneSession(), searchParams])
const runId = run ? Number(run) : NaN
if (Number.isFinite(runId) && runId > 0) return <RunPoller runId={runId} />
return <InputForm />
}
After triggering, push to /?run=${runId} — Next.js prepends the base path, so it resolves to /{orgSlug}/{agentSlug}/app/?run=… on the platform's domain. That is the same URL the platform's notifications use, so one code path serves a fresh trigger, a shared link, a reload, and an email about a waiting run.
When the run has no output yet
Handle completed with an empty output_data explicitly. It is reachable — a task can finish without reporting output, or the platform can record the terminal status before the payload lands — and rendering nothing looks like a broken zone. Show a short "finished, no result stored" state instead.
For a zone that renders the output inline — a report, a generated document — cxpa.runs.createAndWait({ payload, timeoutMs }) blocks until the run terminates and returns output_data, with a timedOut flag you must handle. It replaces the poller for short runs; anything longer should still poll. See the platform client guide.
7. Resolving credentials from the zone
A zone can read the agent's credentials directly, addressed by integration key — not by connection id as a task is:
'use server'
const cxpa = createZoneClient({ /* … */ })
const sheets = await cxpa.credentials.get('google-sheets')
if (sheets.type === 'oauth') {
await fetch(url, { headers: { authorization: `Bearer ${sheets.access_token}` } })
}
The response is the same discriminated union a task gets — api_key, basic_auth, or oauth. The platform resolves it fresh on every call and never caches.
Use this sparingly. The right default is to let the task resolve credentials, because the secret then only ever exists inside the runtime, for the duration of a run. Reach for the zone path only when the zone itself must call the third-party API — rendering a preview of a customer's spreadsheet, listing files to pick from, validating a connection before starting a run. If the work belongs to the agent, trigger a run and let the task do it.
Two rules when you do use it:
- Server-side only. Call it from a server action or server component and never return the credential to the browser. A resolved secret in a client component is a secret in the page source.
- Do not cache it. Fetch it at the point of use. The platform re-resolves on each call precisely so that a rotated or refreshed credential takes effect immediately.
Every access is scoped by the zone JWT to the bound agent, checked against the signed-in user's org membership, rate-limited per user and agent, and written to the audit log — so a zone cannot reach another agent's credentials even if it replays its token elsewhere.
Full reference: Platform client → Resolve a credential.
8. Navigation is base-path relative
Next.js prepends basePath to Link, redirect, and router.push, so write paths as if the zone were at the root:
redirect('/chat/new') // → /acme/revenue-chat/app/chat/new
Hard-coding the full platform path double-prefixes it. And honour the deep-link convention on your landing page:
/{orgSlug}/{agentSlug}/app?run=<runId>
The platform's notifications route zone-backed agents to that URL — email links to a waiting run, Slack messages, dashboards. Ignoring the query parameter breaks every one of them.
9. The local loop
# web/.env.local
ZONE_ORG_SLUG=acme
ZONE_AGENT_SLUG=revenue-chat
DISABLE_ZONE_AUTH=true
DEV_AGENT_ID=1
npm run dev -- -p 4000
http://localhost:4000/ will 404 — the zone only exists inside its base path. Go to http://localhost:4000/acme/revenue-chat/app instead.
DEV_AGENT_ID is the agent's numeric platform id, not its slug — the number shown on the agent's API Key page. It is read only inside the bypass, so it has no effect in production, where agentId comes from the verified token. Set it correctly anyway: the ?? '0' fallback in the snippet above produces calls against agent 0, which does not exist, and the resulting 403 or 404 looks like a credentials problem rather than a missing variable.
The bypass is a complete authentication and tenancy bypass.
DISABLE_ZONE_AUTHis read at request time, not build time, and setting it does two things: the middleware stops verifying tokens, and every visitor is handed the fabricated session above — includingrole: 'admin'. Any role check you write will pass for everyone.Nothing guards it. There is no
NODE_ENVcondition, no build-time assertion, no platform-side rejection — the only protection is that you never set it. Never add it to a deployed environment, including a preview. If you need a deployed environment to work without the platform, the answer is a separate agent and zone registration, not this flag.
To exercise the real JWT path, see Register the zone — that page lists which local modes need a running platform and which do not.
Checklist
-
basePath,assetPrefix(a path), andallowedOriginsare all set. -
matcheris['/:path*'], and an unauthenticated request returns 401. - All identity comes from
getSession()claims. - No
app/api/*route handlers; client calls go through server actions. - Platform calls go through
createZoneClient, never a hand-rolledfetch. - The run poller stops on
completed/failed/cancelled— and keeps polling throughwaiting. - Any credential the zone resolves is used server-side, never returned to the browser or cached.
- The landing page honours
?run=<runId>.
Next
Zone data and tenancy — where the zone's own data lives, and who may see it.