Platform overview
The admin panel's three lists: every team, every person, every agent. They are the answer to a question the product could not answer before — what is actually running here? — and they are strictly read-only.
Who sees them: the platform administrator, a role separate from any role inside a team. How that role is defined and enforced is its own page; this one describes what it opens.


The three lists
| screen | endpoint | what a row says |
|---|---|---|
| Teams | GET admin/team | name, how many people, how many agents, when created, when last active |
| Users | GET admin/user | email, name, every team they belong to and their role in each, when registered |
| Agents | GET admin/agent | name, owner, team, type, status, whether autostart is on, when created |
Walking between them. admin/user and admin/agent both take ?teamId=, so a team row leads to its people and to its agents, and every agent row carries its team, which leads back. That is the whole navigation model: one filter, not a second set of screens.
Every list is a page. ?page= and ?pageSize=, pageSize capped at 100. There is no mode that returns every row, and that is deliberate: teams and agents are the things that grow.
What the lists do not show, and why
No content. The platform administrator sees facts about things — names, counts, dates, statuses, roles — and never the things themselves. No message text. No agent memory. No SOUL.md, USER.md or HEARTBEAT.md. No secret values, no password hashes.
The owner left this open ("everything, or everything except conversations?"); it was settled the narrow way because the two directions do not cost the same. Widening the view later is one field in one response. Un-seeing somebody else's conversation is nothing at all.
A timestamp is a fact, so "when was this team last active" is allowed, and it is derived from the newest chat message's clock. The message itself never leaves the database.
No actions. Nothing on these screens disables, deletes, or signs in as anybody. Not because nobody wants them: because an administrative action leaves no trace until the audit log exists, and an untraceable action over somebody else's tenant is worse than a missing one. Actions arrive with the log, in their own task.
No "last signed in". The lists show when an account was registered, not when its owner last appeared, because nothing in the system records a sign-in: there is no such column and no session table, and the sign-in path writes nothing. The right home for it is the audit log — a sign-in is an event, and a column would only ever answer "the last one", while a log also answers "how many", "from where", and "and the failed attempts?".
How it is built
Thin endpoints over a role guard — not a second copy of the product. The admin group holds no duplicate of any product slice. This is the explicit lesson of 1.x, recorded in the group's own README: "2.0 = thin admin endpoints + a role guard, NOT duplicate modules."
The data is read straight through Prisma. The admin group sits below runtime and agent in the group order, so it cannot import them — see Layered slices. Reading the tenants' tables directly is what keeps that order intact, and it is the declared design, not a shortcut.
The prefix is the protection. Every route lives under admin/, and one guard registered for the whole group covers everything under that prefix. No controller in the slice writes @UseGuards, and none may: a check written per controller is a check somebody eventually forgets to write.
The cost of a row is not a query
The way to get a list like this wrong is to fetch the rows and then ask the database one more question per row. Every answer is correct, so nothing looks broken — until the install is real. This project has been caught by exactly that before.
So the statement count here is fixed by the shape of the query, not by the number of rows:
| list | statements, 1 row | statements, 12 rows | statements, 20 rows |
|---|---|---|---|
| Teams | 6 | 6 | 6 |
| Users | 4 | 4 | 4 |
| Agents | 4 | 4 | 4 |
It is measured, not asserted: a test runs each list over one team and over twelve and requires the two counts to be equal. A per-row query cannot survive that comparison, whatever its constant.
These are end-to-end counts for a whole request, so each includes the one lookup the role guard makes before the handler runs.