Enterprise

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:

ScopeApplies toTypical use
Platformthe whole instanceThe organization's default provider keys
Userone user, everywhereA personal provider key or OAuth login
Projectone project, all its membersA team key billed to that project
Agentone agentA 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: admin holds it through its enumerated default grant and owner through 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 familyWhere 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.

On this page