Credentials
The encrypted credential store and its four scopes.
An AI workforce runs on secrets — provider keys, OAuth tokens, integration
credentials — and the fastest way to lose control of agents is to let those
ride in environment variables. In Neuralis, long-lived secrets — LLM provider
keys, OAuth tokens, MCP API keys, and any secret a skill or integration
needs — live in one place: the encrypted credential store. Values are encrypted at rest (AES-256-GCM) in the platform
zone under ~/.neuralis/app/credentials/, and the platform never reads
provider secrets from environment variables at runtime. .env is reserved
for boot-critical settings; see
configuration for the tier split.
Four scopes
The same credential id can hold a separate value at four scopes:
| Scope | Applies to | Typical use |
|---|---|---|
| Platform | the whole instance | The organization's default provider keys |
| User | one user, everywhere | A personal provider key or OAuth login |
| Project | one project, all its members | A team key billed to that project |
| Agent | one agent | A dedicated key for a single high-volume agent |
Resolution: most specific wins
When a value is needed — at stream time for a provider key, or at skill
activation for a declared secret — the store resolves
agent → project → user → global and returns the first value found.
Project is deliberately checked before user, so a project-level override
beats a member's personal key inside that project. There is no per-credential
exception: the ChatGPT/Codex OAuth login lives at the same four scopes and
resolves the same way. A personal login is a specific person's ChatGPT
session, so it is stored by its owner only — nobody can store one into
another member's user scope, not even a holder of the cross-scope feature —
and through the same member self-service gate as any key a member brings
(credentials.self, evaluated in the project the request comes from). An owner
who withholds that feature in a project withholds making this connection from
that project (a login already stored keeps resolving — user scope is global to
the member). The
project, agent and global scopes are shared platform accounts and behave like
every other credential.
One consequence is worth stating plainly, because it is a deliberate trade-off
rather than an oversight. A Codex subscription configured at global scope is
available to every project through that cascade, and it is not measured by the
spend or credential-use limits: Codex is subscription-billed, so its usage
records at $0, and the LLM provider credentials sit outside the credential-use
counter. What governs it is therefore the WRITE, not the read — storing anything
at global scope requires the cross-scope feature (platform.scope), which
carries no default grant to any role. Plan consumption is still visible, read-only,
through the Codex usage-limit signal.
The Codex credential also has an import path, for a self-hosted instance
running on a machine where the operator has already signed in with the Codex
CLI: the stored ~/.codex/auth.json session can be adopted directly instead of
repeating the browser flow. Because the value is read from the server's own
filesystem rather than supplied by the caller, that import is gated on the
platform-configuration feature (platform.config) — the same one that governs
every other operator-machine surface — and the caller must name the project the
feature is evaluated in. Every import is written to the audit log
(credential.import). The companion status endpoint reveals whether the
operator's CLI is signed in only to a caller who passes that same gate. No other
credential path reads a secret off the host machine.
A catalog row carries id, label, category, source
(package / skill / manual), the declaredBy list with a trust tier per
declarer, an oauth display flag, and hasValue for the requested scope — never
the value itself.
This ordering is what makes bring-your-own-key (BYOK) safe alongside central control: a member's personal user-scope key is used only when no agent- or project-scope value exists, so an owner who sets a project key is guaranteed it takes precedence over every member's personal key inside that project.
Values are encrypted at rest (AES-256-GCM) per scope, and the API never
returns a stored value in any direction — list and read responses carry
only { id, source, hasValue }. Writes and deletions invalidate the runtime
caches immediately, so a rotated key applies to the next request without a
restart.
A dynamic catalog
The set of credential ids is not fixed. The catalog the admin UI and the API expose is the union of:
- package-declared keys — every id a loaded package names in its
credentials[]manifest array (the LLM providers, web-tool and infrastructure keys are all declared this way — see Manifest), - skill-declared ids — every id any installed skill names in its
credentials:frontmatter, so a skill's requirements become configurable the moment the skill is installed, - manual ids — anything an owner adds by hand,
- per-connection channel secrets — one id per configured channel
connection (
<baseId>.<connectionId>). No manifest can name them, because the set does not exist until somebody connects a channel; the catalog gets them from the live connection list.
A custom LLM endpoint's key (llm.endpoint.<id>) is a fourth shape and behaves
like a manual id: it is set from the endpoint's own row in the admin
Custom endpoints section, and it appears in the catalog once a value exists,
not when the endpoint is created. Nothing declares it — the set of endpoints is
operator config, not a manifest.
Who can set what
Two management surfaces exist, deliberately separated by scope and authority. The boundary is the heart of the model: owners supervise all scopes; a member manages only their own.
The admin surface — all scopes, owner/admin
The admin widget's Credentials tab (with the owner/admin
manage-credentials skill) is the full set-and-supervise surface. It can
write and delete at any scope — global, project, user, or agent — using
a scope selector plus free-form custom ids. Two feature gates apply, checked
server-side:
- reading the catalog requires
project.credentials, - writing or deleting requires
project.credentials.write— a feature with no grant below the admin tier:adminholds it through its enumerated default grant andownerthrough the'*'wildcard, and it is grantable to any custom role.
This is the only surface that can seed the organization's shared keys (global defaults, a project's team key) or set a value into another user's or a specific agent's scope.
The member BYOK surface — your own user scope, self-service
A member does not need an admin to bring their own key. A holder of the
credentials.self capability (seeded to the admin, manager and member
roles, and held by owner through the wildcard)
gets a Credentials card in the chat config panel where they store, replace,
and remove their own credentials — both catalog-declared ids and arbitrary
custom ids — entirely within their own user scope. There is no scope
selector: every write lands in the caller's users/<id> scope by
construction, so a member can never touch another user's, a project's, or an
agent's value. Full walkthrough:
Bring your own keys.
The connect surfaces — reserved integration secrets
A handful of credential ids are reserved and managed on their own dedicated cards, never on the two surfaces above:
| Reserved family | Where it is set |
|---|---|
Channel secrets (telegram.*, whatsapp.*) | The Channels panel — channels |
Git remote tokens (git.<host>.*) | The Git remotes card |
Outbound MCP secrets (mcp.<server>.*) | The MCP servers card |
A custom LLM endpoint's key (llm.endpoint.<id>) is deliberately not in
that reserved set. Apply the test the set exists for — is a member's own value
consumed by anyone other than that member? — and the answer is no: the key is
resolved by the stream that is about to call the endpoint, against the caller's
own identity, so a member's key only ever binds their own runs. It is ordinary
BYOK, settable at any scope, and it appears on the endpoint's own row in the
admin Custom endpoints section as well as in the catalog. The one asymmetry
worth knowing: the model LIST is discovered once per endpoint for the whole
instance, so discovery uses the platform-global key and a member's key is used
for streaming only.
Each of these is a self-scope connect flow with its own capability
(channels.connect, git.connect, mcp.connect) and its own tightly
whitelisted set of ids. The member BYOK card excludes these reserved
families outright — trying to set one there is rejected — because they carry
extra per-integration validation (live token checks, per-connection derived
ids, OAuth callbacks) that a generic key field cannot provide.
Turning off member self-service per project
Member self-service is a capability like any other. An owner who does not want
members bringing their own keys in a given project simply removes
credentials.self from that project's roles (see
roles and features). This disables the
set surface — the Credentials group in the chat panel's Connections section
disappears and every write is refused in that project. It does not retroactively delete or stop resolving a key
a member already stored: user-scope values are global to the user, so a key
set while the capability was granted keeps resolving wherever that user's scope
applies. Revoke the value itself from the admin surface if it must go.
Every credential read, write, and deletion — on any surface — is recorded in the audit log, always by id and never with the value.
How skills receive credentials
A skill that needs an external secret declares it in its frontmatter:
---
name: publish-release
description: Publishes a release through the GitHub API.
credentials:
- GITHUB_TOKEN
---At activation, the declared ids are resolved through the scope chain above and projected into the skill script's child-process environment — and only ids that some active skill declared.
The projection is conversation-scoped, not per-command: once a skill is activated, its declared credentials stay in the environment of subsequent shell commands in that conversation, and what a command receives is the union of every skill still active — not only the most recently activated one. A skill is popped from that stack when it is explicitly deactivated, when it is re-activated, or when the conversation is deleted. So treat activation as granting the credential to the conversation, and deactivate a skill when its work is done.
The shell environment is otherwise sanitized. The script's environment starts
from the host process environment, and a deny-list removes what is
recognizably a secret before any command runs: variables with secret-shaped
names (the SECRET / TOKEN / PASSWORD / CREDENTIAL / API_KEY name
families, and more), known provider prefixes (OPENAI_*, AWS_*,
GITHUB_*, …), and an exact list of infrastructure keys.
Because it is a deny-list, treat it as defense in depth rather than a
guarantee: a secret held in a host variable whose name matches none of those
patterns — say MYKEY — is not stripped and remains visible to skill
scripts. Keep secrets in the credential store, where scope resolution and the
credentials: declaration govern who may read them; do not rely on the
sanitizer to hide an arbitrary host variable. See
skills for the full frontmatter contract.
Use limits — capping how often a credential is used
Every credential in the catalog can carry a use limit: at most N calls
per day, week or month (UTC calendar windows, the same periods as the spend
limits). The unit is deliberately calls, not dollars — external APIs do
not report per-call cost in any uniform way, so a call cap is the honest
control. Rules are platform-wide, set from the admin Credentials tab, and
match the exact credential id (a per-connection derived id like
telegram.botToken.<connection> is its own catalog row with its own rule).
Enforcement happens where the platform hands the secret out: every resolve of a credential is counted, and once a rule's window is exhausted further resolves are refused and audited. Embeddings get stricter treatment — each provider HTTP call is counted individually, a capped index/sync job stops with a resumable error, and semantic search falls back to full-text search with a warning rather than failing.
Honest edges, stated rather than hidden:
- LLM provider keys are excluded. Model spend is already governed by the per-project spend limits in dollars, which is the better unit there.
- Counting granularity follows the integration. A web search may resolve its key about twice per executed search; a polled messaging channel consumes its window continuously; secrets injected into skill scripts or MCP sidecars count once per hand-out, not per downstream use — the secret leaves the platform's sight at that point.
Host-API authentication is not a credential
Skill scripts calling back into the Neuralis API do not declare a
credential for it. Every stream mints a short-lived session ticket carrying
the real caller's identity, injected into the script environment as
NEURALIS_SESSION_TOKEN; routes verify it and enforce the caller's actual
role and feature grants. There is no platform bearer token or service
account to store, rotate, or leak — see
sessions and the
security model.