Skip to content

Secrets

Every secret NimbleBrain holds lives in one store, and config points at it rather than carrying it. That gives you three things a literal in a JSON file cannot: a secret you can rotate without editing config, a secret that differs per workspace while the config that names it is shared, and a log line every time one is used.

Anywhere a secret is expected, you may write a credential reference instead of the secret itself:

{ "ref": "credential", "key": "acme.db_url" }

The key is a name in the store — dotted namespace, letters, digits, ., -, _. It says which secret, and nothing about where the value lives. That is the whole point: the storage backend can change — plaintext files, the same files sealed with AES-256-GCM, a managed vault later — without touching a single config file.

The literal form still works everywhere. A reference is the option, not the requirement.

A key is only meaningful inside a scope, and the scope is the secret’s owner.

Scope Who owns it Where it is referenced from Who can write it
instance the deployment nimblebrain.json, instance.json whoever can edit those files, or reach the disk
workspace one workspace that workspace’s workspace.json, and its installed connectors a workspace admin, via manage_connectors
user one identity a personal connector’s OAuth records

The same key in two scopes is two independent secrets. That is what lets one catalog entry install into two workspaces and have each present its own credential — see Per-workspace credentials below.

Instance-scope references resolve once, at startup, on these fields:

Field Env fallback
providers.<provider>.apiKey the provider SDK’s own variable — see Environment Variables
connectors.providers.composio.apiKey COMPOSIO_API_KEY
connectors.providers.smithery.apiKey SMITHERY_API_KEY
connectors.gateways.<name>.apiKey <NAME>_API_KEY (upper-cased, non-alphanumerics to _)
auth.apiKey (in instance.json) WORKOS_API_KEY, which wins over the declared value
nimblebrain.json
{
"providers": {
"anthropic": { "apiKey": { "ref": "credential", "key": "anthropic.api_key" } }
},
"connectors": {
"gateways": {
"acme": { "apiKey": { "ref": "credential", "key": "acme.gateway_key" } }
}
}
}

References change nothing about resolution order — a reference resolves to a value and then takes exactly the place a literal would. For every field but the WorkOS key that is: declared value first, environment variable as the fallback. (WORKOS_API_KEY is the outlier and takes precedence over what instance.json declares, reference or literal.) The env fallback remains a perfectly good posture — a mounted Kubernetes secret is not worse than this store, and for many deployments it is simpler.

Set an instance-scope key by writing the file directly (see Where the files live). There is no tool for it: these are the deployment’s own credentials, not a tenant’s.

Workspace-scope references resolve on every request, which is what makes rotation a one-line operation. They are accepted in a connector’s transport config:

workspace.json
{
"connectors": [
{
"url": "https://mcp.example.com/mcp",
"serverName": "example",
"transport": {
"auth": { "type": "bearer", "token": { "ref": "credential", "key": "acme.api_key" } },
"headers": { "x-account": { "ref": "credential", "key": "acme.account_id" } }
}
}
]
}

…and on the client_secret of an operator-registered OAuth app, which is where the reference shape started.

A connector that declares secretHeaders asks for its values in the install dialog, before it is added. Browse marks the entry “Needs a credential”, Install opens a form, and the connector is added only once every value is stored — so its first connection already resolves. The same form is on the connector’s Configure page under Settings → Connectors, which is where you replace a value later.

Use the UI. The tool actions below exist for scripting and for keys no connector declares; typing a credential into a conversation puts it in that conversation’s transcript, in the model’s context, and in whatever the conversation is persisted to. The dialog sends the value to the store and nowhere else.

manage_connectors set_secret { key: "acme.db_url", value: "postgres://…" }
manage_connectors list_secret_keys {}
manage_connectors delete_secret { key: "acme.db_url" }

Every action is workspace-admin gated. list_secret_keys returns keys and last-write times. No action returns a value — knowing a key is set is what you need in order to know a reference will resolve, and returning the secret would put every workspace’s credentials one tool call away from a conversation transcript. Configure shows which keys are set and when, for the same reason and from the same source.

Set the same key again — from Configure, or with set_secret. That is all of it.

The next request resolves the new value — on a live connection as on a new one. No config edit, no restart, no reconnect, no reinstall. That matters most when the old value leaked: from the moment the put lands, nothing the connector sends carries it.

A connector that was installed before its key was set is not broken permanently either: set the key and the source re-establishes on next use. There is nothing to reinstall.

Deleting a key a connector still references makes that connector’s next request fail, naming the key and the scope. That is deliberate — a blank credential is a vendor 401 a hop away from its cause.

A connector owns the workspace keys it names, so removing the connector removes them. Both declaration sites count: secretHeaders, and — for the built-in credential provider — providerAuth.config.key. The uninstall dialog names each key it will delete and when it was written.

A key another installed connector still names is kept. Nothing stops two entries pointing at one key (the two examples below both name acme.db_url), and deleting it on the first uninstall would break the survivor on its next request. The result says which keys were kept and why; the last connector to go takes the key with it.

There is no keep-them option, and nothing for one to preserve. A connector that declares secretHeaders cannot be installed without supplying every value, so reinstalling asks again and overwrites — holding a key back would keep a value the next install replaces. What it would leave behind is worse than it looks: the rotation surface renders only for an installed connector, so a key left over is unreachable from the UI entirely, a live outbound credential with nothing referencing it and nothing admitting it exists.

The keys come from the connector’s own persisted config, read server-side. The uninstall names none.

manage_connectors uninstall { serverName: "com-acme-db" }

The connector goes first and its keys after, so a refused uninstall removes nothing. If a key cannot be removed after the connector is gone, the result says so and names it — the connector is gone and the key is not, which is a state to act on rather than an uninstall to retry.

Per-workspace credentials for a shared connector

Section titled “Per-workspace credentials for a shared connector”

The reason the scopes exist. A catalog entry can declare that it authenticates with the workspace’s secret, using the built-in credential credential provider:

a catalog entry
{
"id": "com.acme/db",
"auth": "provider",
"providerAuth": { "provider": "credential", "config": { "key": "acme.db_url" } }
}

Install it into two workspaces and each connection presents its own workspace’s acme.db_url — no vendor code, no second catalog entry, and nothing secret in either workspace.json. config.header names a header other than Authorization when a vendor wants one (the default sends Authorization: Bearer <secret>; a named header carries the secret verbatim).

When the connection also has to say who is calling

Section titled “When the connection also has to say who is calling”

The shape above works when the workspace’s secret is the whole credential — the server on the other end accepts it and asks nothing else. A platform service is different: it verifies a short-lived token that identifies the calling workspace and needs the customer-owned secret the tool will use. Those are two different questions — who is calling, and what that caller may open — so the entry answers both rather than choosing:

a catalog entry
{
"id": "com.acme/db-query",
"auth": "provider",
"providerAuth": { "provider": "minted", "config": { "audience": "", "scope": "" } },
"secretHeaders": { "X-Db-Url": { "ref": "credential", "key": "acme.db_url" } }
}

providerAuth produces the connection’s own credential; every entry in secretHeaders becomes one more outgoing header, resolved on every request at that connection’s workspace scope. Both ride the same request.

Only references are accepted in secretHeaders — never a literal. A catalog file is configuration under version control, and a secret written there is the copy this store exists to remove; an entry carrying one is refused at install, naming the header.

A reference may also carry label and help, which is what the install dialog asks the user. Without them the label is derived from the key’s last segment (acme.db_url → “Database URL”), which is usable but knows nothing your entry does:

"secretHeaders": {
"X-Db-Url": {
"ref": "credential",
"key": "acme.db_url",
"label": "Warehouse connection string",
"help": "Read-only role, from Settings → Connections in your warehouse."
}
}

Both are display-only. Neither reaches the transport, the store, or the persisted connection config.

Every secret the platform presents emits one audit.credential_read event to the workspace log:

{"ts":"2026-03-25T14:30:04.000Z","event":"audit.credential_read","scope":"workspace:ws_a1b2c3d4e5f60718","key":"acme.db_url","caller":"transport:provider:credential","purpose":"authenticate remote MCP request as workspace ws_a1b2c3d4e5f60718","workspaceId":"ws_a1b2c3d4e5f60718"}

The value is never in the line. Nor is it in a log message, a stack trace, or a JSON.stringify anywhere else — a secret is held in a wrapper that renders as [redacted] on every stringification path, and the one method that returns the real value is the same method that writes this line.

Two properties worth knowing:

  • A presence check is not a read. The connectors page probes whether an OAuth client_secret is configured for every entry it renders, and connection state is derived by probing for a stored token record. Those probes write nothing; only actually using a secret does.
  • One line per read, not per request. A connection that presents the same secret on a thousand requests resolves it a thousand times and logs a thousand lines — one per resolution — but a single resolution reused across a batch logs once.
{workDir}/credentials/secrets/<key> instance
{workDir}/workspaces/<wsId>/credentials/secrets/<key> workspace
{workDir}/users/<userId>/credentials/secrets/<key> user

One file per key, mode 0600, under a 0700 directory, written atomically. The file’s contents are the secret, with one trailing newline trimmed on read — so seeding an instance key by hand is:

Terminal window
mkdir -p ~/.nimblebrain/credentials/secrets
printf '%s' 'sk-…' > ~/.nimblebrain/credentials/secrets/anthropic.api_key
chmod 600 ~/.nimblebrain/credentials/secrets/anthropic.api_key

Which store holds those values is configuration. secrets.backend names it, and secrets.config is that backend’s own settings:

{
"secrets": {
"backend": "file",
"config": { "seal": { "keyEnv": "NB_CREDENTIAL_KEY" } }
}
}
Configuration What is in the file Who this is for
block omitted, or {"backend":"file"} the secret, verbatim one trusted host — a laptop, a container whose disk you own
"config": {"seal": …} NBS1.… — AES-256-GCM anything whose disk, snapshots or backups outlive the process

secrets.config names where key material lives; it never contains any. keyEnv is the name of an environment variable. Configuration is frequently rendered from a file in version control, so a key written here is a key committed to a repository — the runtime rejects the config rather than let that happen.

Omitting the block is the default and changes nothing. Whichever backend is selected, secrets set writes through it — so the same command seeds a plaintext deployment and a sealed one.

With seal configured, new writes are ciphertext. The variable named by keyEnv holds one or more base64 keys of at least 32 bytes, comma-separated. The first seals; every one opens — that overlap is the rotation seam: add a new key at the front, restart, and the outgoing key still opens everything sealed under it.

Turning sealing on for the first time re-seals the plaintext files already there, in place, on the next boot — so nothing is stranded and nothing has to be re-entered. The same sweep is how a key rotation completes: put the new key at the front of the ring, restart, and everything is re-wrapped on the way up while the outgoing key still opens whatever the sweep has not reached. Drop the old entry on the next rotation. A secret it cannot open is logged and left alone rather than failing the boot.

After a sweep that walked everything, the store stops accepting plaintext files altogether. Once every secret is sealed, a plaintext file appearing in the directory is something nobody legitimate put there — an operator editing by hand, or an injection by whoever can write the volume. If the sweep could not read a directory, or could not open one of the secrets it found, it says so in the boot log and keeps accepting plaintext: it has not seen enough to call a plaintext file an intruder.

For a workspace secret, set it on the connector’s page under Settings → Connectors, which writes through the store and seals it immediately.

For an instance secret, use the secrets command, which writes through the same store and takes effect immediately:

Terminal window
printf '%s' 'sk-…' | bun run src/cli/index.ts secrets set anthropic.api_key

Hand-seeding still works too, with one extra step: write the file, then restart, and the boot sweep converts it before anything reads it. Written into a running sealed deployment it is refused, because from the store’s side it is indistinguishable from a file an intruder dropped in.

A value that cannot be opened is refused, never returned. The failure lands on the connection that uses the secret, and writes an audit.credential_seal_failure line to the workspace log naming the scope, the key and the key id it wanted — never the value.