Skip to main content

Secrets API

This page documents every secrets endpoint on agent-engine-api: storing and rotating project secrets, listing versions, the one audited call that returns a value, minting short-lived read tokens, and the bindings that deliver a secret to an agent as an environment variable. Set $CAI_API first — see the API overview.

For the concepts behind these calls, start with the Secrets Manager overview.

Base URL

Secrets are served by agent-engine-api, the core platform API — the same service as agents, projects, and users.

kubectl -n cai-system port-forward svc/agent-engine-api 8080:8080 &
export CAI_API=http://localhost:8080
export PROJECT=<your-project-uuid> # platformctl projects list

Authentication and roles

Every route here requires a bearer credential:

Authorization: Bearer <token-or-api-key>
CallerCan call
project memberList, create, and rotate secrets; read metadata and versions; list, create, repoint, and remove bindings; apply bindings; mint read tokens
project adminEverything a member can, plus reveal a value and delete a secret
agent ownerThe per-agent value routes (/v1/agents/{name}/secrets) — a project admin and the automation token also pass
workload key (cai_wl_...)Only the project-scoped token mint (POST /v1/projects/{projectID}/secrets:issue-token), and only for its own project

Two verbatim refusals worth knowing:

  • A member calling reveal or delete gets 403 — this action requires the project admin role.
  • A deployed workload's key on any other management route gets 403 — this credential is a deployed workload's key; it can only mint a read token for its own project's secrets, not use management endpoints. The name-scoped :issue-token route is the exception: it is gated on project-resource authority, which a workload key never holds, so there it gets 404 under the rule below.

Conventions

  • Error envelope. Every non-2xx response is {"error": "<message>", "request_id": "<id>"}.
  • The 404 rule. A request against a project you hold no grant on returns 404 — never 403. You see 403 only when you already hold a grant but lack the role for that action.
  • Pagination. page_size (1–200, default 50) and page_token; responses carry next_page_token. See the API overview.
  • Custom methods. Actions that are not plain CRUD use a GCP-style :verb suffix on the resource path (:reveal, :issue-token, :apply), always with POST.
  • Values never travel on a read. No list, get, or version response has a field a value could live in. The single exception is :reveal, which is admin-only and audited.
  • Store health is not emptiness. If the secret store is not configured or cannot answer, you get 503, never an empty list. There are two distinct 503s: the store is unconfigured (the message begins the project secret store is not configured: agent-engine-api starts it only when BAO_ADDR and BAO_TOKEN are set) or the store itself is sealed or unreachable (the message comes from the store). A 404 means the secret is genuinely absent.
  • One reserved name. crusoe-cloud-credential is invisible on this whole surface. Create, get, versions, delete, reveal, and the name-scoped token route all return 404 — no such secret. "crusoe-cloud-credential" is a reserved platform nameuse GET/PUT/DELETE /v1/projects/{id}/crusoe-cloud instead. It is also omitted from listings.

Endpoints at a glance

MethodPathAuthPurpose
GET/v1/projects/{projectID}/secretsmemberList secrets (metadata only)
POST/v1/projects/{projectID}/secretsmemberCreate a secret, or store a new version of one
GET/v1/projects/{projectID}/secrets/{name}memberOne secret: versions and who binds it
GET/v1/projects/{projectID}/secrets/{name}/versionsmemberVersion history
DELETE/v1/projects/{projectID}/secrets/{name}adminDestroy the secret and every version
POST/v1/projects/{projectID}/secrets/{name}:revealadminReturn a value once, audited
POST/v1/projects/{projectID}/secrets/{name}:issue-tokenmemberRead token scoped to one secret
POST/v1/projects/{projectID}/secrets:issue-tokenmember or workload keyRead token scoped to the whole project
GET/v1/projects/{projectID}/secret-mapsmemberEvery binding in the project
GET/v1/projects/{projectID}/agents/{agent}/secret-mapsmemberOne agent's bindings
PUT/v1/projects/{projectID}/agents/{agent}/secret-maps/{envName}memberCreate or repoint one binding
DELETE/v1/projects/{projectID}/agents/{agent}/secret-maps/{envName}memberUnbind — revokes immediately
POST/v1/projects/{projectID}/agents/{agent}/secret-maps:applymemberDeliver bound values to the agent
GET/v1/agents/{name}/secretsownerKey names written directly onto one agent
PUT/v1/agents/{name}/secretsownerReplace that agent's whole set
PATCH/v1/agents/{name}/secretsownerMerge into that agent's set

The secret object

Reads return metadata only:

{
"name": "openai-api-key",
"current_version": 3,
"created_at": "2026-08-06T12:00:00Z",
"updated_at": "2026-08-11T09:14:00Z",
"versions": [{"version": 1, "created_at": "...", "destroyed": false}],
"used_by": [{"id": "...", "agent_id": "...", "agent_slug": "research-buddy",
"env_name": "OPENAI_API_KEY",
"created_at": "...", "updated_at": "..."}]
}
FieldTypeMeaning
namestringThe secret's name within the project
current_versionintThe newest stored version. Every write adds one; nothing is overwritten
created_at, updated_atstringRFC 3339 timestamps
versionsarrayEvery version, with destroyed marking ones that can no longer be read
used_byarrayThe bindings that point at this secret. Absent (not []) when nothing binds it. version is omitted when the binding tracks the newest version, and is an exact version number when pinned

List secrets

GET /v1/projects/{projectID}/secrets

Auth: member.

Response 200:

{"secrets": [{"name": "openai-api-key", "current_version": 3,
"created_at": "...", "updated_at": "..."}],
"path_prefix": "projects/<project-short>",
"next_page_token": "..."}

Version history is deliberately dropped from the list — fetch one secret to see it. path_prefix is where the values live in the store; it is the prefix a read token is scoped to.

Create a secret or store a new version

POST /v1/projects/{projectID}/secrets

Auth: member. There is no separate rotate endpoint — writing an existing name stores a new version.

Request body (capped at 128 KiB):

FieldTypeRequiredRules
namestringyes1–63 characters: letters, digits, - or _, starting with a letter or digit
valuestringyesNon-empty; at most 65536 bytes (64 KiB)
{"name": "openai-api-key", "value": "sk-live-abc123"}

Response 201 for the first version, 200 for a rotation:

{"name": "openai-api-key", "version": 1, "created": true,
"note": "the value is stored. Nothing - this API included - will show it back to you except an explicit, audited reveal."}

Errors:

StatusMessage
400secret name must be 1-63 characters of letters, digits, '-' or '_', starting with a letter or digit (got "...")
400secret value must not be empty
400secret value is N bytes, limit is 65536
503The store is unconfigured, sealed, or unreachable (see Conventions)
Rotation does not reach a running agent by itself

A new version reaches agents whose bindings track the newest version only on the next apply or deploy. Rotate, then apply.

Get one secret

GET /v1/projects/{projectID}/secrets/{name}

Auth: member. Returns 200 with the full secret object above, including versions and used_by. A secret that does not exist returns 404.

List versions

GET /v1/projects/{projectID}/secrets/{name}/versions

Auth: member. Response 200:

{"name": "openai-api-key", "current_version": 3,
"versions": [{"version": 1, "created_at": "...", "destroyed": false}]}

Delete a secret

DELETE /v1/projects/{projectID}/secrets/{name}

Auth: project admin. Destroys the secret and every version.

Response 200:

{"name": "openai-api-key", "deleted": true,
"note": "every version is destroyed. This is not recoverable."}

Errors:

StatusMessage
409<name> is still bound by N binding(s): <agent>.<ENV>, ... Remove them first - nothing in the database prevents this delete, so the failure would otherwise appear later, as an agent that will not start.
404The name does not exist (the API reads it first, so deleting something absent is a real 404 rather than a silent success)

Reveal a value

POST /v1/projects/{projectID}/secrets/{name}:reveal

Auth: project admin. This is the only call in the platform that returns a stored secret value.

Request body (optional):

FieldTypeRequiredDefaultNotes
versionintno00 or absent means the current version
reasonstringnoRecorded with the read
{"version": 0, "reason": "rotating into payments"}

A malformed body deliberately reveals the current version rather than failing — the call is meant to be usable in an emergency.

Response 200:

{"name": "openai-api-key", "version": 3, "value": "sk-live-abc123",
"note": "this read is in the audit log with your identity, the version and the time."}
Reveal is fail-closed on audit

If the audit row cannot be written, the call returns 503 and no value. The platform would rather refuse the read than perform an unrecorded one. See Break-glass and audit.

Mint a scoped read token

POST /v1/projects/{projectID}/secrets/{name}:issue-token
POST /v1/projects/{projectID}/secrets:issue-token

Auth: the project-scoped form (/secrets:issue-token) accepts any project member or a deployed workload's key, for its own project only — it is the one management route a workload credential may reach. The name-scoped form (/secrets/{name}:issue-token) is member-only: it runs through the same gate as :reveal, and that gate grants a workload key no authority at all, so a workload key there gets 404 — not found — under the 404 rule above, not the 403 the other management routes return.

The first form mints a short-lived token that can read exactly one secret; the second mints one that can read every secret under the project. The token is presented directly to the secret store, so a workload fetches a value at call time instead of holding a copy.

Request body:

FieldTypeRequiredDefaultRules
ttl_secondsintno300Clamped to 3600 (silently, with a server-side warning — not a rejection). The store's own maximum lease may clamp it further; the returned ttl_seconds is the effective one

Response 200:

{"token": "hvb....", "ttl_seconds": 300,
"read": {"addr": "http://openbao...", "path": "secret/data/projects/<project-short>/<name>"},
"note": "this token is short-lived and scoped to read only. It reads secret VALUES directly from the store until it expires; it is never stored here."}

Errors:

StatusMessage
404unknown method on a secret. The custom methods are POST /v1/projects/{projectID}/secrets/{name}:reveal and POST /v1/projects/{projectID}/secrets/{name}:issue-token (any other :verb)
501the configured secret store cannot issue scoped tokens

Bindings: how a secret reaches an agent

A binding (also called a secret map) is the rule "stored secret X arrives as environment variable Y on agent Z". Creating a binding records the rule; applying it delivers the values and rolls a new agent revision. Removing a binding revokes immediately — that asymmetry is deliberate.

Every binding response is the binding row plus "pinned_to", which is either "latest" or an exact version number as a string.

List every binding in a project

GET /v1/projects/{projectID}/secret-maps

Auth: member. Response 200: {"bindings": [...], "next_page_token": "..."}.

List one agent's bindings

GET /v1/projects/{projectID}/agents/{agent}/secret-maps

Auth: member. Response 200: {"agent": "research-buddy", "bindings": [...]}.

Create or repoint a binding

PUT /v1/projects/{projectID}/agents/{agent}/secret-maps/{envName}

Auth: member. Body capped at 8 KiB.

FieldTypeRequiredDefaultNotes
secret_namestringyesMust already exist in this project
versionintnonullOmitted or null tracks the newest version; a number pins an exact one
{"secret_name": "openai-api-key", "version": 3}

Response 200:

{"binding": {"env_name": "OPENAI_API_KEY", "secret_name": "openai-api-key", "pinned_to": "latest"},
"note": "recorded. The value reaches the agent when the bindings are applied - POST /v1/projects/{projectID}/agents/{agent}/secret-maps:apply."}

Checks run in this order, each with its own 400:

CheckMessage
The environment variable name must match ^[A-Za-z_][A-Za-z0-9_]*$ and must not be reserved<NAME> is reserved by the platform and cannot be bound. It is one of the variables the platform sets for the agent itself - inference endpoint and credential, sandbox address, messaging credential, process loader. ... Reserved names: <sorted list>.
The secret must existno secret called "<x>" in this project. Create it first - a binding to a secret that does not exist would fail at deploy time instead of now.
A pinned version must exist<name> has no version N (current is M)
A pinned version must not be destroyedversion N of <name> has been destroyed and cannot be bound

The reserved environment variable names are: MODEL_API_KEY, MODEL_BASE_URL, EMBED_BASE_URL, SANDBOX_URL, VALKEY_ADDR, QDRANT_URL, AGENT_NAME, AGENT_IMAGE, TOOL_SANDBOX, BAO_ADDR, BAO_TOKEN, NATS_URL, NATS_PASSWORD, KUBERNETES_SERVICE_HOST, KUBERNETES_SERVICE_PORT, PATH, HOME, LD_PRELOAD, PYTHONPATH. They are enforced on this path by both the API and a database rule.

Unbind

DELETE /v1/projects/{projectID}/agents/{agent}/secret-maps/{envName}

Auth: member. Unbinding is an immediate revocation: it deletes the binding row, removes the key from the agent's Kubernetes Secret, and rolls a new revision — all in one call. It deliberately still works while the secret store is sealed, because revoking access must never depend on the store being healthy.

Response 200:

{"agent": "research-buddy", "env_name": "OPENAI_API_KEY",
"deleted": true, "revoked": true, "revision_rolled": true, "note": "..."}

The note varies with what actually happened (whether the key was present, whether it was revoked, whether a revision rolled). When revoked is false, the note ends: ...treat this credential as exposed and rotate it.

Apply bindings

POST /v1/projects/{projectID}/agents/{agent}/secret-maps:apply

Auth: member. Reads every bound value (each read is audited), writes them into the agent's Kubernetes Secret all-or-nothing, then rolls a new revision.

Response 200:

{"agent": "research-buddy",
"applied": [{"env_name": "OPENAI_API_KEY", "secret_name": "openai-api-key",
"version": "latest", "applied": true}],
"revision_rolled": true,
"note": "applied. The agent picks these up on its next cold start."}

If any value cannot be read, the call returns 424 Failed Dependency with the same per-binding list — every row "applied": false, the failing rows carrying an error — and:

{"error": "nothing was written. At least one binding could not be read, and applying the rest would leave the agent half-configured."}

Applying an empty set changes nothing and says so.

Alpha: apply writes plaintext into the agent's Secret

Agent pods cannot yet authenticate to the secret store themselves, so :apply copies the values into the agent's Kubernetes Secret. Fetching at call time with crusoe.secret() avoids the copy.

Values written directly onto one agent

This is the older, simpler path: a value written straight into one agent's own Kubernetes Secret. No versions, no history, not shared between agents. Full details are in the Agents API; the shapes are repeated here for completeness.

These three routes are gated on the agent's owner (a project admin and the automation token also pass) and take ?project=<projectID> to disambiguate a name that exists in several projects. Bodies are capped at 1 MiB. Keys must match ^[A-Za-z_][A-Za-z0-9_]*$.

GET /v1/agents/{name}/secrets
PUT /v1/agents/{name}/secrets
PATCH /v1/agents/{name}/secrets
  • GET → 200 — {"agent": "research-buddy", "keys": ["DEMO_TOKEN", "MODEL_API_KEY"]}. Key names only, never values.
  • PUT — body is a flat map, for example {"DEMO_TOKEN": "abc123"}. It replaces the whole Secret; empty values are dropped. → 200 — {"agent": "...", "secrets_updated": true}.
  • PATCH — body {"set": {"KEY": "value"}, "remove": ["KEY"]}. Merges. An empty value in set becomes a removal. → 200 — {"agent": "...", "secrets_updated": true, "keys": [...]}.

Errors: 400 — invalid secret key (must be a valid environment variable name): <k>; 400 — nothing to do: provide 'set' and/or 'remove' (PATCH with an empty body).

Every write bumps the agent's secrets-generation annotation so a new revision rolls — Knative revisions are immutable, so a Secret change without a new revision would not reach anything running.

No reserved-name check on this path

Unlike bindings, the per-agent routes do not refuse reserved names server-side; the web console is the only guard. MODEL_API_KEY is a supported override here — setting it repoints that agent's model calls to your own key. Other reserved names are either ignored by the platform or actively harmful (LD_PRELOAD, PYTHONPATH, TOOL_SANDBOX). Prefer bindings.

The platformctl CLI touches only this path:

platformctl secrets set research-buddy DEMO_TOKEN=abc123
set 1 secret(s) for research-buddy

It calls PATCH (a merge, not a replace). There is no CLI for project secrets, bindings, or tokens — use the API or the console. See CLI: agents and functions.

Reading a secret at runtime

Every deployed workload receives CAI_API_URL, CAI_PROJECT_ID, and CAI_PROJECT_KEY (its workload key), plus CRUSOE_REQUEST_TIMEOUT_SECONDS (the token TTL it asks for, default 300, clamped to 3600). The harness helper uses them to mint a project-scoped read token and read the value directly from the store:

from crusoe_adk import secret

key = secret("openai-api-key") # newest version
pinned = secret("openai-api-key", 2) # version 2

One token is cached per invocation. If any of those environment variables is missing the helper raises SecretError — it fails closed rather than returning nothing. See Use secrets in workloads.

Audit actions

Every state change on this surface writes a row to the project audit log, readable by any project member at GET /v1/projects/{projectID}/audit:

ActionWritten by
project.secret.writeCreate or rotate
project.secret.deleteDelete
project.secret.revealReveal (fail-closed — no audit row, no value)
project.secret.issue-tokenEither token-minting route
project.secret.bindCreate or repoint a binding
project.secret.unbindUnbind
project.secret.applyApply

See Quotas and audit and the Platform API.

Status codes

CodeWhen
200Read, rotation, delete, bind, unbind, apply, reveal, token minted
201A secret's first version was stored
400Validation: name, value size, reserved environment variable, missing or destroyed version
401Missing or invalid credential
403You hold a grant but not the role (this action requires the project admin role), or a workload key strayed off its one route
404Not found, or not yours — deliberately identical. Also the reserved name, and unknown :verb suffixes
409Deleting a secret that bindings still point at
424Apply: at least one value could not be read, so nothing was written
429Per-principal rate limit
501The configured store cannot mint scoped tokens
503Store unconfigured, sealed, or unreachable; or reveal could not be audited