@neuralis/admin
v0.3.1
Published
The management layer of Neuralis — where owners and admins govern a workforce of humans AND agents with one vocabulary: who is in which project, what each role may do (feature grants + priority), what everything costs, which secrets exist at which scope,
Readme
@neuralis/admin
The management layer of Neuralis — where owners and admins govern a workforce
of humans AND agents with one vocabulary: who is in which project, what each
role may do (feature grants + priority), what everything costs, which secrets
exist at which scope, and what happened (append-only audit). Builtin,
trust-tier first-party, in-process binding. Owns the Admin widget, the
dashboard / config / credentials / canvas routes, and the LLM-callable skill
bundles under skills/.
Responsibilities
- Surface system health, dashboard stats, and per-package status.
- Read-only inventory of users and projects. Mutation lives on host Next.js
routes, on two different prefixes: user CRUD under
/api/admin/users*, and project lifecycle under/api/projects/*(POST /api/projects,PATCH/DELETE /api/projects/[id],POST …/restore,DELETE …/permanent). The package's ownusers.ts/projects.tsare GET-only by design and must not gain a mutating arm. - Expose project role / feature-grant / limits metadata for admin UI.
- Edit platform configuration through
PlatformConfigStore(file-backed), read boot-critical env vars as read-only rows served from the HOST's explicitENV_INFOallowlist (listEnvInfo()on the store accessor — the old hardcoded admin-side env array is deleted; allowlist-by-construction, secrets can never pattern-leak in). Every editable row shows its declaring package (badge), its live value source (default / env / custom chip), and number bounds (min/max, client-clamped + server-enforced). - Manage encrypted credentials through the host's
CredentialStore— a scope-aware (global/project/user/agent; the agent scope names its project too —projectId+agentId, each defaulting to the session's, a request with no resolvable project is a named 400 — because an agent id is a per-project slug), dynamic catalog surfaced both in the Credentials tab and via themanage-credentialsskill (requiredFeatures: [project.credentials, project.credentials.write]— no grant below theadmintier). The catalog is 0-hardcode:buildCatalogunions, in order, package-declared ids (each loaded package'scredentials[]manifest array, collected via the kernel'scollectDeclaredCredentials()overctx.hostPorts.packageDiagnostics.listDefinitions()), skill-declared ids (credentials:frontmatter), then manual stored-but-undeclared ids — a collision only annotatesdeclaredBy, never overwrites. The formerBUILTIN_CREDENTIALS/builtinCatalog.tshardcode is DELETED. There is now exactly ONE credential surface — the packagecredentials/*routes — which fetch package creds and pass them tobuildCatalog. The parallel host route/api/admin/credentialswas deleted (CRED-TAIL INC 4): it enforced neither theproject.credentials(.write)features norassertScopeMatchesSession, and the Credentials tab drove it with a rawfetch. The tab now usesusePackageApi(). A row'soauthdisplay hint (carried from the declaringPackageCredential) drives the Connect/ Disconnect chrome (codex today). Every OAuth-class row — the Codex login and each outbound MCP server's OAuth grant — also carries astatus(valid·expiring·expired·refresh_failed·unknown, withexpiresAt/lastRefreshAt/note) that agent-core computes for the requested scope through the adapter'sdescribeCredentialStatus; the tab renders it as one chip on both row shapes (credentialStatusPresentation.ts).expiringmeans inside agent-core'scredentialExpiryWarningHours(default 24). A status never carries a token, an identity-provider body or a store path. Provider keys written at any scope are honored at stream time via the host's most-specific-wins resolver (agent → project → user → global) — Codex OAuth included, with no per-credential exception left in the resolver. The Codex block therefore acts on the SAME scope the tab's scope picker selects (there is no second, Codex-only pair of "for me" / "project override" buttons), names that scope explicitly on every start/import/disconnect, and shows the paste-the-callback-URL field in both completion modes — highlighted when the start answeredmode:'relay'. When the picker points at ANOTHER user, Connect and Import are not rendered at all: a Codex login is that person's own ChatGPT session and the server refuses the write by name, so the client mirrors the refusal instead of offering a button whose only outcome is a 403. Disconnect stays available. It listens onBroadcastChannel('codex-oauth')for both connect results and mid-stream{kind:'purged'}signals, re-fetching status so a "Connected" badge flips immediately when a credential is purged. - Audit and per-package log views.
- Vector section: the ACTIVE embedding generation (the lock — every write and search uses its model) beside the TARGET (
embeddingModelId/embeddingDimension— saving one starts nothing), a sample-based rebuild estimate and an explicit start / resume / cancel of the rebuild into the target (forplatform.vector.maintainholders), today's embedding spend against its daily cap, the operator embedding endpoints, and the destructive reset — which also deletes allbrain://content and its version history, because they live in the vector store. The status probe also reportsqdrant.versionGap— the server version against the Qdrant client brain-core ships (qdrantVersionGap); the Vector section warns when they are more than one minor apart and never changes backend for it. - Organization canvas (ReactFlow graph of users + agents).
Where it lives in the workspace
packages/admin/
manifest declared in package.json under the `neuralis` block
src/
index.ts public types + credential-ops re-exports
types.ts AdminState (the kernel base state), HealthReport, DashboardStats, ...
helpers.ts filesystem paths, project / user readers, and the ONE
scope + path-segment guards
healthChecks.ts Qdrant probe, disk, memory, providers, runtime + refusals
qdrantFetch.ts the ONE authenticated Qdrant REST call (api-key header)
configRead.ts live platform-config reads — deliberately NOT in
helpers.ts, which route tests mock with a fixed factory
agentCoreApi.ts the ONE typed agent-core adapter (see below)
packageOpError.ts shared `.code` → HTTP mapper for rescan / cache-clear
credentials/ credentialOps.ts — the scope-aware catalog builder and
the set/delete core shared with the skill route
routes/ file-discovered route handlers (see below)
usecases/ operatorEndpointList.ts — the operator-endpoint list
read and the ONE writer the `endpoints/*` route calls
app/host.tsx the runtime UI entry (`neuralis.app.module`): re-exports
installAdminHostComponents; `pnpm build:ui` bundles it
to dist/app/host.js + host.css (the React Flow sheet)
app/admin/
AdminWidget.tsx vertical sidebar shell, PROJECT / PLATFORM scopes
index.ts the widget barrel (`@neuralis/admin/app/admin` is the
surface's `component.import` id, not an export)
store.ts ONE Zustand store per window (tab + scope state and
slices) under a Context; the dock broom's window map
api/ admin-internal HTTP client (projects, users, feature
catalog, the endpoint lists, every Vector-section call
in vector.ts) — separate from neuralis/src/api so the
package owns its own contract
tabs/ ProjectOverviewTab, UsersTab, AgentOwnershipTab,
RolesTab, LimitsTab (PROJECT scope) and
DashboardTab, ProjectsTab, LogsTab, ConfigTab,
CredentialsTab, CanvasTab (PLATFORM scope, all
but Dashboard are feature-gated). Shared section
building blocks live in tabs/sections/.
canvas/ ReactFlow graph, edges, nodes, side panels
components/ SectionCard, KpiChip, SegmentedToggle, UsageChart,
UsageSignalCard, CodexSavingsCard,
codexWindowPresentation.ts and
credentialStatusPresentation.ts — shared card/chart
building blocks
hooks/ useAdminApi (useHealthPolling, useAuditLoader),
useUsageSeries (GET dashboard/usage),
useCodexSavings (GET dashboard/codex-savings)
host/ installAdminHostComponents — neuralis host wiring;
ProjectSettingsEntry — the workspace.projectSettings slot fill
lib/ pure client helpers: cn.ts (class merge), time.ts
(compact "how long ago"), and projectAccess.ts —
`canManageProjectRoles`, the client MIRROR of the
stored `canManageRoles` FLAG that the Agents and
Limits tabs render read-only from. Nothing in it reads
a role NAME: strength comes from the kernel
`rolePriority` predicate, governance from the flag.
ports/types.ts response DTO shapes the UI consumes
skills/ LLM-callable skill bundles (see "Skills" below)
workflows/ system-maintenance.json — the one shipped template
(in the `package.json#files` whitelist, so the tarball
and Docker image ship it)
test/ route suites + package-level guards (see "Tests")Admin ships NO tools and NO slash commands, and there is no tools/ or
commands/ directory on disk at all: runtime.hosted is none for tools,
prompts and resources, so nothing admin owns reaches the MCP server, and no
PackageFileDiscovery tool / command entries originate here. Admin's surface
to the LLM is the skill bundles plus the HTTP routes the skills wrap.
(package.json#files still whitelists tools — a harmless entry pointing at a
path that does not exist.)
Feature gates
Every route handler exports a feature string the package route dispatcher
matches against the caller's grantedFeatures. Tabs in AdminWidget are
gated by the same keys on the client; server-side gates are authoritative.
The two tiers
The namespace states where a decision ACTS, so a feature's blast radius is readable from its id:
project.*— the decision acts INSIDEsession.projectId. Never reads or writes another tenant.platform.*— the decision CROSSES the project boundary: another tenant's data, a platform-global file, cross-user records, or theglobalcredential scope.
No platform.* feature carries a default role grant. They are grantable —
an owner can hand one to any role — but nothing seeds them, so out of the box
only a role holding the '*' wildcard resolves them. Splitting them out is what
makes the project tier safe to grant broadly: the old single user-management id
fused the Users tab, the cross-tenant project list, the platform audit log and
the entire cross-scope guard onto ONE grantable thing.
project.* — 11 ids
| Feature | Surface |
|---|---|
| project.dashboard | Dashboard tab, /dashboard/* (stats, usage, codex-savings, pricing-basis), /health, /packages, /streams, /projects (read — session-scoped: a member sees only the projects they belong to; the :id detail arm applies the D2 feature-keyed reductions — other members' emails need project.members, other roles' grant lists project.roles, other members' byUser caps project.limits; a canManageRoles member gets the full view). Usage labels never fall back to a user's email (name or id only). Granted to every role by default. |
| project.members | Users tab (project scope), /users (package stub), the host /api/admin/users listing — the members of the caller's project, plus an exact-email lookup for adding an existing user. |
| project.audit | Logs tab, /logs, /logs/sources — the project-scoped log sources (every non-first-party package's log under its runtime/<scheme>/<leaf> zone, walked down to each logs/, and the per-agent run log) under data/<projectId>/. Every FIRST-PARTY package's own file logs live in the platform home app/data/<package>/logs/ and, like audit/routes, need platform.audit; ONE predicate (isPlatformRootedSource) decides the root for the listing, the gate and the read, and a first-party slug's logs/ still sitting under the project root (written before the move) is never listed. The path field on every source row and on the read response is a DISPLAY key, never the absolute host path: this is a member-tier feature and the client renders path as a tooltip. Shape depends on where the file lives — <pkg>/logs/<file> for anything under the project data root (the qualifier matters: a bare basename would make every package's package.jsonl the same string), and a bare file name for the two host rows (audit, routes), which sit outside both data/ roots. |
| project.roles | READ this project's role map: the Config → Roles & Features section and GET /config/roles. Writing it is a separate decision — see the governance note below. |
| project.limits | READ this project's spend-limit rules including OTHER members' per-user caps (D2). The project/role/agent rules, the caller's OWN byUser row and rateLimitRpm are always visible; without this feature the /projects/:id detail (and the host GET /api/projects*) omit other members' byUser rows. Writing limits stays behind the canManageRoles FLAG, which also implies the full read. Granted to admin by default (v16 repair). |
| project.sources | Config → Source Configs, GET/PATCH /sources. |
| project.credentials | Credential READ/catalog: the Credentials tab and the manage-credentials skill through GET /credentials/* — scope-aware, and confined to the caller's own session scope unless platform.scope is also held. |
| project.credentials.write | Credential MUTATION (PUT/DELETE on credentials/*) at the caller's OWN scope. No grant below the admin tier — the seeded admin role holds it through its enumerated default grant, owner through the '*' wildcard; grantable to any custom role. Rate-limited to 10 mutations/min per user (a code-level anti-brute-force floor, not a tunable) and audited with the id + scope only, attributed to the verified session user. |
| project.canvas | Canvas tab, /canvas/{graph,agent/:id,user/:id}. The user-detail card's lastActivity field escalates to platform.audit on its own (it is derived from the cross-tenant platform audit log) and is simply OMITTED without it — the card still renders. |
| project.members.invite | POST /members-invite (the create-projects-and-users skill): invite a new user into THIS project. Declared stacking — the role's canInvite flag is ALSO required, and canAssignRole caps the assigned role at the caller's strength. The response NEVER carries password material (the transcript is member-readable + vector-indexed); first password = operator reset via the Users tab. Granted to admin by default (v15 repair). |
| project.agents.assign | POST /agents-assign (the same skill): assign/unassign a project agent to a member (agentOwnership.assignedTo). Declared stacking — the role's canManageRoles governance flag is ALSO required, the same floor as the Agents tab's privileged PATCH. Granted to admin by default (v15 repair). |
platform.* — 8 ids, none default-granted
| Feature | Surface |
|---|---|
| platform.projects | See and read projects you are NOT a member of: the platform Projects tab, the cross-tenant /projects list, scope=platform usage series, groupBy=project, the project census in /dashboard/stats, and the host /api/admin/project-packages. |
| platform.users | Platform-wide USER records and cross-user spend: groupBy=user, the user census in /dashboard/stats, canvas detail for a NON-member, the whole roster at GET /api/admin/users, acting on a user who belongs to no project, and deleting a platform user record (on top of governing that user in every project they belong to — the host's canGovernUserRecord). |
| platform.scope | Act on a scope OUTSIDE your session — another project's, another member's, another agent's, or the global credential scope. The key assertScopeMatchesSession requires, and the gate on the credential scope-target picker. |
| platform.audit | Every platform-global log: /audit and /logs?source=audit (security/admin events), /logs?source=routes (app/logs/routes.jsonl — one line per package-route dispatch, every tenant), and every first-party package's own file logs under app/data/<package>/logs/ (system, package, sync, vector, and pkg with a bare first-party slug — e.g. agent-core, brain-core, admin, machine-core, plus any first-party host dep). None of them carries a projectId, so none can be project-filtered; they all ride this SAME split rather than declaring a feature of their own. |
| platform.config | Platform-global settings: GET/PATCH /config, the operator-endpoint lists and probe (endpoints/*) and agent-core's Codex CLI import. Affects every project on the instance. Also the release line: GET /updates, the Dashboard's version card and the update.available notice. |
| platform.vector | Vector store READ: GET /vector/status, the Config → Vector section, and the module gate of vector/collection. |
| platform.vector.maintain (brain-core's) | Embedding rebuild, dense-only clear and the platform-scope dense-backfill catch-up through GET and POST /vector/collection, re-checked in-handler — the same feature brain-core's health/collection requires. Declared by brain-core, platform.vector.maintain carries no default role grant (the owner holds it through '*'). |
| platform.vector.reset | The destructive POST /vector/reset — drops Qdrant collections across ALL projects. |
| platform.projects.create | POST /projects-create (the create-projects-and-users skill) — creating a project MINTS a tenant. Reachability only: the REAL floor is owner-strength (priority ≤ 1 in an existing project), re-derived inside the host service; the feature alone is never sufficient. |
| packages.manage (namespace-free) | The package-runtime mutation routes /rescan and /cache/*. Project-scoped and already namespace-free, so it keeps its bare id. The route-level gate is the coarse advertise tier; the host shared fn re-derives canManagePackages FRESH per call (deny-by-default floor, never the ticket's cached grants). |
Governance is a FLAG, not a feature
PATCH /config/roles requires project.roles to be REACHED (the route
dispatcher) and, on top of that, the caller's role must carry the stored
canManageRoles flag — re-derived in the host body patchProjectRoles from the
record as it is on disk at write time, not from the session. There is no feature
arm: governance must not be self-grantable through a capability toggle, and the
flag is what the host's own canManageProjectRoles checks on
PATCH /api/projects/[id]. So a role granted
project.roles can READ the role map and cannot rewrite it. The write is
additionally session-scoped (a body projectId may only AGREE with the session)
and gated by the ONE shared write body validateRoleMapWrite
(@neuralis/package-system/access) — literally the same function the host's
validateRolesPatchPriority calls, not a mirror of it. Two rules, both keyed on
the priority NUMBER and never on a role name:
- STRENGTH — you may edit only roles STRICTLY WEAKER than your own resolved
priority. A role at or above your strength cannot be modified and cannot be
deleted, your own role included. That
<=is what makes self-lockout structurally impossible, and it replaces two invariants that would otherwise be needed separately. CREATE is the one place equality is allowed: minting a PEER-strength role is permitted (rule 2 confines it to your own feature set anyway), which is what keeps a second owner-strength custom role possible. - CONSERVATION — you may grant only, and revoke only, features you hold yourself. Symmetric by design: the revoke half is what stops a caller DESTROYING authority they never had, and it also runs over a deleted role's whole grant list so deletion is not a bypass.
Priorities must still be integers in 1..99, and built-in anchors stay
immutable. Consequence worth stating: governance flows strictly downward — the
strongest role in a project is tunable only by something stronger, so the apex
role's definition is effectively frozen once seeded.
The body's roles REPLACES the project's whole role map — it is not a merge.
This route holds no gate and no writer of its own: it hands the map to the host
body patchProjectRoles through the governance port, which re-derives the
canManageRoles flag and runs the two rules above against the record as it is
on disk, then writes through the host updateProject — so the host's ONE
structural record invariant runs on the same write. That invariant requires the
candidate record to keep the provenance owner a member on the seeded owner
role, to keep roles.owner, and to leave no member pointing at a role the patch
removed. A violation is 400 and nothing is written; a write-rule refusal is
403 with the rule that refused; an unwired port is 503 (fail CLOSED — never
write unvalidated).
This route used to write <project>.json RAW — readFile → mutate →
${file}.tmp.${Date.now()} → rename — outside the host store entirely, which
is why it needed a separate host-injected copy of that invariant to be safe at
all, and why {"roles":{}} once answered 200 and erased every role of a real
project while the host route rejected the byte-identical body. Both the raw
write and that second port are gone. The prohibition stays: a package route
never writes a host record directly; the governance port is the only path.
Stated as the guarantee it is: a role that has MEMBERS cannot be deleted; a
member-less role can — provided it is also within your reach under the two
rules above. Deliberate deletion of a weak, member-less role is not blocked:
reassign the members first, then delete. The structural invariant is what keeps
the members side honest, and it stays the ONE body both write paths share; it is
deliberately not duplicated inside the authorization gate. Pinned by
neuralis/src/server/store/__tests__/projectInvariants.test.ts (the guarantee, all
seven shapes) and by the adoption case in test/routes/config.test.ts, which
asserts this route hands the invariant the stored members alongside the new
roles — without that field the refusal cannot fire.
The two refusals are independent and report differently: the write rules answer 403 naming the rule that refused, the structural invariant answers 400.
Both write gates now walk the UNION of the stored and incoming key sets.
That is load-bearing, not tidiness: roles and members are replaced wholesale
by both writers, so iterating the incoming map alone made every DELETION
invisible — omitting a stronger role, or a stronger member, was permitted with
nothing to check. The same union walk closes the grant-truncation hole this
README previously recorded as a deliberate open gap: emptying a surviving role's
grantedFeatures is a revoke, and a revoke you cannot hold is refused.
Default grants
project.dashboard goes to every role, so non-owner users still see the system
overview — which is exactly why /projects scopes by session and puts any
cross-tenant read behind platform.projects, deny-by-default.
admin receives the enumerated union of every first-party non-platform.*
feature, derived from the manifests (see "The admin role's grant list" below).
owner keeps the '*' wildcard.
/dashboard/usage escalation: the module gate is project.dashboard, but
inside the handler scope=platform (cross-tenant series) requires
platform.projects and groupBy=user (per-user spend, in either scope)
requires platform.users. /dashboard/codex-savings at platform scope requires
BOTH — it aggregates across every tenant AND labels per-user subscription rows.
Per-user spend and cross-project visibility are user-data surfaces;
project.dashboard alone (granted to every role) must not leak them.
Project lifecycle (members, limits, roles, agent ownership) is gated separately
at the host level through the canManageRoles flag or packages.manage.
The admin role's grant list
DEFAULT_ROLES.admin no longer ships the '*' wildcard. It receives
every feature declared by a first-party package, MINUS the
platform.*tier.
That list is never hand-typed: each package declares its own contribution under
neuralis.requires.defaultRoleGrants.admin, and the host union
(mergeDefaultRoleGrants) IS the list. Declaring a non-platform.* feature
WITHOUT an admin grant fails
packages/package-system/test/adminGrantCoverage.test.ts, which names the id and
the manifest to edit. Existing projects are de-wildcarded by the on-disk role-grant
migration, which fails CLOSED if the manifest union reads back short or empty.
Two consequences worth stating plainly:
- A project admin is confined to their own tenants by default. They cannot see a project they are not a member of, read the platform audit log, patch platform settings, or act on another member's scope.
- A project admin does not automatically inherit features contributed by a
project-dropped
_packages/package. The'*'wildcard used to match those ids; an enumerated list cannot. They remain listed in the feature catalog and an owner can grant them by hand. This is deliberate: a dropped package must not be able to grant ITSELF onto the strongest in-project role.
The wildcard itself is not an invariant — the write floor still lets a '*'
holder grant '*' to a role. What changed is that the seeded admin role no
longer ships with it.
Role priority (the assignment gate, surfaced in the UI)
Every RoleDefinition carries an ordinal priority (lower = stronger;
built-in anchors owner 1 · admin 2 · manager 10 · member 20 · viewer 30, and a
declared priority must be an integer in 1..99). It is orthogonal to
feature grants: features answer "what can this role do", priority answers "what
strength of role may a caller hand out". A caller may only assign/define a
role whose priority is ≥ their own — enforced server-side (the host
rolePriority/canAssignRole predicate; deploy S4), so a member cannot
mint an admin agent and an admin cannot invite an owner. Built-in
priorities are immutable; custom roles carry their own. A member's
strength is derived from their role (no per-member priority field;
ProjectMember.tier is a legacy mirror of the role priority).
The admin UI surfaces priority everywhere it is relevant (deploy S4b):
- Project
RolesTab→RoleCardshows aPriorityBadgeand an editable priority input (built-in roles read-only; the input min-clamps to the viewer's own priority — best-effort, the server gate is authoritative). ProjectOverviewTab→HierarchyTreegroups members by their role priority;MemberEditModalshows the derived priority read-only (no more hand-editedtier);UsersTabshows it on the active-project chip and on the invite form (which only offers roles the caller can assign). The row verbs (disable / enable, reset password, delete) render from the row's server-computedcan— the caller must govern the user in EVERY project the user belongs to, which only the server can see — and are hidden, never disabled, when refused. Delete opens a preflight panel first (GET /api/admin/users/:id): the projects that refuse it (provenance or last active owner; unseen ones only counted), and what leaves with the user (memberships, personal credential ids).- Platform
ConfigTab→ Roles & Features shows + edits priority (built-in read-only);ProjectsTabshows it in the project browser. ProjectsTabis a management surface, not a read-only browser. It lists active + archived projects (theprojects/*route now includes archived +archivedAt) and, for a project the caller holds OWNER STRENGTH in (D-B: the server derivesisOwnerStrengthper row andcanCreateProjectacross all rows overisOwnerStrengthOf, and sends both — the list payload carries no member/role map for the client to judge from, andownerIdrides along as PROVENANCE only, never read for a gate), exposes: Create (canCreateProject— owner strength in ANY project, including rows this list filtered out →POST /api/projects), member management in the expanded detail (add an existing user found by exact email / remove / edit role viaMemberEditModal→ the hostPATCH /api/projects/[id]which re-enforcescanManageRoles+ role-priority), Archive (DELETE /api/projects/[id], moves the row to the Archived section), and per archived project Restore (POST …/restore) + Delete permanently (DELETE …/permanent, a typed-name-confirm dialog listing exactly what is purged — vector memory, machine desktops and their browser profiles, data tree, source configs, project + agent credentials — and saying that a failed step leaves the project archived and the delete retryable). All mutations call the HOST routes viaapp/admin/api/projects.ts; the adminprojects/*route itself stays read-only (project.dashboard). The dock Project switcher no longer creates projects — creation moved here. Every row shows the project id beside the name (two projects may share a name; the id is fixed at creation), and the list reloads on the workspaceprojectrealtime frame (WorkspaceHostPort.subscribeRealtime) and once on a hub reconnect, so a create, rename or restore in another client appears without a manual refresh.
Backend routes (src/routes/)
Routes are file-discovered through PackageFileDiscovery; each module
exports pattern, feature, and one or more of GET / POST / PUT / PATCH /
DELETE handlers conforming to RouteHandler from
@neuralis/package-system/contracts (credentials.ts is the only PUT). Eight
of the twenty-one also export an escalations array — CATALOG/LINT metadata that
gates nothing at runtime: RouteDispatcher enforces route.feature and
nothing else, so a module whose sub-paths straddle the two tiers sets its
feature to the WEAKEST one and must hand-code the stronger check inside EVERY
method arm that can reach it. test/routes/escalationDrift.test.ts guards the
declaration in both directions and openly states its limit: it cannot see
per-method or per-sub-path coverage. They run inside the host process
(in-process binding) and receive (req, state) where state is the
AdminState: the kernel's base state for a package with no lifecycle file —
{ ctx, ...ctx.hostPorts }, i.e. its own context beside the host ports
(platformConfig, credentialStore, audit, governance, …) that only a
host-assigned first-party context receives. Admin has no src/lifecycle.ts.
| File | Pattern | Methods |
|---|---|---|
| health.ts | health | GET — runtime health contributes an aggregate packages check; displayed package rows remain the four builtin ids with ctx.packages.isLoaded(id) flags. Scoped package identities and individual runtime checks stay internal; missing or not-ready diagnostics returns 503 (also on dashboard/stats) |
| dashboard.ts | dashboard/* | GET (stats, usage, codex-savings, pricing-basis) |
| packages.ts | packages | GET |
| streams.ts | streams | GET |
| users.ts | users | GET (discovery stub — returns { href: '/api/admin/users', mutable: false }; real user CRUD is the host route) |
| projects.ts | projects/* | GET (list + detail) |
| logs.ts | logs/* | GET (/logs, /logs/sources) |
| audit.ts | audit | GET |
| updates.ts | updates | GET — module feature platform.config; the installed release line (this package's own manifest version) and the last update check (latestVersion, updateAvailable, checkedAt, status ok/failed/never/disabled) from the check's cache. No request leaves the platform here, and the answer names no URL and no error text |
| config.ts | config/* | GET / PATCH (config, config/roles, config/embedding-target) — PATCH config/embedding-target {modelId, dimension} sets the embedding TARGET as ONE write: both validated first (brain-core resolveEmbeddingModel + isSupportedDimension; 400 unknown_embedding_model / unsupported_dimension, nothing written), then both keys stored with nothing between — two single-key PATCHes pass through a model-at-the-wrong-dimension state; it starts nothing, the active model keeps serving until a rebuild. The settings PATCH refuses the two operator-endpoint lists (localLLMs, embeddingEndpoints, the kernel's OPERATOR_ENDPOINT_CONFIG_KEYS) BY NAME, reset included (400 endpoint_list_key — PUT endpoints/<family> is their one validating writer), and accepts an embeddingModelId only when brain-core's resolveEmbeddingModel finds it — a catalog row or a declared endpoint:<id>:<model> row (400 unknown_embedding_model). It also carries a {key, reset: true} arm behind the SAME in-handler platform.config escalation: it removes the platform.json override via the accessor's removeOverride, so the value falls back to the key's envFallback when it resolves (the row reads back valueSource: 'env') or the declared default; idempotent 200, unknown key 404, read-only 403, and a host without removeOverride answers 500 (never a silent no-op) |
| credentials.ts | credentials/* | GET (catalog + scope-targets + use) / PUT / DELETE (credentials/:id) / PATCH (use/:id) — module feature project.credentials (read tier); BOTH write arms re-check project.credentials.write in-handler, scope-targets re-checks platform.scope, writes are capped at 10/min per user (a code-resident floor, not a tunable) and audited with the id + scope label only, never the value. Credential-use limits v1: GET use (rules + per-window/30-day usage + the derived LLM-excluded ids) and PATCH use/:id ({rule: {maxCalls, period} \| null}) both re-gate platform.scope in-handler — v1 rules are per-credential-id and PLATFORM-GLOBAL (they bind every project); the PATCH additionally re-gates project.credentials.write, refuses LLM-excluded ids with the honest "USD spend limits govern those" message, audits credential.use_rule_update, and both arms fail CLOSED (503) when the ctx.hostPorts.credentialUseAdmin port is absent |
| endpoints.ts | endpoints/* | GET/PUT (llm · embedding) + POST (probe) — module feature project.credentials (read tier); EVERY arm re-checks platform.config in-handler. GET/PUT read and replace one family's operator-endpoint list through usecases/operatorEndpointList.ts (the kernel parser + address check + config-write guards; PUT is human-only and audits config.write). The probe audits config.probe and takes TWO target shapes, neither carrying a key: {endpointId} probes a SAVED endpoint (the exact entry, the exact credential the runtime will use), {baseUrl, kind, network?} answers reachability for an address nobody has saved yet (network absent ⇒ the VALIDATED public class). Real-wired via the ONE adapter's probeLocalEndpoint, because the address validation, the connection pin and the model discovery are all agent-core's — a copy anywhere else would be a second egress floor |
| sources.ts | sources | GET / PATCH |
| vector.ts | vector/* | GET (status, collection) / POST (reset, collection) — the lock is read through brain-core's own reader (a refused lock is reported as lockError, never as "no lock"); the collection comes from the lock. collection is brain-core's collection maintenance reached server-side (getProjectInfra → describeCollection for the read, runCollectionAction for an action — the widget mounts no brain-core client, so the browser never calls brain-core's agent-scoped route itself), re-gated in-handler on platform.vector.maintain and proxying ONLY rebuild-estimate, rebuild-generation (start/resume, 202), rebuild-cancel, dense-clear (confirmation CLEAR_DENSE; payload/text/history preserved) and dense-backfill (body source, required string → 400 otherwise; enqueues ONE platform-scope embedding catch-up of that source through brain-core's enqueueEmbeddingCatchup, 202 — the way back after a dense clear; it checks neither that the source exists nor that embedding is enabled, an unknown source surfaces as a failed queue job); brain-core's "not now" refusals (action_in_progress, rebuild_running, no_switch_pending, no_rebuild, no_active_generation, rebuild_unavailable, embedding_disabled) answer 409, the rest 400. status lists the catalog plus brain-core's endpoint rows (listEndpointEmbeddingModels) and says activeResolvable: false when the active model no longer resolves; it carries activeCredentialId (the ACTIVE model's key — the Vector section's primary credential row; null without a lock, when the first write builds from the target). The TARGET (current.modelId/dimension) is brain-core's own resolveTargetEmbeddingShape() — never a default typed here; one that does not resolve is named in targetError (dimension null). embeddingEnabled is the intent switch; the Current card renders a metadata lock as "Metadata only — no embedding model active" and compares the target only against a dense lock. Reset drains admitted work and drops recognized owned base/generation/shadow targets, then removes the lock and collection memo only after every deletion succeeds. Partial deletion reports dropped/remaining targets and preserves ownership for retry; cleanup failure reports incomplete cleanup without rollback |
| cache.ts | cache/* | POST (clear) — packages.manage; wired via ctx.hostPorts.packageMaintenance |
| rescan.ts | rescan | POST — packages.manage; wired via ctx.hostPorts.packageMaintenance |
| canvas.ts | canvas/* | GET (graph + per-entity) |
| agents-admin.ts | agents-admin/* | DELETE (:id) — core.agents; thin wrapper over agent-core's AgentService.delete via the ONE server adapter (the Agents tab trash button); the authority stays agent-core's canAccessAgent('delete') |
| members-invite.ts | members-invite | POST — project.members.invite + the role's canInvite flag re-derived in the host service; NEVER returns or accepts password material |
| projects-create.ts | projects-create | POST — platform.projects.create; the real floor is owner-strength inside the host service |
| agents-assign.ts | agents-assign | POST — project.agents.assign + the role's canManageRoles flag re-derived in the host service |
Twenty-one modules, one file each — this table is the full inventory of
src/routes/.
The FOUR governance routes (members-invite, projects-create,
agents-assign and config.ts's PATCH config/roles) do not write anything
themselves: each calls the host's ONE gate body through AdminState.governance
(the hostPorts.governance port; absent ⇒ 503, fail closed). The port shape is SCALAR
caller identity (callerUserId, projectId from the verified session); the host
services (neuralis/src/server/admin/{inviteUserToProject,createProjectForUser,assignAgentToUser,patchProjectRoles}.ts)
re-derive the floor from the LIVE project record, inside the store's per-record
write chain, and are the SAME bodies the cookie routes (POST /api/admin/users,
POST /api/projects) run — one gate body per operation, two callers, no drift.
The routes fail CLOSED (503) when the port is absent, and accept no identity
fields in the body.
Cross-package data the routes need (active streams, connector status, MCP
servers, usage summaries, vector dimension config, source mutation) is
fetched through state.ctx.packages.getPackageApi<…>() against
@neuralis/agent-core and @neuralis/brain-core — never via internal MCP
callTool, which is forbidden by CLAUDE.md Architectural Principle #6.
The two directions are typed differently, because the dependency edges differ.
Admin DOES depend on @neuralis/brain-core, so src/routes/sources.ts imports
the canonical BrainCoreApi and ListSourceEntry (the listing row) — there is no
hand-written brain-core shape left in this package, and a renamed brain-core
member is a compile error at the call site. Admin does NOT depend on
@neuralis/agent-core (Golden Rule 2), so that side stays a structural mirror —
pinned by a compile-time guard rather than by an import (below).
The agent-core surface goes through ONE adapter: src/agentCoreApi.ts
(AgentCoreAdapter + getAgentCoreAdapter()) is a narrow structural mirror
of the real AgentCoreApi members admin reads. That mirror is the COMPLETE
surface — usageStore.summarizeProject / usageStore.aggregate /
usageStore.projectSpan, listConnectorInstances, listMcpServers,
getActiveStreams, listCodexSubscriptions, refreshCodexUsage(session)
(the fail-soft one-shot refresh of the CALLER'S OWN Codex scope, never another
principal's), agentService.delete, and probeLocalEndpoint. Credential channel
references and the token-free status describer have their own narrow mirrors.
Package maintenance and runtime declarations/health use the kernel's first-party
host ports. Every mirrored member must exist on the real API, and
the list is not additive by convention: adding a member here without it
existing on the real API is the bug this type exists to prevent. Every route
AND every test stub must use this type — the
2026-06 dashboard regression (Usage/Connectors/MCP/streams silently 0)
happened because routes and mocks both invented getUsageStore() /
getConnectorRegistry() / getMcpClientManager() methods that never existed
on the real API, and optional chaining swallowed the mismatch.
The mirror is no longer only a convention: test/agentCoreApiContract.test.ts
COMPILES AgentCoreAdapter, AgentAccessContextMirror, the channel credential
references (AgentCoreChannelCredentialIdsApi) and the REQUIRED status describer
(AgentCoreCredentialStatusApi) against agent-core's shipped .d.ts, read by
absolute path — so an invented member, a renamed one and a changed argument or
return shape all fail there. No import edge is created and Golden Rule 2 is
intact; the helper is assertApiMirror from
@neuralis/package-system/testing. The guard reads a BUILT declaration, so it
fails loudly when agent-core's dist is missing rather than passing vacuously.
GET dashboard/usage
Time-bucketed usage series for the dashboard / overview charts, aggregated
server-side by agent-core's UsageStore.aggregate (the response stays small:
≤ 50 series + other, ≤ ~50 buckets — the cap is high enough that every model
with real usage renders, so other only appears in pathological cases).
- Query:
range=24h | 7d | 30d | all(fixed buckets 60 / 360 / 1440 min, default24h;all= all-time — the route probesUsageStore.projectSpanfor the earliest event across the in-scope projects and picks a dynamicbucketMinutestargeting ~48 buckets, staying under the aggregate 1000-bucket cap; an empty project falls back to a 24h window),groupBy=model | agent | user | project(defaultmodel),scope=project | platform(defaultproject). Invalid values → 400. scope=projectaggregates exactly the session'sprojectId(never a caller-supplied id);scope=platformenumerates all project files.groupBy=projectis only valid withscope=platform.- Response:
{ usage: { buckets, bucketMinutes, groupBy, series[], totals, scope, range, labels?, pricingBasis } }—labelsresolves user/project ids to display names for the legend; agent/model keys are returned raw.pricingBasisis agent-core's own disclosure, forwarded verbatim (seeGET dashboard/pricing-basis). - Gates: module
project.dashboard;scope=platformadditionally requiresplatform.projectsandgroupBy=userrequiresplatform.users(see Feature gates above). - Series + totals also carry
equivalentCostUsd— the Codex subscription "savings": the equivalent OpenAI API cost of the (otherwise$0-billed) codex usage, recomputed server-side from token counts. The per-model legend rows render a codex row's equivalent in an accent colour instead of its$0.
GET dashboard/pricing-basis
The ONE sentence saying what every USD figure in this package MEANS — the rates
are the catalog's LIST prices, never negotiated or promotional, so for a model
the catalog prices the figure can overstate but never understate. agent-core
owns the wording (UsageStoreImpl.pricingBasis); admin forwards it and never
authors a second copy.
- Response:
{ pricingBasis: { kind: 'list', note } }.503when the agent-core adapter is unavailable; an unknowndashboard/*sub-path is still404. - Gate: the module's
project.dashboardonly — the value is a process-level constant string with no tenant, user or credential data, so it carries no escalation. - The Limits tab reads it FAIL-SOFT. A custom role can hold
project.limitswithoutproject.dashboard; such a caller gets the limits paragraph WITHOUT the sentence, never an error and never a locally-written substitute.
GET dashboard/codex-savings
Per-connected-subscription Codex savings + break-even. Codex (ChatGPT OAuth)
usage is subscription-billed ($0 per token), so the "savings" is the
equivalent OpenAI API cost of the same tokens. Each connected subscription =
one persisted Codex usage-limit snapshot scope (global / user:<id> /
project:<id>, from agent-core listCodexSubscriptions).
- Query:
range(same as/dashboard/usage),scope = project | platform(defaultproject). Invalid values → 400. scope=projectshows only THIS project's own subscription;scope=platformshows every connected subscription and requires BOTHplatform.projectsandplatform.users— it aggregates across every tenant AND labels per-user rows from the platform user files, so it crosses both boundaries. Default scope = caller's session.- Per subscription: savings is
UsageStore.aggregate(...).totals.equivalentCostUsdover that scope's usage; the plan's monthly fee (estimated from the OpenAIplan_type, agent-coreresolveCodexMonthlyUsd) is pro-rated day-precise onto the selected window (monthlyUsd × windowDays / 30.4375) for a recouped-%. Unknown plan → savings only, no recouped-%. - On open the route makes ONE fail-soft live refresh of the caller's own Codex
scope via the
agentCoreApiadapter'srefreshCodexUsage(session)(never impersonates another user/project; a refresh failure downgrades to cached computation, never a 500). A row isfreshness:'live'ONLY when it is the caller's own scope AND its snapshot version exactly matches the refresh; every other row (all platform scopes, a stale/fallback snapshot) is'cached'. No polling. - Response:
{ codexSavings: { scope, range, sinceMs, untilMs, windowDays, totalSavedUsd, subscriptions: [{ scopeKind, scopeId?, label, planType?, monthlyUsd?, savedUsd, planCostForWindowUsd?, recoupedPct?, windows[], freshness, receivedAt, source }] } }—windows[]({ slot, usedPercent, windowMinutes, resetAt }, sorted shortest-first) drives the DYNAMIC reset-countdown rings; the admin-localcodexWindowPresentation.tsderives each ring label fromwindowMinutes(never a fixed 5h/weekly).scopeIdentityis an internal comparison field and is NOT in the public row. The payload also carriespricingBasis— on the payload rather than per row, because the disclosure is one card-level line and N subscriptions would otherwise carry N identical copies of the same string. It is NOT a promise about the empty case: with no subscriptionsCodexSavingsCardearly-returnsnulland renders nothing.
App surface
AdminWidget (app/admin/AdminWidget.tsx) is the widget
declared as neuralis.app.surfaces[].kind = "widget" with renderer
direct importing @neuralis/admin/app/admin. A matching kind = "dock"
surface puts it on the bottom dock rail with the Shield icon.
Several Admin windows can be open at once (open.singleton: false) — for
different agents, or twice for the same one. Each window gets its OWN store
(tab, scope, canvas selection, loaded data, loading flags), created by the
install entry for that window, and every admin package-route request it makes
carries the window's project and its BOUND agent (X-Agent-Id), never the
workspace's active agent — except the Credentials tab's credentials/* calls,
which carry the project only: that route defaults a missing agentId to the
session's, so the agent scope a credential request names comes from the scope
picker alone, never from the window. The window's store is keyed by that scope, so an answer still in
flight for an old project, agent or user lands where nothing reads it. The dock
broom's "Clean window" renews exactly one window's store and leaves its
siblings; nothing on the server changes. The Config tab reads agent-core's
model catalog through a client built from the same window scope.
Sidebar layout — mellow-percolating-wind (2026-05-20)
AdminWidget is a vertical sidebar shell with two scopes:
- PROJECT — operates on the currently active project (resolved from
WorkspaceHostPort.useProjectId()). Tabs:project-overview,users,agents,roles,limits. The host-sideneuralis/src/workspace/shell/ProjectSettingsPanel.tsxwas deleted (it had lingered with zero importers after the dashboard merge) — every project-config screen now lives here. - PLATFORM — cross-project / system-wide. Tabs:
dashboard,projects,logs,config,credentials,canvas. Onlydashboardis visible to everyone; the other PLATFORM tabs are feature-gated and appear only when the caller'sgrantedFeaturesinclude the matching feature key (see the table below).
The sidebar has three states (app/admin/sidebarMode.ts): the labelled
column, the icon rail and hidden. Until the user chooses, the widget's width
decides (below). Its header button names what a click does — "Expand
sidebar" on the rail, "Collapse to icons" on the column — and "Hide sidebar"
sits beside it; a hidden sidebar leaves one "Show sidebar" button inside the
content area, which brings back the state it was hidden from. A choice is not
persisted. Selecting a tab in either scope flips the active scope
automatically, so navigation between PROJECT and PLATFORM needs only one
click.
Tabs respect the caller's grantedFeatures:
users— visible only whenproject.membersis granted.roles— visible only whenproject.rolesis granted.agents/limits— always visible; the editor renders in read-only mode unless the caller's role carries thecanManageRolesFLAG. Both write through the hostPATCH /api/projects/[id]privileged path, whose gate is that flag — so the affordance mirrors the flag, not a feature.project-overview,dashboard— always visible.- PLATFORM
projectsrequiresplatform.projects;logsrequiresproject.audit;configrequiresproject.roles(its weakest section);credentialsrequiresproject.credentials;canvasrequiresproject.canvas.
The Config tab gates PER SECTION with the same requiredFeature shape:
Platform Settings needs platform.config, Vector needs platform.vector,
Roles & Features needs project.roles, Source Configs needs project.sources.
A section the caller cannot use is HIDDEN, never rendered disabled — an
unusable contribution is not advertised.
Platform Settings carries a declarer filter: one select narrowing the ~200
rows to a single declaring package, the host's own residual keys (host) or the
read-only boot env rows (env). Its options are DERIVED from the loaded rows'
declaredBy — never a hardcoded @neuralis/* list — because first-party trust
is assigned by SOURCE (a package in the host's dependencies, whatever its scope),
so an operator's own builtin gets an entry with no code change. The counts are
per-caller honest for the same reason: the route already withholds the vector
category, so a declarer is offered with the rows this caller can actually see.
Filtering is client-side over the response the tab already holds — no second
request and no new gate.
If the persisted activeTab (or the active Config section) is hidden by a
feature drop, the widget transparently falls back to the first visible one.
Until the user chooses a state, Tailwind CSS container queries pick it:
≥720px— full sidebar with labels.<720px— icon-only rail (the label appears as a hover tooltip); "Expand sidebar" shows the labels at any width.
Accent color is pastel violet (#cba6f7). The manifest pins no
transparency, so the widget wears the user's Widgets material (Opaque /
Dim / Clear) like every other window; the surface is the host chrome's.
Do not add background-color to the root container. The host's
WidgetControls cycle button changes one window's material at runtime.
The DashboardTab (PLATFORM scope), the Limits editor (PROJECT scope),
and the Agents editor (PROJECT scope) all use a shared colour-coded
icon card grid:
- Each section is wrapped in
SectionCard(app/admin/components/SectionCard.tsx) — a translucent surface with a tinted pastille icon (bg-{color}-500/12+text-{color}-300/85), bold title, optional badge, and ChevronDown rotate when collapsible. The header pattern mirrors the chat-configClientConfigPanelso the workspace reads as one design language. - DashboardTab renders a dense KPI chip strip (8 metrics via
KpiChip— Users, Projects, Agents, Packages, Connectors, MCP, Streams, Status+uptime; 2 → 4 → 8 columns by container query), then the usage chart card (UsageSignalCard, see below), then the Codex savings card (CodexSavingsCard— total + per-subscription savings with a day-precise "paid off" bar and 5h/weekly reset-countdown rings; backed byGET dashboard/codex-savings, rendered only when a Codex subscription has produced usage), then a 2-column grid of compactedSectionCards: System health (status headline + per-check rows merged into one card), Packages (wrap-chips, red only when failed), Active streams (pulse dot + agent / user / started-ago), Providers, and — for aplatform.configholder only, mirrored by thecanReadUpdatesprop — the release-line card (UpdateNoticeCard: installed vs. latest version fromGET updates; a failed check is a fixed sentence and a time). Everything is visible at once — no inner scroll, no collapse. Do not re-introduce the old oversized hero cards or free-form prose blocks. The header also carries the Rescan / Clear-cache operation buttons. They are really wired — Rescan surfaces a live status line (Rescanned N packages — M failed to load, green/rose by outcome) from the route's{ scanned, packageErrors }instead of swallowing the result — but they carry no client-side gate:DashboardTab's only prop iscanSeeUserSpend, andpackages.manageappears nowhere inapp/. The server route is the gate, so a caller without it gets a 403 that the catch renders asRescan failed/Cache clear failed. Themanage-cache-and-rescanlauncher between the two buttons IS catalog-derived and does hide for that caller — a known asymmetry, not a permission read. - ProjectOverviewTab is stats-first: KPI chip row (Members, Roles,
Agents — counted from the project record), the project-scoped
UsageSignalCard, then the compacted Project Info form (collapsed by default; name + description on one grid row, meta as a single muted line) and the OrganizationHierarchyTree. Name and description are editable only for an owner-strength caller (isOwnerStrengthIn, the mirror of the host'sisOwnerStrengthOf—canManageRolesis the wrong mirror, a priority-2 admin holds it); everyone else sees them read-only and muted. Both fields key on the stored value, so a change from another client re-seeds them and a failed save reverts them.useProject(every project-scope tab) re-reads the record QUIETLY — no loading state — on aprojectrealtime frame naming the active project and once when the hub reconnects after a drop (the hub has no replay); itssaveresolvestrue/false. Clearing the description sends the empty string (descriptionPatch) —undefinedwould drop out of the JSON body and leave the stored text in place. UsageSignalCard(app/admin/components/UsageSignalCard.tsx) is the shared usage-chart card: range toggle (24h/7d/30d/All) + group-by toggle (SegmentedToggle), totals row, multi-line SVG chart (UsageChart) and a per-series legend. It fetchesGET dashboard/usagethroughuseUsageSeries(app/admin/hooks/useUsageSeries.ts) — series arrive pre-bucketed from the server, the client never re-buckets raw events.UsageChartis an admin-local generalization of agent-core'sUsageSignalChartSVG pattern (accepted duplication — admin must not import agent-core chat-UI internals). Group-by options are gated by the caller:AdminWidgetpassescanSeeUserSpend = hasFeature('platform.users')into both tabs; without it the Dashboard card falls back toscope=projectand neither card offers the User/Project dimensions (mirroring the server's deny-by-default gate so non-admins never hit a 403). Both cards print the server'spricingBasis.noteabove the chart and label the count metricUsage rows— one row is one MODEL's share of a turn, so the figure counts turn x model, not turns.- LimitsEditor wraps Project total / By role / By user / By agent in
four
SectionCards (Gauge / Shield / Users / Bot icons).By userrows render the hostUserAvataras a row prefix — and show only the rows the caller may see (D2: all rows withproject.limitsor thecanManageRolesfull view, otherwise just their own). The info banner carries aPriorityChain(User → Shield → Bot → Globe chips, with the total marked as an always-applied ceiling) instead of plain text. That banner also carries the server's pricing-basis sentence (fetched fail-soft byLimitsTabfromdashboard/pricing-basisand passed down aspricingNote; absent for a caller withoutproject.dashboard) plus one local clause drawing the consequence — a cap closes at or before the money is actually spent — and the note that raising a cap takes effect on the caller's NEXT message, the current window's spend being measured against the new figure at once (a turn already running keeps the limits it started with — the ACCEPTEDSPEND-CAP-FROZEN-FOR-THE-IN-FLIGHT-TURNresidual). Each rule is{ amountUsd, period }with a per-row day/week/ month select (UTC calendar windows, one period per rule). Spend is not the only dimension:LimitsTabrenders a fifth control below the editor — LLM rate limit (limits.rateLimitRpm, max PROVIDER CALLS per minute per user and provider, empty = unlimited) — and saves it on the same hostPATCH /api/projects/[id], read-only behind the samecanManageRolesFLAG. The unit is a provider CALL, not a stream start — one agentic turn makes one call per step, so a 47-step turn spends 47. It counted stream starts until halting-heron, which meant the number on this control described a fraction of the traffic it was supposed to cap (delegate and auxiliary calls passed no limiter at all). Over the limit the turn WAITS for the bucket, abortably and counted against its own timeout, rather than being killed — throwing away 46 steps of paid work to enforce a per-minute cap is not a limit, it is a loss. - AgentOwnershipEditor pulls the project's real agent list from
GET canvas/graph(viaAgentOwnershipTab) and renders one row per agent with the hostUserAvatarfor owner + assignee chips. The empty placeholder "No agent ownership records yet" was removed — when the project has no agents the editor shows a friendly Bot icon empty state. - Each graph-backed agent row also carries a delete affordance (trash
icon → inline two-step confirm naming the agent and warning that its
conversations, memory and identity go with it). It calls ADMIN's own
DELETE /agents-admin/:idroute (featurecore.agents— the same gate as the capability it wraps), which delegates to agent-core'sAgentService.deletethrough the ONEagentCoreApi.tsadapter; the service re-checkscanAccessAgent('delete'), so thecanManageRolesFLAG gating the button is affordance, not authority. The widget must NOT callusePackageApi('agent-core')— a sibling provider subtree is invisible to the context map and the hook throws, killing the tab (live-measured 2026-08-14). On success the tab REFETCHES the project and only then drops the row: the ownership record (and the agent's credential directory) is pruned SERVER-side by the host's agent lifecycle hook on that same delete, andAgentOwnershipEditormerges the record's keys back in as manual rows, so filtering the local list against a stale record would draw the deleted agent straight back. This tab used to prune the record itself with a client-side project patch; that write is GONE and must not come back — one caller's whole map replacing the record is the lost-update shape, and it only ever ran on the admin path, so an agent deleted from anywhere else kept its fossil. Manual (non-graph) rows keep their separate X, which only removes the ownership record.
Skill launchers (SkillLauncher)
Every admin surface the LLM can also drive carries a <SkillLauncher>
(@neuralis/package-system/client) — a translucent "+"/wand affordance
that PREFILLS the chat composer with the matching skill (no admin launcher pins a
script — the call lines live in each SKILL.md), exactly like a / slash pick. It never sends or streams; it is
a pure discoverability trace from the UI to the agent-facing skill. The
launchers resolve the WorkspaceHostPort from the
WorkspaceHostPortProvider mounted by installAdminHostComponents, so no
port prop is passed.
No launcher states a feature list. Visibility is DERIVED from the host's
per-caller skill catalog (port.useVisibleSkills()), so it can never drift from
the skill's own SKILL.md requiredFeatures and an unresolved catalog renders
nothing. The packageId is the REGISTRY id (@neuralis/admin), matched as an
exact pair with the skill id. The gate column below is therefore documentation of
what each SKILL declares, not something the UI repeats:
| Surface | Skill | Skill's own gate |
|---|---|---|
| ConfigTab section-tab toolbar | manage-platform-config | project.roles |
| ConfigTab → Settings group headers | manage-platform-config | project.roles |
| ConfigTab → Roles & Features intro | manage-platform-config | project.roles |
| ConfigTab → Source Configs section | manage-platform-config | project.roles |
| DashboardTab header | inspect-admin | project.audit, project.canvas, project.dashboard |
| DashboardTab operations strip | manage-cache-and-rescan | packages.manage |
| CredentialsTab header | manage-credentials | project.credentials, project.credentials.write |
| VectorSection → Current card | manage-platform-config | project.roles |
| LogsTab filter toolbar | inspect-admin | project.audit, project.canvas, project.dashboard |
| UsersTab header | manage-projects-and-users | project.dashboard, project.members |
| ProjectsTab header | manage-projects-and-users | project.dashboard, project.members |
| ProjectOverviewTab header | manage-projects-and-users | project.dashboard, project.members |
| AgentOwnershipTab header | inspect-admin | project.audit, project.canvas, project.dashboard |
| LimitsTab header | manage-projects-and-users | project.dashboard, project.members |
| RolesTab add-role row | manage-platform-config | project.roles |
| CanvasView top-left overlay | inspect-admin | project.audit, project.canvas, project.dashboard |
variant="icon" size="sm" sits flush in a card/section header or toolbar
(wrapped in inline-flex items-center gap-0.5); CredentialsTab keeps a
variant="label" pill in its actio
