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.
63
79%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
Fix and improve this skill with Tessl
tessl review fix ./.agents/skills/secrets/SKILL.mdNever 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.
| 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:
pnpm check:google-redirect-uris -- --env allThe 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:
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.
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:
kind: "api-key" with the narrowest
correct scope, a human label, a description, a docs link, and a validator.kind: "oauth" entry so the shared UI renders Connect and the
runtime owns status, refresh, and reauthorization.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.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.
Use this for any external credential your template needs: API keys, service tokens, webhook secrets. It gives you:
required: true secrets.readAppSecret) that decrypts values on
demand.@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.NODE_ENV,
deployment flags). Those belong in the onboarding form method or the
envKeys list in core-routes-plugin.// 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})` };
},
});
});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 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:
/_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).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.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.
| 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>. |
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.
usedFor.register-framework-secrets.ts via
registerSecretUsage(), which survives a template registering the same key.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.preview-secret-removal
and tell the user the effects.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-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.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).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.
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:
process.env fallback that makes every user inherit one
deployment's key.scope: "user" → pass the user email.
scope: "workspace" → pass the active orgId from
getOrgContext(event).orgId.resolveXConfig, a connector, a client factory), call it — do not
read process.env for that key a second time somewhere else.server/lib/ takes the email as a parameter; only an entrypoint (action,
route, cron) decides whose identity it is. A library that resolves its own
identity from process.env.AGENT_USER_EMAIL authorizes the deployment rather
than the caller.The failure this prevents is not a leak — it is a split brain. When the config
check reads the vault and the feature reads process.env, the settings UI and
onboarding checklist report the integration as configured while every actual
call fails with "env var is required". The credential is right there in the
vault, so the error names the wrong cause and sends everyone hunting for a sync
or redeploy problem that does not exist. This has now shipped four times in one
app (BigQuery, Jira, Pylon, Academy).
Before finishing any change that touches a credential, run the guard from the
app directory — it finds this whether the app lives in templates/ or in a
workspace repo:
npx agent-native doctor --only no-env-credentialsresolveCredential sees exactly one organizationresolveCredential(key, { userEmail, orgId }) checks the user scope, then the
one orgId you pass, then the solo workspace. That is the whole search. It is
correct for a signed-in request, and wrong for two common cases:
orgId, so only the user and solo
scopes are ever consulted — and a shared key is in neither.app_secrets has no
scope visible to every org (readAppSecret is strict equality on
(scope, scope_id, key)), so a key the vault UI advertises as available to
every app is unreadable from any org except the one that ran the sync.Both produce the same misleading "not set" that the split brain above produces,
which is why swapping process.env for resolveCredential can look like a fix
and change nothing. A workspace app should read shared keys through a resolver
that also sweeps the caller's other memberships and a designated vault org —
see resolveConnectorSecret and AGENT_VAULT_ORG_ID in the builder-workspace
repo for the shape, including the boot-time assertion that the deployment really
is single-tenant before a non-membership-gated fallback is safe.
The doctor guard does not catch this second form — it looks for
process.env reads, and resolveCredential is not one. Grep for
resolveCredential yourself and confirm each call sits somewhere a single-org
lookup is genuinely the right question. Findings that pair it with a
?? process.env.KEY fallback are the sanctioned deploy-level escape hatch; the
bugs are calls whose value can only live in another org's vault.
Core routes plugin mounts these under /_agent-native/secrets/ automatically:
GET /_agent-native/secrets — list registered secrets with status (set
/ unset / invalid / unknown), metadata, and — for set or invalid
api-keys — the last 4 characters. invalid means the provider rejected the
key in effect (rejectedAt); it stays until a call with the key succeeds or
the key is replaced. Values are never returned.POST /_agent-native/secrets/:key — body { value, scope?, scopeId? }.
Runs the registered validator; returns 400 with the error on failure.DELETE /_agent-native/secrets/:key — remove the stored value. A managed
key returns 409 naming its owner unless the owner passes ?managedBy=<id>
(same on DELETE /secrets/adhoc/:name). Owner pages call
removeManagedSecrets(keys, managerId) from @agent-native/core/client,
or their own action when the agent must reach it too: Channels saves and
removes channel keys through manage-messaging-channel.POST /_agent-native/secrets/:key/test — re-run the validator against the
currently stored value.usedFor and managedBy for registered and ad-hoc
keys. The remove-impact preview is the preview-secret-removal action.Model provider keys are checked by listing the models they reach: the
check-provider-key action (client helper fetchProviderModels) returns
{ ok, models } or { ok: false, code, reason }. Call it before saving a key
the user gives you; the key-save route runs the same check and refuses a
rejected key.
app_secrets (created on-demand; no migration
needed).<APP_NAME>_SECRETS_ENCRYPTION_KEY (for example,
ANALYTICS_SECRETS_ENCRYPTION_KEY), then SECRETS_ENCRYPTION_KEY, then
BETTER_AUTH_SECRET.app_secrets prefer
WORKSPACE_SECRETS_ENCRYPTION_KEY, then the legacy shared
SECRETS_ENCRYPTION_KEY, then a purpose-derived key from A2A_SECRET.
Better Auth and app-scoped keys remain legacy read candidates; a successful
legacy decrypt is compare-and-swap migrated to the preferred shared key
without changing the row timestamp.WORKSPACE_SECRETS_ENCRYPTION_KEY in every app that
reads a shared vault. It is intentionally separate from app-local OAuth
encryption. If the vault still relies on A2A_SECRET, rotating A2A material
also rotates its encryption key. Add the stable vault key everywhere and
read/migrate existing rows before rotating A2A.WORKSPACE_SECRETS_ENCRYPTION_KEY_PREVIOUS=<old value>, let reads
migrate ciphertext, then remove the previous key after the migration window.Ad-hoc keys are user-created secrets that are not declared by the template.
Users create them through the settings UI or the agent chat to use with
automations and the web-request tool. They support ${keys.NAME}
substitution in outbound HTTP requests.
Core routes plugin mounts these under /_agent-native/secrets/adhoc:
GET /_agent-native/secrets/adhoc — list all ad-hoc keys (name, last 4
chars, URL allowlist). Values are never returned.POST /_agent-native/secrets/adhoc — body { name, value, urlAllowlist? }.
Creates or updates an ad-hoc key.DELETE /_agent-native/secrets/adhoc/:name[?scope=user|workspace] —
remove an ad-hoc key. Pass the listed row's scope when a name is saved
at both; without it the personal row goes first.Each ad-hoc key can have a URL allowlist — an array of origin URLs that restrict where the key's value can be sent. The check is origin-level (scheme + host + port). If no allowlist is configured, the key can be used with any URL.
// Creating a key with an allowlist
POST /_agent-native/secrets/adhoc
{
"name": "SLACK_WEBHOOK",
"value": "<SLACK_WEBHOOK_URL_FROM_SETTINGS>",
"urlAllowlist": ["https://hooks.slack.com"]
}${keys.NAME} SubstitutionThe web-request tool supports ${keys.NAME} placeholders in the URL,
headers, and body. Substitution happens server-side after the agent emits
the tool call — the raw secret value never enters the agent's context.
import {
resolveKeyReferences,
validateUrlAllowlist,
} from "@agent-native/core/secrets/substitution";
// Resolve all ${keys.NAME} references in a string
const { resolved, usedKeys } = await resolveKeyReferences(
"Bearer ${keys.API_TOKEN}",
"user",
"user@example.com",
);
// Validate a URL against a key's allowlist
const allowed = validateUrlAllowlist(
"https://hooks.slack.com/services/<WORKSPACE>/<CHANNEL>/<SECRET>",
["https://hooks.slack.com"],
);Key resolution falls back from user scope to workspace scope, so users can override shared keys without breaking automations that reference workspace defaults.
Save model provider keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, the other
PROVIDER_ENV_META keys, and the OpenAI/Ollama endpoints) with
saveAgentEngineProviderSettings({ provider, apiKey, scope }); remove them
with deleteAgentEngineProviderSettings({ provider, scope }).
scope defaults to "user". "org" needs an owner or admin; a member gets
403. A caller with no organization saves personally either way, and the
response's scope says which row was written.useCredentialSaveScope + WhoField); everyone else saves
personally. Save stays off until the role is read; a failed read shows a
retry and never falls back to a personal save. Every provider key registers
at scope: "user", so Settings → API keys writes the same personal row.GOOGLE_GENERATIVE_AI_API_KEY, for chat models and
for voice input, embeddings, and image generation. Read it with
resolveGeminiApiKey() from @agent-native/core/server (never
resolveSecret("GEMINI_API_KEY")): rows saved under the older
GEMINI_API_KEY name still answer, and removing the key removes both
names at its own scope. An older-name row at another scope (Brain once
saved one for the whole workspace) still answers and is listed with the
ad-hoc keys; remove it with
DELETE /_agent-native/secrets/adhoc/GEMINI_API_KEY?scope=workspace
(owners and admins). Don't register GEMINI_API_KEY; record an app's uses with
registerSecretUsage(GEMINI_API_KEY, [...]) from @agent-native/core/secrets.An org setting that stops members (not owners or admins) from using or adding
their own model provider keys and personal Builder.io connection. Read and
change it with the manage-provider-key-policy action: omit set to read it
(owners and admins also get affectedMembers, each member and the providers
that stop), set: true|false to change it (owners and admins only, audited).
Read it before turning it on and tell the user who is affected.
isPersonalProviderPolicyKey in server/personal-provider-key-policy.ts).
Other personal secrets are untouched.isPersonalProviderKeyUseRestricted: it skips the
member's user row, their solo:<email> row, and legacy personal settings
rows, then continues to the org's. An unreadable policy is a failed lookup,
never "not restricted". Add any new resolver or personal write path here.personal_provider_keys_restricted).Owners and admins pick which provider powers Voice input, Image generation, and
Embeddings (Settings › Infrastructure). Read and change it with the
manage-service-providers action: omit provider to read each service's
choice, the provider that answers next, and each option's keyState (org,
personal, none, unavailable); pass service and provider to change one
(owners and admins only, audited). "builder" returns a service to Builder.io
and null resets it to the default order.
readServiceProviderChoice(service) and order
providers with serviceProviderOrder(service, choice) from
@agent-native/core/server. Don't hard-code a second list of providers.backfill-search-embeddings); the set call returns reindexRequired.resolveSecretDetailed (see "Which credential answers first"). An unreadable choice
is a failed lookup, never "unset".get-file-storage / manage-file-storage for uploads,
list-model-providers for the AI model, and get-infrastructure-status
(owners and admins) for the environment: the database provider and host,
where each app is hosted and its address, which deploy variables are set,
and the app profile's Required/Recommended tags. It never returns a value or
a database URL. Those variables live on the host, so the answer to "change
DATABASE_URL" is a host setting plus a redeploy, not a Settings write.Dispatch workspaces have a vault access policy for workspace app credentials:
all-apps is the default. Every saved Dispatch vault key is available to
every workspace app; sync-vault-to-app pushes all vault keys to the target
app.manual requires explicit per-app grants. Use
create-vault-grant / grant-vault-secrets-to-app, then
sync-vault-to-app.Use get-vault-access-settings before deciding whether to create grants, and
use set-vault-access-settings only when the user asks to change the policy.
Vault keys land in the shared app_secrets store at org scope, so an app's
Settings › API keys page reports them as Set · Vault through
resolveSecretDetailed (source/scopeId) instead of the registered-scope
row alone. Runtime precedence is personal (user) row → shared org row →
legacy workspace row → designated vault org → deploy env, with the personal
row moved after the organization's for owners and admins. Never add a second
place to enter a key that the Vault already provides; label the source instead.
| File | Purpose |
|---|---|
packages/core/src/secrets/substitution.ts | resolveKeyReferences(), validateUrlAllowlist() |
packages/core/src/tools/fetch-tool.ts | web-request tool consuming key references |
onboarding — the setup checklist that required secrets show up in.actions — where you'll read secrets when calling third-party APIs.authentication — session scoping; scope: "user" uses the session
email.security — input validation and never logging secrets.automations — ad-hoc keys power ${keys.NAME} in automation web requests.a941a2e
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.