Imported from zitadel/nextgen (
apps/console/AGENTS.md). Install upstream withnpx skills add zitadel/nextgen --skill console. Copyright stays with the author.
Agent Instructions — apps/console
Scoped instructions for the console SPA. These add to, and where they conflict
take precedence over, the root AGENTS.md for files under
apps/console.
Decision source: read the console ADRs first
Before changing console routing, navigation, the app shell, or how the console
talks to the API, read the console-scoped ADRs in
docs/adrs/ (statuses in that index; 0001/0002 are
Accepted, 0003/0004 Proposed and largely implemented). They are the agreed
direction for the console build-out (issue
#440):
- ADR 0001: Routing — file-based routing,
one router factory,
basepathderived from the Vitebase, route loaders + pending/error/not-found boundaries,staticData-driven sidebar. - ADR 0002: API access and auth interceptors
— the console holds no script-readable credential and calls the API
same-origin; the embedded browser carries its HttpOnly first-party session
cookie. Reuse
configureZitadel()/getApi()rather than a bespoke client. The base is/apionly under the dev server (whose proxy temporarily injects the project secret); the embedded build talks to the origin root, where the Go binary serves the API. The/apishim §1 once deferred to the server was withdrawn in the 2026-08-12 revision — do not reintroduce it. - ADR 0003: Console authentication
—
/loginembeds the login widget (@zitadel/sdk-react); the pathless_authedlayout owns the session guard (GET /sessions/me) and the app shell; the first-party session cookie is the embedded Console's human operator credential. Management calls stay on the dev-only proxy secret until ADR 053's session-derived target authorization exists; embedded calls fail closed in the meantime. - ADR 0004: Deployment modes
— one build and one authorization model serve cloud and self-host. Target:
every deployment uses a reserved platform project for Console identities; an
explicit testkit or future server-file seed fully provisions it, its initial
user, membership, separate owner assignment, and optional customer project.
Today: the Console signs into the pinned or first-created project — an
ordinary customer project — under §2's cutover rule, unless
platform.bootstrap_projectis set, which provisions the reserved platform project itself (keys, default schema, default login flow — but not the initial user/membership/owner assignment the target seed transport adds) and is what makes claiming and self-registration work. Do not remove that fallback (or theplatform.project_idpin) until the seed transport ships; doing so strands self-hosters. Standalone optimizes for one project but does not forbid more. The runtime document carries only public sign-in metadata; portal surfaces render from target-scoped effective permissions, never membership, build-time flags, or a parallel console-facing feature array. A runtime document the Console cannot read is an error, not a mode (§3): keep "unreachable/erroring" and "no project yet" separate states, and do not reintroduce a silent fallback tostandalone— backend-less dev and preview runs opt in withVITE_CONSOLE_RUNTIME_FALLBACKinstead.
If an implementation needs to diverge from an ADR, update the ADR in the same change rather than letting code and decision drift.
Styling: classify before building
Before building any console UI, read
docs/styling.md — where a component lives decides how it
is styled (unprefixed shadcn utilities for console chrome vs a Lit+React pair),
and the retired *-zl-* console utility names must not come back. The 3-way
classification and token authority live there; the pair recipe lives in
apps/storybook/AGENTS.md.
Screen conventions
List/detail screens follow the shipped patterns under src/routes/_authed/
(users, schemas, flow-definitions): loader-fetched data, status columns where
the resource has lifecycle state, $param detail routes. The sessions screen
speaks POST /sessions/query (structured filters + cursor pagination); there
is no GET /sessions list, and sessions have no revoked state — revocation
deletes the session.
Generated files
src/routeTree.gen.ts is generated by the TanStack Router Vite plugin. Do not
hand-edit it — add or change files under src/routes/ and let the plugin
regenerate the tree (consistent with the root AGENTS.md "Generated Files"
rule).
Local tasks
moon run console:dev-real # seeded real backend + dev server (default loop)
moon run console:dev-claim # same, but for the claim page (see below)
moon run console:dev # dev server only on http://localhost:5174
moon run console:typecheck
moon run console:test
moon run console:build
The claim page needs dev-claim, not dev-real
dev-real boots one ordinary project and signs the console into it. The claim
page is the console acting as the platform's claim surface, and
claim/complete only accepts a session belonging to the platform project — so
on dev-real the page renders but the claim always fails with "This account
can't claim the project". That is the harness, not a defect.
moon run console:dev-claim boots the platform project, pins the console to it,
and prints a ready claim link. The trade-off is why it is opt-in: pinning the
console to proj_platform changes the standalone semantics the demo and
embedded suites rely on, and the seeded users live in the project being claimed
rather than the platform one, so list screens read empty and you register on the
claim page instead of signing in with the seeded credentials.
Three things that break either loop before it starts, none of which say so clearly:
- Build the CLI first (
moon run cli:build). Both loops shell out to it to start the server; without a built bundle the server starts unmigrated and dies withno such table: projects. - Reinstall after a rebase (
pnpm install). A stalenode_modulesfails the built CLI on a missing transitive dependency, not on anything you changed. - A raw binary needs
--migrate. Migrations are opt-in since #1152; the CLI passes the flag for you, so this only bites when launchingdist/server/nextgenby hand.
CONSOLE_DEV_ORIGIN sets the console's port for both loops, so a second worktree
can run beside the first.
Develop against real data, not the mock
The console manages an instance, so use console:dev-real — it boots a real
ephemeral instance and seeds users, so list screens show real API responses.
@zitadel/api-mock has no user store; a users list read from it is a fiction.
Why the mock cannot substitute (authorization, the user.read scope, and the
publishable-key refusal) is documented canonically in the Local development
section of README.md — read it for all three backends and when
each applies. The mock's flow-shape authority rule lives in
packages/api-mock/AGENTS.md.