Credentials & secrets
An agent’s outbound requests sometimes need to carry sensitive material the agent itself shouldn’t hold — authentication tokens, signing keys, customer identifiers, anything you don’t want sitting in a model context. The platform keeps that material on the off-pod proxy and injects it onto the relevant requests on the agent’s behalf. There are two storage primitives, used for different jobs:
- Credentials are how you authenticate to a known upstream. They’re typed records for providers the platform models (OAuth, static API keys, HTTP Basic, query-string API keys). The platform owns each provider’s wire shape: the proxy’s broker mints the right token from the credential’s stored form and stamps it onto the upstream request as the provider expects.
- Secrets are opaque named values for any other sensitive
bytes you need injected — a static API token your internal
service expects in a custom header, a tenant identifier the
agent shouldn’t see in plain text, a customer-id you don’t
want appearing in logs or model context. You reference them
from a capability’s transform (
secrets["my-internal-token"]), and the transform decides where the value lands.
Both store their bytes encrypted at rest. The agent itself never sees either — no environment variables, no files on the filesystem, no API to read them.
This page covers both, the distinction between them, and the console flows for managing each. For the transform syntax that references them, see caps.yaml.
When to use which
Section titled “When to use which”| Use a credential when… | Use a secret when… |
|---|---|
| The upstream is one of the providers the platform models — see the Providers list below. | The upstream isn’t in that list and you need to inject something specific — a static token an internal service expects in a custom header, a tenant identifier baked into a request body, a customer-id passed in a header. |
| You want the platform to handle refresh tokens, OAuth flows, Basic-auth base64 encoding, and provider-specific wire shapes for you. | You want full control over where the value lands (which header, which body path) and how it’s combined with other things. |
Concretely: refreshing a Google Drive token is a credential
(google provider, oauth2_authorization_code kind). A custom
X-Internal-Auth: <token> header your internal API expects is a
secret referenced from a header transform.
Credentials
Section titled “Credentials”A credential row carries an identity tuple and a pointer to where the durable form is stored.
Fields
Section titled “Fields”| Field | Purpose |
|---|---|
id, org_id, name | Operator-visible identity. name is the slug a capability’s auth: transform references. (org_id, name, COALESCE(user_id, '')) is unique — an org-scoped row can coexist with per-user same-name overrides. |
user_id | NULL for org-scoped rows (shared across every actor in the org); populated for user-scoped rows (only the named user). Lookup at activation prefers the user-scoped row for the workspace’s creator, falling back to the org-scoped sibling under the same name. See Org-scoped vs. user-scoped below. |
provider | Closed-enum provider slug — see Providers. |
kind | Closed-enum auth shape — see Kinds. |
scopes | OAuth scopes for the credential. Empty for api_key / basic_auth / query_api_key (no scope concept). |
durable_ref | Internal pointer to where the durable form (refresh token / service account JSON / client_secret / Basic password / API key value) is stored, encrypted. Never returned over the API; never logged in audit. |
provider_config | Kind-specific companion fields that aren’t the secret itself — Microsoft tenant_id + client_id, Google DWD subject, Atlassian username, Google Programmable Search cx, BYO OAuth client_secret_ref for AC kinds. |
status | active / needs_reauth / revoked. See Status lifecycle. |
last_minted_at, last_minted_status | The proxy’s last reported mint outcome. Updated on every mint regardless of success or failure, so the operator can see the freshest health signal. |
allow_user_override | Org-scoped-only knob that decides whether user-scoped siblings under the same name are allowed to override. Default false (operator’s credential is single-source-of-truth). See Org-scoped vs. user-scoped. |
Providers
Section titled “Providers”Provider slugs are a closed enum. Adding a provider is a code change, not a config change — credential auth is too sensitive a path to be open to free-form configuration.
| Group | Providers |
|---|---|
| OAuth — delegated and DWD | google, microsoft |
| Static API key (header) | anthropic, openai, gemini, azure_openai, slack_bot, github_pat, notion_token, linear_pat, hubspot_pat, discord_bot, gitlab_token, sendgrid, pagerduty, grafana, databricks, nvd, elevenlabs, airtable, splunk, newrelic, virustotal, elasticsearch |
| HTTP Basic | jira, confluence, gitlab_git_https, github_git_https |
| Static API key (query string) | google_search |
The closed-enum credential kinds determine how the proxy turns the durable form into the wire credential at request time:
| Kind | Mint flow | Durable form | provider_config |
|---|---|---|---|
oauth2_jwt_bearer | JWT bearer assertion → access token (RFC 7523). | Service account JSON. | — |
oauth2_jwt_bearer_with_subject | Same as above, but the minted access token impersonates a Google Workspace user via domain-wide delegation. | Service account JSON. | subject (the user to impersonate) |
oauth2_authorization_code | Three-legged OAuth. The console walks the operator through consent on the provider’s screen; the resulting refresh token is stored durably. Every subsequent mint exchanges the refresh token for a fresh access token. Microsoft rotates the refresh token on every refresh; Google doesn’t. | Refresh token. | BYO client_id + client_secret_ref pointing at the customer’s OAuth-app secret. Microsoft also: tenant_id. |
oauth2_client_credentials | Two-legged app-only flow. Form-POST grant_type=client_credentials exchanges the client_secret for an access token; no refresh token, no rotation. | client_secret. | tenant_id + client_id (Microsoft). |
api_key | No mint. The durable value rides directly on the wire under the provider’s fixed header (x-api-key for some, Authorization: token … for GitHub, x-goog-api-key for Google Gemini, etc.). | The API key string. | — |
basic_auth | No mint. The proxy computes base64(username:token) at request time and stamps Authorization: Basic <base64>. | The password / API token. | username (not a secret — Atlassian uses email addresses here, intentionally visible). |
query_api_key | No mint. The durable value rides in a query parameter instead of a header (?key=<value>). Provider-specific companion params (Google Programmable Search’s ?cx=<engine-id>) also come from provider_config. | The API key string. | Provider-specific companion params. |
The legal (provider, kind) pairs are pinned — a microsoft
credential can’t be api_key, an anthropic credential can’t
be oauth2_authorization_code, etc. Create requests with an
invalid pair are rejected with a field error before any durable
value gets persisted.
Status lifecycle
Section titled “Status lifecycle”A credential’s status reflects what the proxy is actually
observing on the mint path, not just whether the row exists.
activeis the happy state. New rows start here (after the OAuth flow completes, for AC kinds).needs_reauthis set automatically when the proxy reports a terminal mint failure — refresh token rejected, client_secret invalid, durable cred unparseable. Transient failures (network blips, provider 5xx) don’t flip status; they show up only on the affectedpolicy_decisionrows’auth_failuresmap. To recover, re-run the OAuth flow (for AC kinds) or re-upload the durable form (for everything else), which transitions the row back toactive.
To take a credential out of service today, delete it
(DELETE /v1/credentials/{id}). Delete actually removes the row
and broadcasts cache invalidation to every active proxy, so any
mint after delete fails with auth_unavailable. The schema
reserves a revoked status for a future soft-revocation feature,
but it isn’t reachable today — the customer-visible revocation
operation is delete.
The mechanism: every mint the broker performs reports back to
the control plane with (reason, terminal). The CP updates
last_minted_status + last_minted_at on every callback so
the operator sees the latest mint outcome; it flips status to
needs_reauth only when the failure is terminal and the row was
previously active. Once-active-then-failed transitions emit an
audit row with from_status / to_status / reason; routine
“still failing” repeats don’t.
OAuth wizards
Section titled “OAuth wizards”For the two AC kinds, the console walks the operator through a three-legged OAuth flow rather than asking them to paste a refresh token.
The flow:
- Operator opens the + Add Credential wizard, picks a
provider (
googleormicrosoft), and lands on the AC kind. - The wizard collects the BYO OAuth client the operator
has registered with the provider —
client_id(verified against the provider’s expected format), theclient_secret(sent to the platform and stored as the durable form’s companion), and (Microsoft only) thetenant_id. The client_secret is stored at a separate location from the eventual refresh token because both need to be readable on every refresh. - The wizard collects scopes (with a few common presets — Drive
read-only, Sheets read-write, Microsoft Graph delegated /
app-only) and any kind-specific extras (Google DWD
subject). - The operator clicks Connect. The console calls the platform’s start-OAuth endpoint, which returns the provider’s authorisation URL. The browser navigates there.
- The user grants consent on the provider’s screen. The
provider redirects back to a fixed platform callback URL
(
/oauth/callback?code=...&state=...) — the callback is at the platform root rather than under/v1/because the redirect URI is registered with the customer’s BYO app and can’t move easily. - The platform exchanges the code for access + refresh tokens,
persists the refresh token durably, and flips the credential
to
active. - The browser is redirected back to the console’s credential detail view, which renders a “you’re connected” success state.
For Microsoft, every subsequent refresh rotates the refresh token — the broker writes the rotated value back through a callback, so the durable form stays current without operator involvement.
For the other kinds (api_key, basic_auth,
query_api_key, oauth2_jwt_bearer*, oauth2_client_credentials)
there’s no consent flow — the wizard collects the durable value
directly and persists it. The browser-redirect handshake above
doesn’t apply.
Org-scoped vs. user-scoped
Section titled “Org-scoped vs. user-scoped”Credentials are usually shared across the org — Operators set up
the org’s google credential once and every workspace under
every role uses it. But for AC kinds (and only those), each
Member can optionally attach their own user-scoped credential
under the same name, so an agent running a task they submitted
authenticates as them rather than as the org-shared service
account.
The override is gated by the org-scoped row’s
allow_user_override flag:
| State | What happens |
|---|---|
| Org-scoped row only | Org-scoped wins. Every workspace’s auth: transform resolves to it. |
Org-scoped row + user-scoped row, allow_user_override=true | At activation time, the platform looks at the workspace’s created_by_user_id. If a user-scoped row exists under that identity, it wins; otherwise the org-scoped sibling. |
| User-scoped row only | It resolves only for that user. Workspaces created by other users hit auth_unavailable on transforms referencing this credential — there’s no fallback. |
| Neither | The capability that references the name resolves to nothing; affected requests record auth_unavailable and reach upstream without the credential header. |
allow_user_override=false and user-scoped siblings can’t
coexist — creating either side of that combination on top of the
other is rejected.
The lookup happens once at workspace activation; the proxy caches the chosen identity for the lifetime of the workspace. There’s no per-request acting-user disambiguation in the hot path.
allow_user_override is only meaningful on AC kinds —
api_key / basic_auth / query_api_key are always org-scoped
(there’s no user identity baked into the durable form to attach
to), and the wizard force-flips personal=false on those
providers.
How a credential reaches upstream
Section titled “How a credential reaches upstream”A capability references a credential through an auth:
transform:
transforms: - auth: credential: google_drive provider: googleAt workspace activation, the platform resolves the name to a specific row (per the override rules above), ships its full config to the proxy on the activation payload, and pre-fetches the durable value. On the request, the proxy’s broker:
- Looks up the resolved credential by name.
- Mints fresh wire credentials if needed (cached with a short TTL; OAuth access tokens get re-minted before their natural expiry).
- Stamps them onto the upstream request —
Authorization: Bearer <token>for OAuth providers, the provider’s service-specific header for static API keys,Authorization: Basic <base64>for Basic, or merged into the query string for Google Programmable Search.
The agent never sees any of this. It made an unauthenticated HTTP call; the upstream got an authenticated one.
If the mint fails — terminal or transient — the upstream call
proceeds without the credential header (the request goes
through; the upstream rejects naturally with whatever 401/403
shape it produces). The policy_decision audit row records the
failure on auth_failures (mapping credential name →
auth_unavailable), and terminal failures additionally flip
the credential’s status to needs_reauth as described above.
Secrets
Section titled “Secrets”Secrets are the simpler primitive. A row carries a name and a pointer to where the value is stored.
Fields
Section titled “Fields”| Field | Purpose |
|---|---|
id, org_id, name | Operator-visible identity. name is the key a capability’s transform CEL references via secrets["name"]. (org_id, name) is unique. |
created_at, updated_at | Bookkeeping. The actual value is stored encrypted at rest, separately from this row. There’s no in-place update — rotation is delete then create under the same name, so anything pre-fetched on the proxy invalidates correctly. |
There’s no kind, no status, no provider, no per-user variant — secrets are deliberately uniform. The platform never reads the value to interpret it; it only resolves the lookup at request time and hands the bytes to the transform’s CEL.
Where they can be referenced
Section titled “Where they can be referenced”A capability transform’s CEL value can reference secrets:
transforms: - header: name: X-API-Source action: set value: 'secrets["internal-source-id"]'The parser statically extracts every secrets["literal"]
reference at save time. Dynamic key access (secrets[someVar])
is rejected — the platform pre-fetches secrets at activation
based on the static reference list, and a dynamic key wouldn’t
be resolvable ahead of time. Each transform’s CEL only has
access to the secrets it statically references; a transform
can’t reach for one it didn’t declare.
The transform variants that accept secrets["..."] references:
| Variant | Accepts secret refs? | Why |
|---|---|---|
header | yes | Request headers don’t appear in the proxy’s access log. |
query | yes | The access log strips the query string entirely. |
body | yes | Request bodies aren’t logged. |
path | no | The path is logged verbatim, and there’s no general way to scrub a secret from an arbitrary position in a URL path. The parser rejects path values referencing secrets["..."] at save time. |
auth | n/a | Structural variant — no CEL value, so no secret reference. Use a credential instead. |
How a secret reaches upstream
Section titled “How a secret reaches upstream”At workspace activation, the platform walks every transform’s declared secret references and pre-fetches the values into the proxy’s secret cache. On the request:
- The transform’s CEL evaluates with a per-transform
secretsmap scoped to exactly the names that transform declared. - The result is rendered into the header / query / body of the upstream-bound request.
The cache has a short TTL. If a secret is rotated through the API, the platform invalidates the relevant cache entries on every proxy so the next request picks up the new value immediately rather than waiting out the TTL.
If the lookup fails at request time (transient backend
error, etc.), the transform records the failure on the
policy_decision row’s transforms_failed map (with reason
apply_error); the request continues to upstream without the
header / query / body mutation. Transforms observe, never
deny.
A single capability can use both kinds — an auth: transform
that mints an OAuth Bearer header from a credential plus a
header transform that stamps a secret into a custom header on
the same request. Most capabilities use at most one of the two.
Missing-ref hints
Section titled “Missing-ref hints”When a capability references a credential or secret that doesn’t
exist in the org, that gap surfaces in the console as
missing-ref chips — small warning-coloured labels with the
referenced name, rendered on every
role and provider card that has
the dangling reference. The chips deep-link to the create flow
with the name pre-filled, so an operator can jump straight to
fixing it. Members see the chips as plain text rather than a
link when the credential’s kind would be force-org-scoped (a
Member with only auth:write:own can’t create org-scoped
kinds), which keeps them from clicking into a wizard they’d
land on a disabled submit in.
The same chips also surface on the
task-submit form as a
pre-flight panel, so an operator about to run a task on a
role with unresolved auth or secrets gets a warning at submit
time instead of a runtime auth_unavailable audit row.