@admin

Features and routes

The two-tier project.* / platform.* feature namespace and the backend surface it gates.

Every admin route module exports a feature string, and the package route dispatcher checks it server-side against the caller's granted features before the handler runs — the general mechanism is described in Routes. The admin UI mirrors the same gates client-side to decide which tabs to show, but hidden tabs are a convenience, not a defence: the server-side check is always authoritative.

The two feature tiers

A project.* feature acts inside the caller's current project; a platform.* feature crosses the project boundary.

Project-tier featureGates
project.dashboardDashboard stats, usage charts, health report, loaded-package list, active streams, project list and detail (session-scoped — a member sees only the projects they belong to), and visibility of the Admin widget itself
project.membersThis project's member/role inventory — the Users tab roster (this project's members) and the exact-email lookup for adding an existing user
project.auditThe project-scoped log sources — a project-installed package's log (_installed/<slug>) and the per-agent run logs — plus the list of available log sources
project.rolesREAD this project's role map. The PATCH additionally requires the caller's role to carry the canManageRoles flag — governance is a flag, not a grantable feature
project.sourcesSource configs with live health, and their permission/sync patches
project.limitsThis project's spend-limit rules including OTHER members' per-user caps. Your own cap and the project/role/agent rules are always visible; writing limits stays behind the role's canManageRoles flag
project.credentialsCredential catalog — the read tier: ids, sources, and whether a value is set, never values (at the caller's own scope)
project.credentials.writeCredential mutation — the write tier: set or delete a value at the caller's OWN scope
project.canvasOrganization canvas graph and per-entity detail
project.members.inviteInviting a new user into THIS project (POST members-invite). The role's canInvite flag is ALSO required, and the assigned role may not be stronger than the caller's. Granted to admin by default
project.agents.assignAssigning/unassigning a project agent to a member (POST agents-assign). The role's canManageRoles governance flag is ALSO required — the same floor as the Agents tab's save. Granted to admin by default
packages.manageThe package-runtime mutation routes — cache clear and package rescan. Already namespace-free and project-scoped, so it keeps its bare id; the route gate is the coarse advertise tier, while the host re-derives the live canManagePackages grant on every call (deny-by-default).
core.agentsNot an admin feature — it belongs to @neuralis/agent-core and is granted to every role there. The admin agent-delete route (DELETE agents-admin/:id) reuses it deliberately, so the wrapper is never reachable below the capability it wraps; the real authority stays agent-core's own per-agent access check inside the service
Platform-tier featureGates
platform.projectsProjects you are NOT a member of: the cross-tenant list, scope=platform usage series, groupBy=project, the project census, and the host project-packages inventory
platform.usersPlatform-wide user records and cross-user spend: groupBy=user, the user census, canvas detail for a non-member, the full user roster, acting on a user who belongs to no project, and deleting a platform user (which also needs authority over that user in every project they belong to)
platform.scopeActing on a scope OUTSIDE the session — another project, member, agent, or the platform-wide credential scope — and only on principals inside the caller's own projects
platform.auditEvery platform-global log: the audit log (audit, logs?source=audit), the route log (logs?source=routes) and every first-party package's own file logs (logs?source=system, sync, vector, and package/pkg with a bare first-party slug) — none of them carries a project id
platform.configPlatform settings read/write, the custom-endpoint registry, the host CLI import
platform.vectorVector config, the embedding lock (the active model), and the vector-database probe
platform.vector.resetThe destructive, platform-global POST /vector/reset
platform.vector.maintainNot an admin feature — it belongs to @neuralis/brain-core, which gates its collection maintenance on it. The admin vector/collection route re-checks the same feature in-handler, so the embedding-model rebuild needs exactly what brain-core's own route needs; platform.vector.maintain carries no default role grant
platform.projects.createReaching POST projects-create — creating a project mints a tenant. Reachability only: the real floor stays owner-strength (priority 1 in an existing project) inside the host service, so the feature alone is never sufficient

Default role grants hand project.dashboard to every role, so all project members can open the system overview. The admin role receives every project-tier feature admin declares; the owner role holds the '*' wildcard and passes every gate. No platform.* feature is granted to any role by default — so a project admin is confined to their own tenants until an owner grants otherwise. See Features and access for how ungranted features behave.

What an administrator can query

All routes are served under the package's API prefix (/api/packages/@neuralis/admin/<pattern>).

SurfaceMethodFeatureReturns
healthGETproject.dashboardHealth report: vector store probe, disk, memory, configured LLM providers
dashboard/statsGETproject.dashboardKPI aggregate: users, projects, agents, packages, connectors, MCP servers, streams, today's usage
dashboard/usageGETproject.dashboardTime-bucketed usage series (see below)
dashboard/codex-savingsGETproject.dashboardCodex subscription savings + break-even per connected subscription (scope=platform requires platform.projects + platform.users)
dashboard/pricing-basisGETproject.dashboardWhat every USD figure means: the one server-authored pricing-basis sentence
packagesGETproject.dashboardLoaded package status
streamsGETproject.dashboardActive agent streams
projects, projects/:idGETproject.dashboardProject list and per-project detail (members, roles, limits) — session-scoped: a member sees only the projects they belong to, and :id is 404 for a project they do not belong to unless they hold platform.projects. The detail payload is then reduced field by field, each slice keyed on its own feature: other members' emails need project.members, other roles' grant lists need project.roles, and other members' per-user spend caps need project.limits — a missing feature drops the field, never a 403. A member whose role carries canManageRoles gets the full view (write-implies-read: the limits editor round-trips this payload). ownerId travels as provenance only, never as authority; the list adds a server-derived isOwnerStrength / canCreateProject so the archive and create affordances appear exactly where the write would succeed
usersGETproject.membersA feature-gated discovery descriptor — { href: "/api/admin/users", mutable: false } — not a user list. The package route carries no user data and no mutation surface; both user inventory and user lifecycle live on the host's admin API
auditGETplatform.auditRecent platform-global audit events (the audit log carries no project id, so the MODULE feature is platform-tier — there is no second in-handler gate)
logs, logs/sourcesGETproject.auditLog reader. The project-scoped sources — a project-installed package's _installed/<slug> log and the per-agent run log — stay on project.audit. Every platform-rooted source additionally requires platform.audit and is omitted from logs/sources without it: source=audit, source=routes (the host's cross-tenant route-dispatch log), and every first-party package's own file logs (system, sync, vector, package/pkg with a bare first-party slug — they live in the platform zone, every tenant's events in one file); another member's source=run requires platform.scope. Log paths are shaped for display, never returned as absolute host paths
config/rolesGETproject.rolesTHIS project's role map (session-scoped — never a cross-project merge)
configGETplatform.configPlatform settings (env-derived read-only + file-backed editable)
endpoints/llm, endpoints/embeddingGETplatform.configThe operator-declared custom LLM or embedding endpoint list (never a key — an endpoint's key is an ordinary credential)
sourcesGETproject.sourcesSource configs with live health (on-disk counts, index state, sync status) — 400 if the session has no project (no all-projects fanout)
vector/statusGETplatform.vectorThe target model, the embedding lock (the ACTIVE model) with the active model's credential id, the serving collection (read from the lock), vector-database probe, embedding-host probe
vector/collectionGETplatform.vector + platform.vector.maintainbrain-core's collection report: active model, target, point counts, a running rebuild's progress, the last estimate and today's embedding spend
credentialsGETproject.credentialsScope-aware credential catalog with hasValue flags (caller's own scope unless platform.scope)
credentials/scope-targetsGETplatform.scopeIds and display names of the users and agents the credential scope picker may target — deliberately the same feature the cross-scope guard requires, so a caller who cannot act on another scope is never handed the list. Ids and names only; 400 without a session project, 404 when the project record is missing
credentials/useGETplatform.scopeCredential-use rules and their usage overview: the per-credential call ceilings, each rule's consumption inside its current window, the trailing 30-day call totals, and the ids excluded from this mechanism (LLM provider keys, which are governed by the USD spend limits instead). The rules are platform-global — one rule binds every project — so the read re-gates on platform.scope in-handler rather than the project-tier module feature, and answers 503 on a host that does not provide the port
canvas/graph, canvas/agent/:id, canvas/user/:idGETproject.canvasOrganization graph and per-entity detail; user/:id returns detail only for members of the session project unless the caller holds platform.users, and its lastActivity field additionally needs platform.audit (it is read from the cross-tenant platform audit log) — without that feature the field is omitted, never a 403

Dashboard stats degrade gracefully by privilege: platform-wide user and project counts are populated only when the caller holds platform.users and platform.projects respectively; without them the same endpoint still serves the project-scoped KPIs (the counters read 0, never a 403).

What an administrator can mutate

SurfaceMethodFeatureEffect
configPATCHplatform.configUpdate a single runtime setting, persisted to the platform config store. embeddingModelId must name a catalog or endpoint model (400 unknown_embedding_model), and the two endpoint lists are refused here (400 endpoint_list_key) — PUT endpoints/<family> is their only writer
config/embedding-targetPATCHplatform.config{modelId, dimension} — the embedding TARGET as one write: both are validated first (400 unknown_embedding_model / unsupported_dimension, nothing written), then both are stored. It starts nothing: the active model keeps serving until a rebuild
endpoints/llm, endpoints/embeddingPUTplatform.config, a signed-in person onlyReplace that family's WHOLE endpoint list. Every entry and every public address is validated first; an absent list is refused while an explicit [] clears it; removing, disabling or renaming the endpoint of the active, target or rebuild-target embedding model answers 409 endpoint_in_use with nothing written
endpoints/probePOSTplatform.configAsk whether a custom LLM endpoint answers and with which models — a saved one ({endpointId}) or an address before saving ({baseUrl, kind, network?}); never carries a key
config/rolesPATCHproject.roles + the canManageRoles flagUpdate role definitions (feature grants, priority) for the session project (a body project id may only agree with it → 403 project_mismatch). Reaching the route needs the feature; REWRITING the map needs the caller's role to carry the canManageRoles flag — there is no feature arm, so a read-tier holder cannot rewrite the map. Two rules then decide the write, applied by the same shared body the host project PATCH uses: the caller may edit only roles strictly weaker than their own priority (their own role included — that is what makes self-lockout impossible; defining a NEW peer-strength role is the one allowed equality), and may grant only, and revoke only, features they hold themselves. Built-in priorities stay immutable and every priority must be an integer in 1..99. The body's roles REPLACES the whole map (read it first, send the complete map back), so a role left OUT is a deletion and is authorized as one — omitting a role at or above your strength, or one holding a feature you lack, is 403. The write itself happens in the host, on the record as it is on disk at that moment, so the host store's own structural invariant runs on the same write: it must keep roles.owner, keep the project's creator a member on the owner role, and leave no member pointing at a removed role — otherwise 400 and nothing is written
sourcesPATCHproject.sourcesUpdate a source's permissions, sync settings, or description — routed through brain-core's validated mutation point, never raw file writes. permissions is REPLACED wholesale (read the current object and send it back edited; a partial body answers 200 and drops every path rule it omits — immutable floor:true rules are the one protected class), while sync is merged key-by-key
vector/resetPOSTplatform.vector.resetDrop vector collections and the embedding lock — brain:// content and its version history go with them; requires confirm: "RESET" in the body. Platform-global (drops every project's collections by env prefix) — gated by its own feature, not by the platform.vector read tier
vector/collectionPOSTplatform.vector + platform.vector.maintainOne embedding-model rebuild action through brain-core: rebuild-estimate (a sample-based estimate, changes nothing), rebuild-generation (202, starts or resumes the rebuild into a new collection while the active one keeps serving) or rebuild-cancel (drops the unfinished collection, the active one untouched). Any other action is 400; a conflicting state (a rebuild already running, nothing to switch to) is 409
cache/clearPOSTpackages.manageClear the session project's package caches — the lazy counterpart to rescan: drops the activation flag, scanner record caches, and runtime snapshot so the next access re-reads disk (no eager reload). Returns { ok, message }
rescanPOSTpackages.manageRe-scan the session project's _packages/ drop-zone and re-sync the runtime. Returns { ok, scanned, packageErrors? } — a package that fails to load surfaces in packageErrors[] rather than vanishing silently. Both routes derive the project from the verified session (no caller-supplied id) and re-derive the live canManagePackages grant per call → 403 when denied
credentials/:idPUT / DELETEproject.credentials.writeSet or delete a credential value at the caller's OWN scope (project/user/agent); writing to the platform-wide scope or another project/user/agent additionally requires platform.scope, and the target must be inside one of the caller's own projects. Both methods are capped at 10 mutations per minute per caller (429 beyond it) and audit the id and scope label — never the value
credentials/use/:idPATCHplatform.scope and project.credentials.writeSet or clear the use rule (call ceiling per period) for one credential id. The rule is platform-global, so BOTH ids are required in-handler — the cross-scope key alone does not authorize a write, and the write tier alone does not authorize a platform-global one. An LLM provider key is refused with 400 (those are governed by the USD spend limits), an unknown host port answers 503, and every accepted change is audited
members-invitePOSTproject.members.invite + the role's canInvite flagInvite a new user into the session project ({email, name, role?} — no identity fields, no password in either direction: the response says passwordDelivery: "admin-ui-reset" and an operator sets the first password via the Users tab reset, delivered out-of-band). The assigned role may not be stronger than the caller's; an unknown role and a too-strong role answer the same generic 403
projects-createPOSTplatform.projects.create + the owner-strength floorCreate a new project (tenant). The feature is reachability only — the host service re-derives owner-strength (priority 1 in an existing project) before the instance capacity cap, so an unauthorized caller never learns the project count
agents-assignPOSTproject.agents.assign + the role's canManageRoles flagAssign or unassign a project agent to a member user. Unknown agent and non-member target answer the same 403 (no existence oracle). A first-time assignment also narrows what other agents:'own' users can read (record-present-no-match denies read), so assigning is also restricting
agents-admin/:idDELETEcore.agents (agent-core's feature) + agent-core's own per-agent access checkDelete a project agent from the Admin widget's Agents tab. A thin transport wrapper: the route carries the call and writes the audit row, while the authority stays inside agent-core's agent service, which re-checks delete access against the caller's real session. It is gated on the SAME feature as agent-core's own agents route, so the wrapper can never be reachable below the capability it wraps; a denied delete and a missing agent answer the same 403

The usage endpoint

GET dashboard/usage returns a time-bucketed usage series, aggregated server-side so the response stays small — the client never re-buckets raw events.

Query parameters:

  • range — 24h (60-minute buckets), 7d (6-hour buckets), 30d (daily buckets), or all (all-time — buckets are sized dynamically from the earliest recorded event so the chart spans the project's full history). Default 24h.
  • groupBy — model (default), agent, user, or project.
  • scope — project (default) or platform. scope=project always aggregates the session's project; a caller cannot substitute another project id. groupBy=project is only valid with scope=platform.

Invalid values return 400. The response carries buckets, bucketMinutes, groupBy, per-series data, totals, a pricingBasis, and — for user or project grouping — a labels map resolving ids to display names.

Privilege escalation inside the endpoint

The module gate is project.dashboard, which every role holds by default — but scope=platform (cross-tenant series) additionally requires platform.projects and groupBy=user (per-user spend) requires platform.users, returning 403 without them. Per-user spend and cross-project visibility are user-data surfaces that the baseline dashboard grant must not expose.

The usage series also carries, per model and in the totals, the Codex subscription savings — the equivalent OpenAI API cost of subscription-billed (Codex) usage, which otherwise records as $0. The chart legend renders a Codex model's savings in an accent colour instead of its $0 spend.

Each usage record is one model's share of a turn, not the whole turn. A turn that delegates work to a subagent on a different model produces one record per model, each priced from that model's own list, so the event count reflects turn × model. The split does not multiply what a turn contributes to a spend limit — that is still exactly one increment, carrying the summed cost. The sum itself is higher than before wherever a subagent ran a paid model, because that work is now priced on its own model rather than the parent's.

GET dashboard/codex-savings

Codex (ChatGPT) usage is subscription-billed — $0 per token — so the "savings" is the equivalent OpenAI API cost of the same tokens. It counts Codex tokens only: work a Codex-model agent delegates to a subagent on a paid model is billed and reported under that model, not folded into the subscription's savings. This applies to usage recorded from this release onward — earlier records keep the attribution they were written with, and are not recomputed. This endpoint reports it per connected subscription (the platform's credential scopes are per project / per user), each with its plan tier, the plan fee pro-rated day-precise onto the selected window, and a "has it paid off" percentage.

  • range — same as dashboard/usage.
  • scope — project (default, this project's own subscription) or platform (every connected subscription; requires BOTH platform.projects and platform.users — it aggregates across every tenant AND labels per-user rows).

On open, the endpoint makes a single fail-soft live refresh of the requesting admin's own Codex scope (it never impersonates another user's or project's credential; a failed refresh falls back to the last-known reading rather than erroring). Each row is flagged live only when it is that own scope and its snapshot matches the refresh exactly; every other row is cached last-known data. There is no background polling.

The response carries each subscription's windows[] — one entry per reported usage window ({ slot, usedPercent, windowMinutes, resetAt }, sorted shortest-first) — plus its freshness, receivedAt, and source. The response also carries the same pricingBasis sentence as the usage endpoint, once at the top level rather than repeated on every subscription. The dashboard card labels each reset-countdown ring from the window's real duration (e.g. 5h, 7d), not a fixed short/weekly assumption.

What the money figures mean

Every token-priced figure is an estimate priced from the model catalog's list rates — never a negotiated or promotional rate. For any model the catalog prices, that makes the figure conservative: it can overstate what you are charged, never understate it. A model the catalog does not price records $0 — correctly, when a subscription plan pays for it or it runs on your own hardware; as an understatement, when the catalog simply carries no rate for the id, and that second case is logged by the chat and delegate meters rather than left silent (the auxiliary title, polish and summary calls still record it silently).

The sentence also states what a STOPPED step books: the numbers the provider reported, or the last measured request size as its input, and — on providers that report nothing until an answer finishes — no output at all. That is a named gap rather than a silent one; the usage rows carry how many of their steps were stopped and how many could not book an output.

Plan fees are not token-priced and are not covered by that sentence: the Codex savings card's "vs. plan" figure is an estimate derived from the reported plan tier, pro-rated onto the selected window. Four surfaces show the sentence — the chat config Usage Signal card, the admin usage and Codex savings cards, and the Limits editor. Other places that show money, such as the conversation history's per-conversation spend, do not.

GET dashboard/pricing-basis serves that sentence on its own, so a surface with no usage payload — the Limits editor — can show it too. It needs the module's project.dashboard and nothing more: the value is a fixed string with no tenant, user or credential data in it. The Limits tab reads it fail-soft, so a role that can edit limits without holding the dashboard feature sees the paragraph without the sentence rather than an error.

Cross-package data

Data the routes need from other packages — active streams, connector status, MCP server lists, usage aggregates, source health — is fetched through typed package APIs (getPackageApi()), never through internal MCP calls. The admin package reads from @neuralis/agent-core and @neuralis/brain-core through narrow typed adapters, and source mutations go through brain-core's single validated mutation point for source configuration.

On this page