Skip to content

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.

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.

A credential row carries an identity tuple and a pointer to where the durable form is stored.

FieldPurpose
id, org_id, nameOperator-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_idNULL 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.
providerClosed-enum provider slug — see Providers.
kindClosed-enum auth shape — see Kinds.
scopesOAuth scopes for the credential. Empty for api_key / basic_auth / query_api_key (no scope concept).
durable_refInternal 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_configKind-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.
statusactive / needs_reauth / revoked. See Status lifecycle.
last_minted_at, last_minted_statusThe 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_overrideOrg-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.

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.

GroupProviders
OAuth — delegated and DWDgoogle, 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 Basicjira, 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:

KindMint flowDurable formprovider_config
oauth2_jwt_bearerJWT bearer assertion → access token (RFC 7523).Service account JSON.
oauth2_jwt_bearer_with_subjectSame 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_codeThree-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_credentialsTwo-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_keyNo 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_authNo 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_keyNo 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.

A credential’s status reflects what the proxy is actually observing on the mint path, not just whether the row exists.

  • active is the happy state. New rows start here (after the OAuth flow completes, for AC kinds).
  • needs_reauth is 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 affected policy_decision rows’ auth_failures map. To recover, re-run the OAuth flow (for AC kinds) or re-upload the durable form (for everything else), which transitions the row back to active.

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.

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:

  1. Operator opens the + Add Credential wizard, picks a provider (google or microsoft), and lands on the AC kind.
  2. The wizard collects the BYO OAuth client the operator has registered with the provider — client_id (verified against the provider’s expected format), the client_secret (sent to the platform and stored as the durable form’s companion), and (Microsoft only) the tenant_id. The client_secret is stored at a separate location from the eventual refresh token because both need to be readable on every refresh.
  3. 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).
  4. The operator clicks Connect. The console calls the platform’s start-OAuth endpoint, which returns the provider’s authorisation URL. The browser navigates there.
  5. 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.
  6. The platform exchanges the code for access + refresh tokens, persists the refresh token durably, and flips the credential to active.
  7. 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.

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:

StateWhat happens
Org-scoped row onlyOrg-scoped wins. Every workspace’s auth: transform resolves to it.
Org-scoped row + user-scoped row, allow_user_override=trueAt 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 onlyIt resolves only for that user. Workspaces created by other users hit auth_unavailable on transforms referencing this credential — there’s no fallback.
NeitherThe 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.

A capability references a credential through an auth: transform:

transforms:
- auth:
credential: google_drive
provider: google

At 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:

  1. Looks up the resolved credential by name.
  2. Mints fresh wire credentials if needed (cached with a short TTL; OAuth access tokens get re-minted before their natural expiry).
  3. 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 are the simpler primitive. A row carries a name and a pointer to where the value is stored.

FieldPurpose
id, org_id, nameOperator-visible identity. name is the key a capability’s transform CEL references via secrets["name"]. (org_id, name) is unique.
created_at, updated_atBookkeeping. 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.

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:

VariantAccepts secret refs?Why
headeryesRequest headers don’t appear in the proxy’s access log.
queryyesThe access log strips the query string entirely.
bodyyesRequest bodies aren’t logged.
pathnoThe 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.
authn/aStructural variant — no CEL value, so no secret reference. Use a credential instead.

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:

  1. The transform’s CEL evaluates with a per-transform secrets map scoped to exactly the names that transform declared.
  2. 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.

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.