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>
| Caller | Can call |
|---|---|
| project member | List, create, and rotate secrets; read metadata and versions; list, create, repoint, and remove bindings; apply bindings; mint read tokens |
| project admin | Everything a member can, plus reveal a value and delete a secret |
| agent owner | The 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-tokenroute 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) andpage_token; responses carrynext_page_token. See the API overview. - Custom methods. Actions that are not plain CRUD use a GCP-style
:verbsuffix 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-credentialis 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 name…use GET/PUT/DELETE /v1/projects/{id}/crusoe-cloud instead.It is also omitted from listings.
Endpoints at a glance
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /v1/projects/{projectID}/secrets | member | List secrets (metadata only) |
| POST | /v1/projects/{projectID}/secrets | member | Create a secret, or store a new version of one |
| GET | /v1/projects/{projectID}/secrets/{name} | member | One secret: versions and who binds it |
| GET | /v1/projects/{projectID}/secrets/{name}/versions | member | Version history |
| DELETE | /v1/projects/{projectID}/secrets/{name} | admin | Destroy the secret and every version |
| POST | /v1/projects/{projectID}/secrets/{name}:reveal | admin | Return a value once, audited |
| POST | /v1/projects/{projectID}/secrets/{name}:issue-token | member | Read token scoped to one secret |
| POST | /v1/projects/{projectID}/secrets:issue-token | member or workload key | Read token scoped to the whole project |
| GET | /v1/projects/{projectID}/secret-maps | member | Every binding in the project |
| GET | /v1/projects/{projectID}/agents/{agent}/secret-maps | member | One agent's bindings |
| PUT | /v1/projects/{projectID}/agents/{agent}/secret-maps/{envName} | member | Create or repoint one binding |
| DELETE | /v1/projects/{projectID}/agents/{agent}/secret-maps/{envName} | member | Unbind — revokes immediately |
| POST | /v1/projects/{projectID}/agents/{agent}/secret-maps:apply | member | Deliver bound values to the agent |
| GET | /v1/agents/{name}/secrets | owner | Key names written directly onto one agent |
| PUT | /v1/agents/{name}/secrets | owner | Replace that agent's whole set |
| PATCH | /v1/agents/{name}/secrets | owner | Merge 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": "..."}]
}
| Field | Type | Meaning |
|---|---|---|
name | string | The secret's name within the project |
current_version | int | The newest stored version. Every write adds one; nothing is overwritten |
created_at, updated_at | string | RFC 3339 timestamps |
versions | array | Every version, with destroyed marking ones that can no longer be read |
used_by | array | The 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):
| Field | Type | Required | Rules |
|---|---|---|---|
name | string | yes | 1–63 characters: letters, digits, - or _, starting with a letter or digit |
value | string | yes | Non-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:
| Status | Message |
|---|---|
| 400 | secret name must be 1-63 characters of letters, digits, '-' or '_', starting with a letter or digit (got "...") |
| 400 | secret value must not be empty |
| 400 | secret value is N bytes, limit is 65536 |
| 503 | The store is unconfigured, sealed, or unreachable (see Conventions) |
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:
| Status | Message |
|---|---|
| 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. |
| 404 | The 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):
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
version | int | no | 0 | 0 or absent means the current version |
reason | string | no | — | Recorded 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."}
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:
| Field | Type | Required | Default | Rules |
|---|---|---|---|---|
ttl_seconds | int | no | 300 | Clamped 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:
| Status | Message |
|---|---|
| 404 | unknown 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) |
| 501 | the 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.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
secret_name | string | yes | — | Must already exist in this project |
version | int | no | null | Omitted 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:
| Check | Message |
|---|---|
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 exist | no 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 destroyed | version 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.
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 insetbecomes 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.
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:
| Action | Written by |
|---|---|
project.secret.write | Create or rotate |
project.secret.delete | Delete |
project.secret.reveal | Reveal (fail-closed — no audit row, no value) |
project.secret.issue-token | Either token-minting route |
project.secret.bind | Create or repoint a binding |
project.secret.unbind | Unbind |
project.secret.apply | Apply |
See Quotas and audit and the Platform API.
Status codes
| Code | When |
|---|---|
| 200 | Read, rotation, delete, bind, unbind, apply, reveal, token minted |
| 201 | A secret's first version was stored |
| 400 | Validation: name, value size, reserved environment variable, missing or destroyed version |
| 401 | Missing or invalid credential |
| 403 | You hold a grant but not the role (this action requires the project admin role), or a workload key strayed off its one route |
| 404 | Not found, or not yours — deliberately identical. Also the reserved name, and unknown :verb suffixes |
| 409 | Deleting a secret that bindings still point at |
| 424 | Apply: at least one value could not be read, so nothing was written |
| 429 | Per-principal rate limit |
| 501 | The configured store cannot mint scoped tokens |
| 503 | Store unconfigured, sealed, or unreachable; or reveal could not be audited |
Related pages
- Secrets Manager overview — what the service is and when to use it
- Manage secrets — the create, bind, apply, rotate workflow with output
- Use secrets in workloads — environment variables versus call-time reads
- Agent secrets and env — the per-agent view
- Agents API · Platform API · API overview
- Platform limits — every size cap, name rule, and TTL in one table