Use secrets in workloads
Storing a secret does nothing by itself — this page shows how values actually reach your agents: bindings and apply (the recommended path), call-time reads with short-lived tokens, and the older direct per-agent path.
Before you begin
- A stored project secret — see Manage secrets.
- A deployed agent and a bearer token with project membership.
export CAI_API="http://localhost:8081" # your agent-engine-api endpoint
export TOKEN="$CAI_TOKEN" # a bearer token
export PROJECT="your-project-id" # the project's ID
Bindings: the mental model
A binding is a rule: "stored secret openai-api-key arrives as environment variable OPENAI_API_KEY on agent research-buddy." Two things make bindings safe and predictable:
- Bind is not deliver. Recording a binding changes nothing running. Values reach the agent only when you apply — an explicit, all-or-nothing step.
- Version pin or latest. A binding either names an exact version (reproducible forever) or tracks the latest one (rotation reaches it on the next apply).
In the console, bindings live on the agent's own page, with a badge per binding showing whether it is actually delivered on the running agent or still pending, and an "Apply N pending bindings" button.
1. Bind a secret to an environment variable
curl -s -X PUT "$CAI_API/v1/projects/$PROJECT/agents/research-buddy/secret-maps/OPENAI_API_KEY" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"secret_name":"openai-api-key"}'
You should see:
{"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."}
To pin an exact version instead, send {"secret_name":"openai-api-key","version":2}.
Naming rules and refusals
- The environment variable name must match
^[A-Za-z_][A-Za-z0-9_]*$— letters, digits, and underscores, not starting with a digit. - The secret must already exist. Otherwise:
400withno 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 and not be destroyed:
400with<name> has no version N (current is M)orversion N of <name> has been destroyed and cannot be bound. - Reserved names cannot be bound. These are the variables the platform sets for the agent itself — the inference endpoint and credential, the sandbox address, the messaging credential, the process loader. Binding one returns
400naming the full list:
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
To see the bindings on an agent: GET /v1/projects/$PROJECT/agents/research-buddy/secret-maps. For the whole project's binding table: GET /v1/projects/$PROJECT/secret-maps.
2. Apply: deliver the values
Apply reads every bound value (each read is audited), writes them all-or-nothing into the agent's environment, and rolls a new revision:
curl -s -X POST "$CAI_API/v1/projects/$PROJECT/agents/research-buddy/secret-maps:apply" \
-H "Authorization: Bearer $TOKEN"
You should see:
{"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, nothing is written. You get status 424 (Failed Dependency), every row marked applied: false with a per-row error, and:
"error": "nothing was written. At least one binding could not be read, and applying the rest would leave the agent half-configured."
Why a revision rolls
Agent revisions are immutable snapshots — a running revision never changes its environment. So every delivery rolls a new revision, and the agent picks up the values on its next cold start. The response reports revision_rolled honestly; false means the roll could not happen (for example, the agent was never deployed). See Traffic and revisions.
3. Rotate end to end
# write a new version (same endpoint as create)
curl -s -X POST "$CAI_API/v1/projects/$PROJECT/secrets" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"openai-api-key","value":"sk-live-NEW"}'
# re-apply so latest-tracking bindings pick it up
curl -s -X POST "$CAI_API/v1/projects/$PROJECT/agents/research-buddy/secret-maps:apply" \
-H "Authorization: Bearer $TOKEN"
Version-pinned bindings ignore rotation until you repoint them (same PUT as binding, with the new version).
4. Unbind: immediate revocation
Unbinding is deliberately asymmetric with binding. It does not wait for an apply — it deletes the binding, removes the variable from the agent, and rolls a new revision immediately:
curl -s -X DELETE "$CAI_API/v1/projects/$PROJECT/agents/research-buddy/secret-maps/OPENAI_API_KEY" \
-H "Authorization: Bearer $TOKEN"
You should see:
{"agent":"research-buddy","env_name":"OPENAI_API_KEY","deleted":true,"revoked":true,"revision_rolled":true,"note":"..."}
revoked fieldIf the response says revoked: false, the value could not be removed from the running agent — the note ends with ...treat this credential as exposed and rotate it. Unbind intentionally works even while the secret store is sealed, so revocation is never blocked by store trouble.
Read secrets at call time (no env var at all)
A deployed workload carries three injected variables: CAI_API_URL, CAI_PROJECT_ID, and CAI_PROJECT_KEY — the last is a workload key (it starts with cai_wl_). A workload key can do exactly one management-plane thing: mint a short-lived, read-only token for its own project's secrets. Every other management call refuses it with 403 and 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.
In Python, the harness wraps the whole dance in one call:
from crusoe_adk import secret
key = secret("openai-api-key") # latest version
pinned = secret("openai-api-key", 2) # version 2, pinned
Under the hood, secret() mints a token, reads the value straight from the store, and caches one token per invocation. It fails closed with a SecretError if the injected variables are missing — it never silently returns nothing.
The token-minting endpoints
Any project member — or a workload key, for its own project — can mint:
# scoped to read exactly one secret
curl -s -X POST "$CAI_API/v1/projects/$PROJECT/secrets/openai-api-key:issue-token" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"ttl_seconds":300}'
# or scoped to read every secret in the project
curl -s -X POST "$CAI_API/v1/projects/$PROJECT/secrets:issue-token" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"ttl_seconds":300}'
You should see:
{"token":"hvb....","ttl_seconds":300,"read":{"addr":"http://openbao...","path":"secret/data/projects/acme/openai-api-key"},"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."}
ttl_secondsdefaults to 300 and is clamped to 3600 (silently — the returnedttl_secondsis the effective one; the store's own limits may clamp it further).- The token is presented directly to the store as the
X-Vault-Tokenheader onGETof the returnedread.addr+read.path(add?version=Nto pin). The token's scope and expiry are enforced by the store itself. - A store backend that cannot mint scoped tokens returns
501withthe configured secret store cannot issue scoped tokens.
The older path: direct per-agent values
You can write values straight onto one agent, skipping the project store. No versions, no history, no sharing — but quick. These routes require the agent's owner (or a project admin), and take ?project=$PROJECT as a query parameter.
platformctl secrets set research-buddy DEMO_TOKEN=abc123
You should see:
set 1 secret(s) for research-buddy
The CLI calls PATCH /v1/agents/research-buddy/secrets — a merge, so it never wipes values set elsewhere. The raw API offers three verbs:
| Call | Behavior |
|---|---|
GET /v1/agents/{name}/secrets | Returns key names only: {"agent":"research-buddy","keys":["DEMO_TOKEN"]}. Never values. |
PUT /v1/agents/{name}/secrets | Replaces the whole set with the flat map you send. Empty values are dropped. Body cap 1 MiB. |
PATCH /v1/agents/{name}/secrets | Merges: {"set": {"KEY":"value"}, "remove": ["KEY"]}. An empty value in set acts as a removal. Sending neither returns 400 with nothing to do: provide 'set' and/or 'remove'. |
Keys must be valid environment variable names (^[A-Za-z_][A-Za-z0-9_]*$); otherwise 400 with invalid secret key (must be a valid environment variable name): <k>. Every write rolls a new revision, like apply does.
The reserved-name check is enforced by the console but not by the server on this path. One override is officially supported here: setting MODEL_API_KEY repoints the agent's model billing to your own key. Avoid setting other platform names (PYTHONPATH, LD_PRELOAD, TOOL_SANDBOX, ...) — they would take effect and can break or weaken your agent.
Summary
| Action | Effect on the running agent | Audited value reads |
|---|---|---|
| Bind / repoint | None until apply | No |
| Apply | New revision with all bound values (all-or-nothing) | Yes, one per value |
| Rotate (write new version) | None until re-apply | No |
| Unbind | Immediate: value removed, revision rolled | No |
platformctl secrets set (PATCH) | Immediate: new revision | No |
secret() at call time | None (value never enters the environment) | Read enforced + expired by the store |
Next: Manage secrets · Agent secrets and environment · Secrets API reference