Skip to content

App Secrets ​

Your app's server-side configuration needs credentials — API keys for LLM providers, tokens for external services, signing secrets. App secrets are the platform's server-side store for them: set a value once with the CLI, reference it from server-side config as {{secrets.KEY}} or read it in a server function, and the value resolves on the server. Secrets never appear in your repo, your client code, or anything shipped to users.

Managing Secrets ​

bash
# Create or update a secret
primitive secrets set OPENAI_API_KEY --value sk-... --summary "OpenAI production key"

# List secrets (values are never shown)
primitive secrets list

# Delete a secret
primitive secrets delete OPENAI_API_KEY

Secrets are write-only: once set, a value can be overwritten or deleted but never read back — list shows keys and summaries only. Use --summary so you can tell keys apart later.

Secrets are scoped to an app, so each of your environments (dev, staging, production) keeps its own values — set the production key with --env prod and the development key with --env dev.

An app holds up to 100 secrets, each value up to 2 KB. Every credential your server side uses — an integration's API key, a webhook trigger's signing secret, a token a function reads — is one key, so plan the budget across all of them.

Referencing Secrets ​

Server-side config refers to a secret as {{secrets.KEY}}, resolved at the moment the config runs:

SurfaceWhere secrets resolve
API IntegrationsrequestConfig.defaultHeaders and requestConfig.staticQuery — resolved into the outbound request
Server Functionsctx.secret("KEY"), for a function that declares secret:KEY in its capabilities
Inbound webhooksA webhook trigger's signingSecret — which must be a whole reference — resolved server-side when an incoming event is verified
DatabasesServer-stamped field triggers can read secrets.*

The CEL secrets.* variable is declared-only

Unlike the {{secrets.KEY}} template form, which resolves any secret you've set, secrets.* in a CEL expression binds only the keys a database or collection type config declares in a secrets = ["KEY"] list — see Access Control for the rule.

For example, an integration authenticates with a header that names the secret:

toml
# primitive/dev/integrations/openai.toml
[requestConfig.defaultHeaders]
Authorization = "Bearer {{secrets.OPENAI_API_KEY}}"

Keep Secrets Server-Side ​

The point of the store is that secret values only ever exist where the server resolves them:

  • Never inline credentials in TOML — config files are committed to your repo. Reference {{secrets.KEY}} instead.
  • Keep credentials in integration config, not in function code — a function's ctx.integrations.call has the credential resolved server-side into the request, so the value never enters the function at all. Reach for ctx.secret only when the function itself must hold the value, and never print it: log records redact a secret's value on a best-effort basis only.
  • Clients can't read secrets — there is no client API for secret values; apps only ever see the results of server-side calls that used them.

Config Vars ​

Not every server-side value is a credential. Config vars are the non-secret twin of app secrets: the same key format, the same per-app limit, and the same declare-and-bind path into CEL rules — but the values are plaintext. Use a var for something like a platform-assigned group ID that a rule needs to compare against; use a secret for anything that grants access to an external system.

Vars are configuration, so they are authored in vars.toml and applied with config push (see Syncing Config Vars below):

bash
# Create or update a var
primitive config set vars ADMIN_GROUP_ID=grp_01ABC
primitive config push --only var/ADMIN_GROUP_ID

# Read vars back from the server (values are shown — vars are not secret)
primitive vars list
primitive vars get ADMIN_GROUP_ID

Delete a var by removing its line from vars.toml and pushing: a key the file no longer declares is deleted server-side on the next push, no --prune needed.

Unlike secrets, vars are readable: list shows every value, and a var may appear unmasked in debug traces. Never store a credential in a var. Vars (like secrets) can also be managed from the Admin Console, where their values display in plain text.

A var write that can't land answers 409 with a code naming why: VAR_KEY_EXISTS when a create targets a key the app already holds, or VAR_LIMIT_REACHED when the app is at its var cap — distinct codes, so a full store reads as the cap, not as a duplicate key. A push writes by key, replacing an existing key rather than refusing it, so from the CLI only the cap applies.

Syncing Config Vars ​

Unlike secrets — which never appear in TOML — config vars are part of the sync loop: primitive config pull writes every var to a flat vars.toml at the root of the config directory, sibling to app.toml:

toml
# Per-environment non-secret config vars.
# Bind as {{ vars.KEY }} in integration config and vars.* in CEL rules.
# Values are checked into the repo and NOT secret — never put a credential
# here; use `primitive secrets` for that.

ADMIN_GROUP_ID = "grp_01ABC"
API_HOST = "https://api.example.com"

Edit vars.toml and run primitive config push to apply changes: a changed value upserts, and removing a key from the file deletes that var on the server. primitive config diff reports added, removed, and modified vars like any other synced entity. If a var changed on the server since your last pull — someone edited it from the Admin Console, say — config push reports that key rather than overwriting it, and what it prints depends on which side moved. If only the server moved and your vars.toml still holds what the last sync left, the push prints a DRIFT var: KEY row under "Server drift — not pushed", writes nothing for that key, and the exit code is unchanged. If both sides moved, it prints CONFLICT var: KEY (a 409 carrying CONFLICT) and exits non-zero without applying the change. Either way, config pull reconciles by taking the server's value, and --force overwrites the server with yours unconditionally.

Referencing Vars ​

Beyond the CEL path below, a var also resolves as a template — the same {{ vars.KEY }} form as {{secrets.KEY}}, in integration defaultHeaders/staticQuery (see API Integrations) — and a server function reads one with ctx.configVar("KEY") (see Reading secrets and config vars).

Vars are visible where secrets are hidden

A var resolved into a template is never redacted — logs and request previews show its value, since vars aren't secret. And a {{vars.KEY}} reference to a var key that doesn't exist isn't checked when you save the config; it's left as a literal unresolved placeholder at call time, unlike a {{secrets.KEY}} reference to a missing secret, which fails the save.

CEL rules read declared vars only

Same declared-only rule as secrets: vars.<KEY> binds only the keys the owning config declares in a vars = ["KEY"] list — see Access Control. Unlike an undeclared secret, an unbound var doesn't quietly deny: the rule errors, so check first when the key may be missing — 'KEY' in vars is false rather than failing, and vars.?KEY gives you an optional.

A rule then reads the declared var like any other CEL variable:

toml
access = "isMemberOf('team', vars.ADMIN_GROUP_ID)"

Next Steps ​

Documentation validated against js-bao-wss-client 3.4.0 · js-bao 0.11.0 · primitive-admin 1.0.62 · primitive-app 3.1.0 — 2026-09-30