Skip to main content

Use secrets in workloads

This guide explains how to connect stored secrets to your deployed workloads using environment variable bindings, workload revisions, and runtime SDK calls.

Overview of secret delivery

Workloads integrate with Secrets Manager through two delivery models:

  1. Environment Variable Bindings: Map stored secret names to workload environment variable names (e.g., openai-api-keyOPENAI_API_KEY). Applying bindings triggers a revision roll that injects secret values securely.
  2. Runtime SDK Reads: Fetch secret values dynamically during application execution using short-lived access tokens.

Order of operations

A binding attaches to a workload that already exists, and three steps have to happen in this order:

  1. Deploy the workload. Binding to a name the project has never deployed answers a bare 404 not found: the API resolves the agent or function before it looks at the binding, and it reports "does not exist" and "not yours" identically.
  2. Bind. This records the mapping and nothing else.
  3. Apply. This is the step that reads each value, writes it onto the workload and rolls a new revision.

So the first revision always runs without the variable. The bound values are attached to the workload optionally, which means a key that is not there yet is silently absent rather than a deploy error — the workload starts normally and the variable simply is not in its environment.

That is sharpest in a function. The shim executes handler.py at import, so a handler that reads the variable at module top level dies before it ever serves a request:

# Crashloops on the first revision: the binding is not delivered yet.
import os
API_KEY = os.environ["OPENAI_API_KEY"] # KeyError at import

Read it inside the handler body instead, and the same function survives the gap between deploy and apply:

import os

def handle(event):
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
return {"error": "OPENAI_API_KEY is not bound yet"}
...

1. Bind a secret to a workload

A binding defines a mapping between a project secret and an environment variable name on a specific workload.

The variable name must match ^[A-Za-z_][A-Za-z0-9_]*$ — letters, digits and underscore, not starting with a digit. Nineteen names are reserved by the platform and are refused with 400:

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.

These are the variables the platform sets for the workload itself, so binding one would point its inference, its sandbox, its messaging or its process loader at a value the binder chooses. The check uppercases the name first, so model_api_key is refused too, and a database trigger enforces the same list underneath the API.

platformctl secrets bindings set research-buddy OPENAI_API_KEY --secret openai-api-key

To pin a specific secret version:

platformctl secrets bindings set research-buddy OPENAI_API_KEY --secret openai-api-key --version 2

List active bindings:

platformctl secrets bindings list --agent research-buddy

Output:

AGENT VARIABLE SECRET VERSION
research-buddy OPENAI_API_KEY openai-api-key latest

2. Apply bindings (Trigger Revision Roll)

Recording a binding saves the mapping configuration. Executing apply reads secret values, records an audit event, and rolls a new workload revision with the injected values.

platformctl secrets bindings apply research-buddy

Output:

VARIABLE SECRET VERSION APPLIED ERROR
OPENAI_API_KEY openai-api-key latest yes -

applied. The agent picks these up on its next cold start.

3. Unbind a secret

Unbinding immediately revokes environment variable access and rolls a clean workload revision.

platformctl secrets bindings delete research-buddy OPENAI_API_KEY

4. Runtime SDK reads (Call-Time Fetch)

To read a value at call time instead of injecting it into the environment, use the secret() helper. It is baked into the agent harness images, and it is not in a function image — see the next subsection for functions.

from crusoe_core import secret # or: from crusoe_adk import secret

# Latest version
api_key = secret("openai-api-key")

# Pin a KV v2 version (None or <= 0 reads latest)
pinned_key = secret("openai-api-key", 2)

crusoe_core is on every harness image; crusoe_adk is on the ADK harness only and re-exports the same module, so from crusoe_core import secret is the form that works on LangGraph and CrewAI too.

The helper POSTs to {CAI_API_URL}/v1/projects/{CAI_PROJECT_ID}/secrets:issue-token with the workload identity key (CAI_PROJECT_KEY) as the bearer, which mints a short-lived, read-only token, and then reads the value directly from the secret store with that token. Inside a request scope the token is minted once per invocation and dies with it; outside one, every call mints its own. If CAI_API_URL, CAI_PROJECT_ID or CAI_PROJECT_KEY is missing it raises SecretError rather than returning an empty string.

In a function, do it by hand

A function image is python:3.12.13-slim plus the shim — it carries neither crusoe_core nor crusoe_adk, so from crusoe_adk import secret raises ModuleNotFoundError at import and crashloops the revision. Adding crusoe_adk to requirements.txt fails the build instead: the package is on no index, and it has no setup.py on the harness image either — it is plain-copied onto the import path.

Either bind the secret to an environment variable (sections 1 and 2 above), or make the same two calls with the standard library:

import json
import os
import urllib.parse
import urllib.request


def read_secret(name, version=None):
api = os.environ["CAI_API_URL"].rstrip("/")
project = urllib.parse.quote(os.environ["CAI_PROJECT_ID"])

mint = urllib.request.Request(
f"{api}/v1/projects/{project}/secrets:issue-token",
data=json.dumps({"ttl_seconds": 300}).encode(),
headers={
"Authorization": "Bearer " + os.environ["CAI_PROJECT_KEY"],
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(mint, timeout=30) as resp:
token = json.load(resp)

addr = token["read"]["addr"].rstrip("/")
path = token["read"]["path"].strip("/")
url = f"{addr}/v1/{path}/{urllib.parse.quote(name)}"
if version and version > 0:
url += "?version=" + str(version)

read = urllib.request.Request(url, headers={"X-Vault-Token": token["token"]})
with urllib.request.urlopen(read, timeout=30) as resp:
return json.load(resp)["data"]["data"]["value"]


def handle(event):
api_key = read_secret("openai-api-key")
...

CAI_API_URL, CAI_PROJECT_ID and CAI_PROJECT_KEY are injected into a deployed workload by the platform — all three or none — so this code only works from inside one. ttl_seconds is optional and defaults to 300; the store clamps anything above the request-timeout cap. Call read_secret inside handle, not at module level — at import time the function is still starting up, and a failure there crashloops the revision rather than failing one request.


Delivery lifecycle summary

OperationWorkload ImpactAudit Trail
Bind SecretMapping recorded only. The value is not on the workload until Apply.Config updated.
Apply BindingsNew workload revision created with injected values.Secret reads audited during apply.
Rotate SecretNew version saved; active workloads retain running version until re-applied.New version logged.
Unbind SecretImmediate variable removal and fresh revision roll.Unbind logged.
SDK Read (secret())Fetched dynamically at runtime without environment variable storage.Short-lived token issued & audited.