- name
- secrets
- description
- Declaratively register API keys and service credentials a template needs so they appear on Settings › API keys and in the onboarding checklist. Use before adding any third-party credential or setup UI so API keys, OAuth connections, and scoped configuration use the correct shared primitive.
- scope
- dev
- metadata
- {"internal":true}
# Secrets Registry
## Non-negotiable rule
Never hardcode credential values. Source, docs, tests, fixtures, prompts, seed
data, and generated extension/app content may mention credential **names** such
as `OPENAI_API_KEY`, but must not contain real API keys, tokens, webhook URLs,
signing secrets, OAuth refresh tokens, or private Builder/customer data.
Never write real secrets to non-gitignored files. Put temporary secret material
under the root `.tmp/` or another explicitly gitignored path, then delete it
when it is no longer needed.
Provider secret values are supplied at runtime through the encrypted
`app_secrets` vault, `saveCredential` / `resolveCredential`, OAuth, or
`${keys.NAME}` substitution. Deployment configuration is reserved for
deploy-level secrets and non-provider configuration. Examples must use obvious
placeholders such as `<OPENAI_API_KEY>` or `${keys.SLACK_WEBHOOK}`, not
real-looking copied values.
Provider credentials and provider account identifiers are workspace data. Use
standard workspace connections and org/workspace vault scopes; never put them
in `.env` or deployment environment variables, and never add a provider-specific
action or startup bootstrap just to write a credential for one organization.
## Google OAuth triage
| Observation | Meaning | Next action |
| --- | --- | --- |
| `invalid_grant` from a deliberately fake code | Google accepted the client pair and rejected only the code | Do not rotate credentials; check flow state and callback registration |
| `invalid_client` | Google rejected the client id/secret pair | Verify the exact pair and deployment source before rotating |
| `redirect_uri_mismatch` | The client, host, callback path, and Google registration disagree | Compare that exact tuple in Google Cloud Console; publish a new deploy if site-scoped or build-time configuration changes |
Do not reason about this from memory. The probe checks both contracts: the
unqualified `/health/google` endpoint reports the sign-in contract, while
`/health/google?client=managed` reports deployment-level managed OAuth. It asks
Google directly whether each live `(client_id, redirect_uri)` pair is
registered:
```bash
pnpm check:google-redirect-uris -- --env all
```
The sign-in health path calls `checkGoogleSignInCredential()`, which prefers
the active Better Auth pair and otherwise uses `resolveGoogleSignInCredentials()`
from `packages/core/src/server/google-oauth-credentials.ts`. Managed health
calls `checkGoogleManagedCredential()`, which resolves
`["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"]` through `resolveSecretPair()` in
`packages/core/src/server/credential-provider.ts`. App provider handlers use
`resolveGoogleProviderCredentialCandidatesWithReader()` with `resolveSecret`
under request context. These are separate flows and can intentionally land on
different Google clients, so a clean result for one says nothing about the
other. Read both contracts before changing anything:
```bash
curl -s https://HOST/_agent-native/health/google | jq '{clientId,mismatchedPairs,credentialSource}'
curl -s "https://HOST/_agent-native/health/google?client=managed" | jq '{clientId,mismatchedPairs,credentialSource}'
```
Different `clientId` values across those two, or `mismatchedPairs: true`, is a
divergence to understand, not damage to undo. Never repair it by rotating a
secret: writing a fresh value into whichever namespace the failing flow does
not read verifies clean and changes nothing. Do not collapse the namespaces
without first confirming which flow uses which client; separate sign-in and
managed clients on one host can be deliberate. For prebuilt Netlify deploys,
uploaded Functions read site-scoped secrets at runtime, and the health route
resolves them per request. The build may receive masked placeholder values.
After changing a site-scoped env var, publish a new deploy before verifying
live behavior. Call it a rebuild when the changed value is baked into build
output or static assets; a runtime-only secret does not need to be baked into
the bundle.
## Credential Modeling Preflight
Before registering a provider's fields, inspect the workspace/provider connection
catalog first. If a reusable connection exists, use its app grant and scoped
`resolveWorkspaceConnectionCredential(s)ForApp` path instead of registering a
parallel secret. Only classify fields for app-local setup when no reusable
connection exists:
- **API or service key** - register it as `kind: "api-key"` with the narrowest
correct `scope`, a human label, a description, a docs link, and a validator.
- **OAuth authorization or refresh token** - use the OAuth token store and
register a `kind: "oauth"` entry so the shared UI renders Connect and the
runtime owns status, refresh, and reauthorization.
- **Deploy- or app-level configuration** - use deployment/runtime
configuration, not a per-user secret row. For a non-secret public setting
already represented by `AgentNativeConfig`, put the default in
`agent-native.config.ts` and use its `AGENT_NATIVE_CONFIG_<PATH>` alias only
for a deployment override. Never put a credential or provider key in that
public namespace.
- **Account, customer, manager, or other non-secret identifier** - store it as
scoped connection metadata or app data, not as a masked secret field.
`required: true` is for a logical setup requirement. If a provider needs
several values, do not automatically create one required checklist item per
field; use one composite onboarding step or a registered connection readiness
check.
Custom setup UI is allowed for provider-specific prerequisites, ordering, or
health checks, but it must delegate credential storage and connection state to
the shared vault/OAuth/settings surfaces.
## When to use
Use this for any external credential your template needs: API keys, service
tokens, webhook secrets. It gives you:
- A sidebar UI entry for each credential (masked input, rotate, test, delete).
- Automatic onboarding-checklist items for `required: true` secrets.
- A stable server-side read API (`readAppSecret`) that decrypts values on
demand.
- Validator hooks for health-checking keys before save and from a Test button.
## When NOT to use
- OAuth flows that need to run the full authorization code exchange — use
`@agent-native/core/oauth-tokens` directly to save/refresh tokens. The
registry can still surface the OAuth connection in the sidebar by
registering a secret with `kind: "oauth"` — that just delegates status
lookup to oauth-tokens and renders a Connect button, no `app_secrets` row
is written.
- Purely process-level env vars that are never user-facing (e.g. `NODE_ENV`,
deployment flags). Those belong in the onboarding `form` method or the
`envKeys` list in `core-routes-plugin`.
## Registering a secret
```ts
// server/plugins/register-secrets.ts
import { defineNitroPlugin } from "@agent-native/core/server";
import { registerRequiredSecret } from "@agent-native/core/secrets";
export default defineNitroPlugin(() => {
registerRequiredSecret({
key: "OPENAI_API_KEY",
label: "OpenAI API Key",
description: "Used for Whisper transcription of your recordings.",
docsUrl: "https://platform.openai.com/api-keys",
scope: "user",
kind: "api-key",
required: true,
validator: async (value) => {
const res = await fetch("https://api.openai.com/v1/models", {
headers: { Authorization: `Bearer ${value}` },
});
return res.ok
? { ok: true }
: { ok: false, error: `OpenAI rejected the key (HTTP ${res.status})` };
},
});
});
```
### OAuth in the unified UI
```ts
registerRequiredSecret({
key: "GOOGLE_CONNECTED",
label: "Google account",
description: "Grants access to Gmail / Calendar APIs.",
scope: "user",
kind: "oauth",
required: true,
oauthProvider: "google", // must match the provider id in oauth-tokens
oauthConnectUrl: "/_agent-native/google/auth-url",
});
```
The sidebar shows a Connect button instead of a text input; no `app_secrets`
row is written — status is derived from `hasOAuthTokens("google")`.
### Builder.io: organization and personal connections
Builder.io has two OAuth grants per caller, stored apart in
`server/builder-oauth.ts`: the organization's (`org` scope, shared with every
member) and a personal one (`user` scope, used only by its owner: ahead of the
org's for a member, after it for an owner or admin). Name the one you mean;
never let role pick it:
- Connect with `/_agent-native/builder/connect?scope=org|personal`. `org` needs
owner/admin, checked at start and again in the callback, and fails rather
than landing as a personal grant. `personal` is for members only: owners and
admins connect for the organization (`canRoleConnectPersonalBuilder`).
- Disconnect with the `manage-builder-connection` action,
`{ "disconnect": "org" | "personal" }` (the Settings Builder.io page calls it
too). `org` needs owner/admin, checked against the stored member role;
`personal` removes only the caller's grant, so they fall back to the org's.
Without `disconnect` it reads `grants`, `canConnect`, and `defaultModel`
(whether the default model runs on Builder.io and switches or stops once it
is gone). Older clients post the same body to
`/_agent-native/builder/disconnect`.
- `/_agent-native/connection-status/builder` returns `grants` (`{}` none,
`null` unreadable), `effective` (`personal` / `org` / `workspace` / `env` /
`null`), and `canConnect`. Each grant has `kind`: `oauth`, or `keys` for a
key pair saved by account activation or an older connect at that scope. Client code reads them from `useBuilderConnectFlow`
and passes `scope` to `flow.start` and `BuilderConnectionMenu`.
- "Restrict personal API keys" answers in `isPersonalBuilderGrantAllowed`; a
restricted personal grant stays stored, is skipped for requests, and reports
`restricted: true`.
A scopeless connect keeps the old rule (owner/admin writes the org grant,
anyone else a personal one) for older clients only.
## Registered options
| Field | Type | Purpose |
| ------------------ | --------------------------------------- | ------------------------------------------------------------------------ |
| `key` | `string` | Env-var style name (`OPENAI_API_KEY`). Also the storage key. |
| `label` | `string` | Human-readable title in the sidebar. |
| `description` | `string?` | Subtitle under the label. |
| `docsUrl` | `string?` | "Get key" link rendered on the card. |
| `scope` | `"user" \| "workspace"` | Per-user or shared across the active org. |
| `kind` | `"api-key" \| "oauth"` | Drives UI and storage behavior. |
| `required` | `boolean?` | When true, an onboarding step is auto-injected. |
| `validator` | `(v) => Promise<boolean \| {ok,error}>` | Runs on save and from the Test button. Never log `v`. |
| `oauthProvider` | `string?` (oauth-kind only) | Provider id in `oauth-tokens` that backs this entry. |
| `oauthConnectUrl` | `string?` (oauth-kind only) | URL the Connect button points at. |
| `usedFor` | `{ appId?, feature, effectWhenRemoved }[]?` | What this app uses the key for. Set `appId` to the app's id; omit it only for every-app uses. |
| `managedBy` | `{ id, owner, route }?` | The Settings page that creates and rotates the key. Its deletes then need `?managedBy=<id>`. |
### What a key powers
Every registered secret should say what it powers, so API keys shows "Used by
{feature}" and remove confirms list what stops. Write `effectWhenRemoved` as the
user-visible outcome ("Uses another image provider, or stops if none is set
up."), not the mechanism.
- A provider key's model use ("Agent", models leaving the picker) is derived
from the engine registry. Don't add it to `usedFor`.
- Framework-wide uses live in `register-framework-secrets.ts` via
`registerSecretUsage()`, which survives a template registering the same key.
- Keys an owner flow writes (Builder.io credentials, `S3_*` storage fields,
channel tokens, calendar tokens) are mapped in `secrets/managed-keys.ts`. A
registration wins over the map: registering a key puts it on API keys unless
the registration sets `managedBy`.
- Before deleting a key or removing a provider, call `preview-secret-removal`
and tell the user the effects.
### Settings › API keys
The page reads the `list-api-keys` action; the agent reads the same thing.
- `keys`: every saved row the resolver can use for the caller. That is their
`user` row and pre-organization `solo:<email>` row, and for owners and
admins the organization's `org` and `workspace` rows. Each entry has
`scope` (`user` | `org`), `storedScope` (the row), a mask, `usedFor`,
`provider`, and `canReplace` / `canDelete` / `canTest`. Members never see
organization keys.
- `managed`: keys with a `managedBy` owner, read-only, organization ones
included without masks. `addable`: registered keys nobody saved yet.
- Delete with `delete-api-key { name, scope, storedScope }` after the preview
and the user's confirmation. It removes exactly that row, refuses managed
and Vault-synced keys, and fails with 404 when nothing was removed. A
provider key also takes its endpoint and older names at that row.
- Values never go through an action. The page saves through
`saveApiKeyValue` (the secrets routes below). To add or replace a key, send
the user to the page (`open-settings-page`, page `api-keys`; a
`#secrets:KEY` anchor opens Add key with that name, or the provider dialog
when KEY is a model provider's key nobody saved).
## Which credential answers first
Owners and admins run on the organization's credential; members run on their
own. The caller's own row stays the fallback, so an org with no key of its own
still runs on an owner's. Every resolver (`resolveSecretDetailed`,
`resolveSecretPairs`, `resolveCredential`, `getOwnerApiKey`, the Builder key
pair and OAuth reads, `${keys.NAME}`) orders its scopes through
`server/credential-read-order.ts` (`readsOrgCredentialFirst`,
`orderCredentialScopes`); a new resolver must too. An unreadable role fails
the lookup and never reads as "member".
Saves default to the organization for owners and admins and ask who can use
it: on any form that saves a credential, pair `useCredentialSaveScope()` with
`<WhoField>` from `@agent-native/toolkit/app/settings`. Members save
personally and see no picker. A registered key keeps its registered scope.
Disconnect with `deleteResolvedCredential(key, ctx)` from
`@agent-native/core/credentials`. It removes every row of whichever owner
answers (the caller's own, or the organization's, including a legacy
`workspace` row) and refuses a member's removal of the organization's with a
403. Deleting only the caller's `user` row leaves a shared one answering. A
save that clears a value already knows its scope: clear with
`deleteCredential(key, { ...ctx, scope })`, which removes that owner's rows
only. The resolved owner can be the organization even when an owner chose
Personal.
## Reading a secret from an action
```ts
import { z } from "zod";
import { defineAction } from "@agent-native/core/action";
import { readAppSecret } from "@agent-native/core/secrets";
import { getRequestUserEmail } from "@agent-native/core/server";
export default defineAction({
description: "Transcribe an audio file with Whisper",
schema: z.object({ fileUrl: z.string() }),
run: async ({ fileUrl }) => {
const email = await getRequestUserEmail();
if (!email) throw new Error("Not signed in");
const stored = await readAppSecret({
key: "OPENAI_API_KEY",
scope: "user",
scopeId: email,
});
const apiKey = stored?.value;
if (!apiKey) {
throw new Error(
"OPENAI_API_KEY is not set. Add it in Settings.",
);
}
// …call OpenAI. NEVER log the key or include it in error messages.
},
});
```
Rules:
- **Never log the value.** The read layer enforces this server-side; your
code must do the same.
- **Use env vars only for deploy-level secrets.** If a credential is
user-scoped, org-scoped, or workspace-scoped, read the scoped vault/credential
store. Do not add a `process.env` fallback that makes every user inherit one
deployment's key.
- **Scope matches the registration.** `scope: "user"` → pass the user email.
`scope: "workspace"` → pass the active `orgId` from
Ver no GitHub