Skip to content

The admin app

The panel is a Nuxt app of its own, next to the cabinet, and it is built the same way: layered slices registered as Nuxt layers. This page describes the frame the screens sit in — how a slice is registered, which groups exist and in what order, where the api client comes from, and what happens to a call that fails.

It does not describe any screen. Signing in and the tenant lists each get their own page as they land (AGNT2-159, AGNT2-160).

What is in the app

admin/
├── nuxt.config.ts             registers the layers, declares the store dirs
├── registerSlices.ts          finds the layers — a slice is a folder, nothing is listed
├── cleanslice.config.cjs      the group order, and the only place it is written down
├── openapi-ts.config.ts       how the api client is generated
├── scripts/cleanslice-check.cjs   the boundary check — byte-identical to the api's
└── slices/
    ├── setup/                 the plumbing every screen needs and no screen is about
    │   ├── api/               the generated client and its wiring
    │   ├── error/             what a failed call does
    │   ├── pinia/             stores
    │   └── theme/             tailwind, the brand palette, four shadcn primitives, the shell
    ├── user/                  who is operating the panel — the session
    └── overview/              the read-only views onto tenants

Groups, and why the order is a rule

The immediate folders under slices/ are the panel's slice groups, and their order is the architecture, not a filing convention: a group may depend on everything below it and on nothing above it.

GroupWhat belongs in it
L0setupThe generated api client, the error handling, the theme, pinia. It knows nothing of the product and nothing of who is signed in.
L1userThe administrator's session: signing in, staying signed in, and what happens to a request when the session has expired.
L2overviewThe read-only views onto tenants — teams, users, agents.

The order is not the cabinet's. The cabinet has seven groups, five of which (common, agent, chat, billing, design) the panel has no counterpart for; copying its list would declare five groups that do not exist. That is not a tidy-up problem — the check stops with exit code 2 on a group it is told about but cannot find, deliberately, because a rule generated for a folder that is not there checks nothing.

The order lives in admin/cleanslice.config.cjs, and adding a group means adding it there, in its rightful place. The check itself is never edited: it is the same cleanslice-check.cjs the api and the cabinet run, byte for byte, and make check compares the three copies and stops if they have drifted.

The rule you will actually hit

setup may not import from user. The tempting violation is the obvious one: the panel signs a request with the administrator's token, so surely the transport should read the session? No — then the plumbing cannot be reasoned about, tested, or reused without the thing that knows who is signed in.

The direction is inverted instead. The session slice registers what it wants done about a failure, and the error slice calls it back without ever learning whose session it was:

ts
// in the slice that owns the session — user/auth
registerApiFailureHandler((failure) => {
  if (failure.response?.status !== 401) return undefined; // not ours
  return renewThenReplay(failure);                        // ours: we answer it
});

Returning a promise claims the failure, and the response it settles to is the one the original caller reads — which is how a renewed session can replay a request and hand back the replay's answer in place of the refusal. Returning undefined declines, and the error slice falls through to its own toast.

Running the check

bash
cd admin && bun run boundaries     # nuxt prepare, then the check
cleanslice-check: OK — 3 group(s) [setup -> user -> overview], 42 modules, 353 ms

It also runs before bun run dev, so a violation stops the start rather than shipping, and it is part of make check. A violation reads like this:

cleanslice-check: FAILED — 3 group(s) [setup -> user -> overview], 44 modules
  error no-upward-import-from-setup: slices/setup/error/utils/handleError.ts
      -> slices/user/auth/utils/probe.ts
    'setup' (L0) may not import higher groups: user, overview

Two things the check does not see, and both matter here. Auto-imported composables and components leave no import statement, so an auto-import across a group boundary is invisible to it. And an import that does not resolve produces no edge at all — which is why tsconfig.boundaries.json exists: it re-roots the generated Nuxt aliases so #api and #error resolve to real files. Without it the check would pass on any code whatsoever. The full list of blind spots is in the CleanSlice standard.

Reaching the api

There is one way, and it is generated. openapi-ts reads api/swagger-spec.json — the artifact make swagger writes — and produces the SDK under slices/setup/api/data/repositories/api. Nobody edits it; a hand-written client would be a second description of every endpoint, and the two would disagree the first time a DTO moved.

bash
cd admin && bun run build:api      # regenerate; `dev` and `build` do it first
ts
import { Teams, unwrap } from '#api';

const { data } = await Teams.listTeams();
const teams = unwrap(data);        // the api wraps everything in { success, data }

Three details that are easy to get wrong and are therefore fixed in one place each:

  • The api address comes from runtimeConfig.public.apiBase, fed by NUXT_PUBLIC_API_BASE, and is applied on a single line in the setup/api plugin. The generator's own config file must not set a baseUrl: it runs at module-eval time, before any plugin, so whatever it writes is a value the plugin has to overwrite — and an ordering mistake then points the panel at the wrong host.
  • Every request leaves a sendable copy of itself behind, taken before it is fetched. fetch consumes a request's body, and a consumed body cannot go out again — so a 401 could not be answered by re-sending the same request without that copy.
  • A replayed request gets the current credentials written onto it, because a request's own headers beat the client's config. Skip that and the replay puts the dead token straight back on the wire, which is a renewal loop rather than a recovery.

Ask for throwOnError, or a refusal arrives as emptiness

The generated client does not throw on a 4xx. It returns, quietly, with error set and data left undefined — so a try/catch around the call never fires, unwrap reads undefined, and the screen renders an empty list.

For this panel that is the worst shape an error can take: "you may not look here" is indistinguishable from "there is nothing on the platform". An operator checking whether a tenant exists gets a confident, wrong answer.

ts
const { data } = await Teams.listTeams({ throwOnError: true });

The failure path still runs either way — the interceptor claims the failure and the toast appears. What throwOnError changes is whether the caller finds out, and a list store that does not find out has no way to tell "empty" from "forbidden".

That asymmetry is the whole problem, because the two have different lifetimes. The toast dismisses itself after five seconds (setTimeout in the error store); the empty table stays on screen for as long as the operator looks at it, asserting in its own words that the platform has no teams. So the failure is not quiet — it is briefly loud and then confidently wrong, which is worse: the signal expires and the false statement does not. This is easy to miss when checking by hand; the first screenshot taken for the AGNT2-158 report caught an empty frame for exactly this reason. Pin it in a spec on the option itself rather than on the rendered result: AGNT2-160's slices/overview/team/stores/team.spec.ts asserts the call carries it, so the control is deleting one line.

When a call fails

The failure travels one path, and it is short. The interceptors hand the failure to the error slice; the error slice offers it to whoever registered a handler; if nobody claims it, the api's { success: false, code, message } envelope becomes a toast.

The toast provider is mounted once, in the panel's shell (setup/theme/layouts/default.vue). A screen that wants an error shown has to do nothing at all.

Theme

The panel uses the cabinet's palette and brand preset — an operator who moves between the two should not have to re-learn what "this is running" looks like — with four shadcn-vue primitives: Button, Card, Input, Label. They are auto-imported, without a prefix.

The rest of the cabinet's components are deliberately absent; they would arrive dead. Add what a screen actually needs with the shadcn-vue CLI into slices/setup/theme/components/ui, which components.json already points at.

Read the package.json diff afterwards. The CLI writes dependencies of its own alongside the component, and they are not always ones the component uses: adding table proposed @lucide/vue, which the panel has no use for, and floated @vueuse/core from ^14.3.0 to ^14.4.0 for an import that already resolved. Neither shows up as an error — the component works either way — so revert what the component does not need and reinstall. A caret range quietly moving is how this repo has broken before (AGNT2-160 caught this one).

One thing that is not copied: the cabinet locks html/body to h-full overflow-hidden because it is a fixed shell that scrolls inside its <main>. The panel's screens are long tables of tenants, and a long table wants the document itself to scroll — so the lock is not there, and a screen does not have to build its own scroll container to show a second screenful.

Adding a slice

  1. Make the folder under the group it belongs to: slices/<group>/<slice>/, named in the singular.

  2. Give it a nuxt.config.ts. That is what makes it a layer — nothing is listed anywhere else:

    ts
    import { fileURLToPath } from 'url';
    import { dirname } from 'path';
    
    const currentDir = dirname(fileURLToPath(import.meta.url));
    
    export default defineNuxtConfig({
      alias: { '#agent': currentDir },
    });

    The alias must be written in exactly that form. It is read back out of this file by a scanner, which rejects anything else loudly — a silently skipped alias is the failure this arrangement exists to prevent: a runtime that resolves it and a tsc that does not.

  3. Put pages/, stores/, components/, layouts/ inside it as needed. Stores are auto-imported because the ROOT nuxt.config.ts says so; a layer's own imports.dirs is silently ignored, which is a Nuxt layer gotcha and not something to re-discover.

  4. Run bun run boundaries before you believe it.

See also