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.
A reference
Section titled “A reference”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.
The three scopes
Section titled “The three scopes”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
Section titled “Instance scope”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 |
{ "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
Section titled “Workspace scope”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:
{ "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.
Setting one
Section titled “Setting one”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.
Rotation
Section titled “Rotation”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.
Uninstalling
Section titled “Uninstalling”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:
{ "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:
{ "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.
What is audited
Section titled “What is audited”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_secretis 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.
Where the files live
Section titled “Where the files live”{workDir}/credentials/secrets/<key> instance{workDir}/workspaces/<wsId>/credentials/secrets/<key> workspace{workDir}/users/<userId>/credentials/secrets/<key> userOne 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:
mkdir -p ~/.nimblebrain/credentials/secretsprintf '%s' 'sk-…' > ~/.nimblebrain/credentials/secrets/anthropic.api_keychmod 600 ~/.nimblebrain/credentials/secrets/anthropic.api_keyChoosing a backend
Section titled “Choosing a backend”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.
Sealing the files
Section titled “Sealing the files”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:
printf '%s' 'sk-…' | bun run src/cli/index.ts secrets set anthropic.api_keyHand-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.
Related
Section titled “Related”- Credentials — the three shapes a connector’s credentials take.
- nimblebrain.json — instance configuration.
- workspace.json — per-workspace configuration.
- Logging — the workspace log this writes to.