npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 own users.ts / projects.ts are 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 explicit ENV_INFO allowlist (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 the manage-credentials skill (requiredFeatures: [project.credentials, project.credentials.write] — no grant below the admin tier). The catalog is 0-hardcode: buildCatalog unions, in order, package-declared ids (each loaded package's credentials[] manifest array, collected via the kernel's collectDeclaredCredentials() over ctx.hostPorts.packageDiagnostics.listDefinitions()), skill-declared ids (credentials: frontmatter), then manual stored-but-undeclared ids — a collision only annotates declaredBy, never overwrites. The former BUILTIN_CREDENTIALS / builtinCatalog.ts hardcode is DELETED. There is now exactly ONE credential surface — the package credentials/* routes — which fetch package creds and pass them to buildCatalog. The parallel host route /api/admin/credentials was deleted (CRED-TAIL INC 4): it enforced neither the project.credentials(.write) features nor assertScopeMatchesSession, and the Credentials tab drove it with a raw fetch. The tab now uses usePackageApi(). A row's oauth display hint (carried from the declaring PackageCredential) drives the Connect/ Disconnect chrome (codex today). Every OAuth-class row — the Codex login and each outbound MCP server's OAuth grant — also carries a status (valid · expiring · expired · refresh_failed · unknown, with expiresAt / lastRefreshAt / note) that agent-core computes for the requested scope through the adapter's describeCredentialStatus; the tab renders it as one chip on both row shapes (credentialStatusPresentation.ts). expiring means inside agent-core's credentialExpiryWarningHours (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 answered mode:'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 on BroadcastChannel('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 (for platform.vector.maintain holders), today's embedding spend against its daily cap, the operator embedding endpoints, and the destructive reset — which also deletes all brain:// content and its version history, because they live in the vector store. The status probe also reports qdrant.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 INSIDE session.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 the global credential 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:

  1. 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.
  2. 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 → RoleCard shows a PriorityBadge and 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 → HierarchyTree groups members by their role priority; MemberEditModal shows the derived priority read-only (no more hand-edited tier); UsersTab shows 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-computed can — 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); ProjectsTab shows it in the project browser.
  • ProjectsTab is a management surface, not a read-only browser. It lists active + archived projects (the projects/* route now includes archived + archivedAt) and, for a project the caller holds OWNER STRENGTH in (D-B: the server derives isOwnerStrength per row and canCreateProject across all rows over isOwnerStrengthOf, and sends both — the list payload carries no member/role map for the client to judge from, and ownerId rides 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 via MemberEditModal → the host PATCH /api/projects/[id] which re-enforces canManageRoles + 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 via app/admin/api/projects.ts; the admin projects/* 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 workspace project realtime 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, default 24h; all = all-time — the route probes UsageStore.projectSpan for the earliest event across the in-scope projects and picks a dynamic bucketMinutes targeting ~48 buckets, staying under the aggregate 1000-bucket cap; an empty project falls back to a 24h window), groupBy = model | agent | user | project (default model), scope = project | platform (default project). Invalid values → 400.
  • scope=project aggregates exactly the session's projectId (never a caller-supplied id); scope=platform enumerates all project files.
  • groupBy=project is only valid with scope=platform.
  • Response: { usage: { buckets, bucketMinutes, groupBy, series[], totals, scope, range, labels?, pricingBasis } } — labels resolves user/project ids to display names for the legend; agent/model keys are returned raw. pricingBasis is agent-core's own disclosure, forwarded verbatim (see GET dashboard/pricing-basis).
  • Gates: module project.dashboard; scope=platform additionally requires platform.projects and groupBy=user requires platform.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 } }. 503 when the agent-core adapter is unavailable; an unknown dashboard/* sub-path is still 404.
  • Gate: the module's project.dashboard only — 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.limits without project.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 (default project). Invalid values → 400.
  • scope=project shows only THIS project's own subscription; scope=platform shows every connected subscription and requires BOTH platform.projects and platform.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.equivalentCostUsd over that scope's usage; the plan's monthly fee (estimated from the OpenAI plan_type, agent-core resolveCodexMonthlyUsd) 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 agentCoreApi adapter's refreshCodexUsage(session) (never impersonates another user/project; a refresh failure downgrades to cached computation, never a 500). A row is freshness:'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-local codexWindowPresentation.ts derives each ring label from windowMinutes (never a fixed 5h/weekly). scopeIdentity is an internal comparison field and is NOT in the public row. The payload also carries pricingBasis — 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 subscriptions CodexSavingsCard early-returns null and 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-side neuralis/src/workspace/shell/ProjectSettingsPanel.tsx was 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. Only dashboard is visible to everyone; the other PLATFORM tabs are feature-gated and appear only when the caller's grantedFeatures include 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 when project.members is granted.
  • roles — visible only when project.roles is granted.
  • agents / limits — always visible; the editor renders in read-only mode unless the caller's role carries the canManageRoles FLAG. Both write through the host PATCH /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 projects requires platform.projects; logs requires project.audit; config requires project.roles (its weakest section); credentials requires project.credentials; canvas requires project.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-config ClientConfigPanel so 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 by GET dashboard/codex-savings, rendered only when a Codex subscription has produced usage), then a 2-column grid of compacted SectionCards: 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 a platform.config holder only, mirrored by the canReadUpdates prop — the release-line card (UpdateNoticeCard: installed vs. latest version from GET 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 is canSeeUserSpend, and packages.manage appears nowhere in app/. The server route is the gate, so a caller without it gets a 403 that the catch renders as Rescan failed / Cache clear failed. The manage-cache-and-rescan launcher 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 Organization HierarchyTree. Name and description are editable only for an owner-strength caller (isOwnerStrengthIn, the mirror of the host's isOwnerStrengthOf — canManageRoles is 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 a project realtime frame naming the active project and once when the hub reconnects after a drop (the hub has no replay); its save resolves true/false. Clearing the description sends the empty string (descriptionPatch) — undefined would 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 fetches GET dashboard/usage through useUsageSeries (app/admin/hooks/useUsageSeries.ts) — series arrive pre-bucketed from the server, the client never re-buckets raw events. UsageChart is an admin-local generalization of agent-core's UsageSignalChart SVG pattern (accepted duplication — admin must not import agent-core chat-UI internals). Group-by options are gated by the caller: AdminWidget passes canSeeUserSpend = hasFeature('platform.users') into both tabs; without it the Dashboard card falls back to scope=project and 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's pricingBasis.note above the chart and label the count metric Usage 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 user rows render the host UserAvatar as a row prefix — and show only the rows the caller may see (D2: all rows with project.limits or the canManageRoles full view, otherwise just their own). The info banner carries a PriorityChain (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 by LimitsTab from dashboard/pricing-basis and passed down as pricingNote; absent for a caller without project.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 ACCEPTED SPEND-CAP-FROZEN-FOR-THE-IN-FLIGHT-TURN residual). 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: LimitsTab renders 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 host PATCH /api/projects/[id], read-only behind the same canManageRoles FLAG. 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 (via AgentOwnershipTab) and renders one row per agent with the host UserAvatar for 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/:id route (feature core.agents — the same gate as the capability it wraps), which delegates to agent-core's AgentService.delete through the ONE agentCoreApi.ts adapter; the service re-checks canAccessAgent('delete'), so the canManageRoles FLAG gating the button is affordance, not authority. The widget must NOT call usePackageApi('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, and AgentOwnershipEditor merges 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