Contents · 15 sections↓
- 01How do you manage secrets for AI agents?
- 02How agent secrets management works
- 03When to use scoped secrets (and when not to)
- 04What you need
- 05Step 1: Declare scoped secrets for a process
- 06Step 2: Read, expire, rotate and revoke
- 07Step 3: Keep secrets away from the model
- 08Step 4: Rotate over the API and watch an agent fetch a secret
- 09Step 5: Redact secret values from output
- 10The complete example
- 11Running it in production
- 12Common problems and fixes
- 13Limitations
- 14Frequently asked questions
- 15Next steps
AI agent secrets management is about one question: which credentials can each agent use, for how long, and who can see them? An agent that runs unattended calls billing APIs, ticket systems and databases. If every agent reads every key from one shared environment, a single prompt injection or a careless log line exposes all of them. This guide shows how to scope secrets per agent with Promptise Foundry, expire and rotate them, keep them out of prompts and logs, and audit every access.
How do you manage secrets for AI agents?
Give each agent only the credentials it needs, keep the values out of the model's context, and make every use visible. Load values from the environment when the agent starts, hold them in a per-agent store with an expiry, rotate without a restart, log each access by name, and wipe them on stop. In the Promptise agent runtime, a SecretScopeConfig on the process does this:
from promptise.runtime import ProcessConfig, SecretScopeConfig
config = ProcessConfig(
model="openai:gpt-5-mini",
instructions="Answer billing tickets.",
secrets=SecretScopeConfig(
enabled=True,
secrets={"billing_key": "${BILLING_API_KEY}"}, # read from the environment at start
default_ttl=8 * 3600, # expires after 8 hours
),
)Key takeaways:
The best secret is one the model never sees. Put tool credentials in the MCP server or its connection spec, not in the conversation.
Scope per agent. Each process gets its own encrypted, in-memory store with only its own secrets.
Expire and rotate. TTLs bound how long a leaked value works, and the API rotates a value at once.
Audit by name. Every read, rotation, expiry and revocation is journaled without the value.
Redaction is your job. The runtime gives you sanitize_text(), but doesn't apply it to replies or logs for you.
[02]
How agent secrets management works
Tools need credentials to call APIs. The model almost never does: it decides which tool to call, and the tool carries the key. Problems start when a key travels through the model, because anything the model sees can end up in a reply, a log, a trace or a tool call to the wrong place. The secrets in prompts guide explains why system prompts leak.
secret manager / platform (Vault, Kubernetes, Docker, CI)
│ injects environment variables
▼
process start ── resolve ${VAR} ──▶ SecretScope (per process, in memory, encrypted)
│ │ get(name) → value, logged "access"
│ │ TTL passed → None, logged "expired"
│ │ rotate() → new value, logged "rotation"
│ │ stop / DELETE → zero-filled, logged "revoke_all"
│ │
│ open mode only: get_secret tool ──▶ value enters the model's context
▼
MCP server specs (bearer_token, headers, env) ──▶ tool credentials the model never seesThe Promptise SecretScope is the per-process store. At start it reads each configured secret, a literal or an ${ENV_VAR} reference, and keeps it encrypted in memory with a key that exists only in that process. A secret can expire after a time-to-live (TTL). Every read is journaled as a secret_access entry with the name and action, never the value. On stop, the scope overwrites and deletes every value.
The scope itself never writes values into the system prompt, context state, status or journal. The model reaches a scoped value only through the get_secret meta-tool, which exists only in open mode. MCP server credentials are a separate path, and the model never sees them.
[03]
When to use scoped secrets (and when not to)
Situation | Use | Why |
|---|---|---|
A tool needs an API key | The MCP server's own config or the server spec | The model never handles the key |
Several agents on one host need different keys | SecretScopeConfig per process | Each agent sees only its own names |
A credential should stop working after a shift or a job | default_ttl / ttls | Expiry limits a leaked value's lifetime |
You rotate keys on a schedule | The rotate route of the API | New value at once, no restart |
An open-mode agent must pass a token to a tool it built itself | get_secret | The only way the model reads a scoped value; treat it as exposure |
Secrets for a single short script | Plain environment variables | A scope adds little when one agent runs once |
[04]
What you need
Python 3.10 or later and Promptise Foundry 1.4.3 or later.
An OpenAI API key in OPENAI_API_KEY for step 4, which makes one gpt-5-mini call. Steps 2 and 3 need no key.
About 15 minutes.
pip install "promptise>=1.4.3"[05]
Step 1: Declare scoped secrets for a process
SecretScopeConfig has five fields:
Field | Default | Effect |
|---|---|---|
enabled | False | Creates the scope (and the get_secret tool in open mode) |
secrets | | Name to value: a literal, or ${ENV_VAR} as the whole string |
default_ttl | None | Seconds until each secret expires; None means never |
ttls | | Per-name TTL overrides in seconds |
revoke_on_stop | True | Documented as "revoke on stop"; in 1.4.3 every stop revokes, whatever its value |
An ${ENV_VAR} reference must be the entire value, and it is resolved when the process starts. A missing variable raises KeyError and the process moves to FAILED, so a deploy without its secrets fails loudly instead of running with blanks. Anything else is kept as literal text, including "Bearer ${TOKEN}"; store the bare token and add the prefix in code.
The same block works in a .agent manifest:
name: support-agent
model: openai:gpt-5-mini
instructions: Answer support tickets.
secrets:
enabled: true
secrets:
tickets_token: "${TICKETS_TOKEN}"
default_ttl: 28800Manifests differ in one way: the loader resolves every ${VAR} in the file at load time, including ${VAR:-default} and references inside longer strings. The process config then holds the plain value, and a missing variable fails with EnvVarNotFoundError. Never commit literal values to a manifest.
[06]
Step 2: Read, expire, rotate and revoke
SecretScope works on its own, so you can see each operation without a process or a model:
"""SecretScope on its own: resolve, read, expire, rotate, redact, revoke, audit. No model, no API key."""
import asyncio
import logging
import os
from promptise.runtime import InMemoryJournal, SecretScope, SecretScopeConfig
logging.disable(logging.WARNING)
# In production these come from your deployment (a secret manager, Kubernetes, Docker secrets).
os.environ["BILLING_API_KEY"] = "sk-billing-7f3a91"
os.environ["TICKETS_TOKEN"] = "tk-0c55e2"
async def main():
journal = InMemoryJournal()
scope = SecretScope(
SecretScopeConfig(
enabled=True,
secrets={
"billing_key": "${BILLING_API_KEY}", # read from the environment
"tickets_token": "${TICKETS_TOKEN}",
},
default_ttl=3600, # one hour for every secret ...
ttls={"tickets_token": 1.0}, # ... except this one: one second
),
process_id="billing-agent",
journal=journal,
)
await scope.resolve_initial()
print("names: ", scope.list_names())
print("billing_key: ", scope.get("billing_key"))
await asyncio.sleep(1.2)
print("tickets_token after its TTL:", scope.get("tickets_token"))
print("names now: ", scope.list_names())
await scope.rotate("billing_key", "sk-billing-NEW-22b0")
print("after rotate: ", scope.get("billing_key"))
reply = "Charged with key sk-billing-NEW-22b0, old key sk-billing-7f3a91."
print("sanitized: ", scope.sanitize_text(reply))
try:
await SecretScope(SecretScopeConfig(enabled=True, secrets={"x": "${NOT_SET}"}), "p").resolve_initial()
except KeyError as exc:
print("missing env: ", exc)
short = SecretScope(SecretScopeConfig(enabled=True, secrets={
"token": "${TICKETS_TOKEN}",
"header": "Bearer ${TICKETS_TOKEN}", # not a reference: kept as literal text
}, default_ttl=1.0), "p")
await short.resolve_initial()
print("header: ", short.get("header"))
await asyncio.sleep(0.6)
await short.rotate("token", "tk-rotated-91aa") # the expiry is not reset
await asyncio.sleep(0.6)
print("rotated token after the first TTL:", short.get("token"))
await scope.revoke_all()
print("after revoke: ", scope.list_names(), scope.get("billing_key"))
await asyncio.sleep(0) # access-log writes are fire-and-forget tasks
print("\naccess log (names only, never values):")
for entry in await journal.read("billing-agent", entry_type="secret_access"):
print(" ", entry.data)
asyncio.run(main())names: ['billing_key', 'tickets_token']
billing_key: sk-billing-7f3a91
tickets_token after its TTL: None
names now: ['billing_key']
after rotate: sk-billing-NEW-22b0
sanitized: Charged with key [REDACTED], old key sk-billing-7f3a91.
missing env: "Environment variable 'NOT_SET' not found (required by secret config)"
header: Bearer ${TICKETS_TOKEN}
rotated token after the first TTL: None
after revoke: [] None
access log (names only, never values):
{'action': 'access', 'secret_name': 'billing_key'}
{'action': 'expired', 'secret_name': 'tickets_token'}
{'action': 'rotation', 'secret_name': 'billing_key'}
{'action': 'access', 'secret_name': 'billing_key'}
{'action': 'revoke_all', 'secret_name': '*'}What each line shows:
Expiry is enforced on read. After its one-second TTL, tickets_token returns None and disappears from the name list, and the journal records expired. Code that calls get() must handle None.
Rotation is immediate. The next get() returns the new value, and the journal records rotation.
Rotation keeps the original expiry. The rotated token still expired at the end of its first TTL. TTLs count from process start, so a rotation can't extend a secret's life. A restart does: it resolves the environment again and starts fresh TTLs.
Redaction covers current values only. sanitize_text() replaced the new billing key but not the old one, which the scope no longer holds.
Literals stay literal. "Bearer ${TICKETS_TOKEN}" was not resolved.
Revocation empties the scope and is logged as revoke_all with the name *.
[07]
Step 3: Keep secrets away from the model
Most credentials should never enter the scope, because the model doesn't need them. The tool needs the key; the agent only needs the tool. Give the credential to the MCP server, in its own environment or in the spec the agent connects with:
import os
from promptise.config import HTTPServerSpec, StdioServerSpec
from promptise.runtime import ProcessConfig
config = ProcessConfig(
model="openai:gpt-5-mini",
instructions="Answer billing tickets.",
servers={
# Sent as an Authorization header on every request; stored as a SecretStr.
"billing": HTTPServerSpec(
url="https://billing.internal.example/mcp",
bearer_token=os.environ["BILLING_MCP_TOKEN"],
),
# A local server reads its key from its own environment.
"tickets": StdioServerSpec(
command="python",
args=["tickets_server.py"],
env={"TICKETS_API_KEY": os.environ["TICKETS_API_KEY"]},
),
},
)The MCP client sends bearer_token as a header, and the model sees only the tools and their results. A stdio server starts with only the environment you pass in env. This path doesn't use SecretScope, so it has no TTL or access log; the server's own logs cover that. For servers that accept short-lived tokens tied to the agent's identity instead of static keys, see AI agent identity and authentication.
Use SecretScope for the rest, such as open-mode agents that build their own tools.
[08]
Step 4: Rotate over the API and watch an agent fetch a secret
This script runs a process in open mode with one scoped secret and a full journal. It lists and rotates the secret over the management API, lets the agent fetch it with get_secret in one real run, then reads the journal:
"""A process with scoped secrets: rotate over the API, let an open-mode agent fetch one, read the audit trail."""
import asyncio
import logging
import os
import secrets
import httpx
from promptise.runtime import (
AgentRuntime,
ExecutionMode,
JournalConfig,
OrchestrationAPI,
OrchestrationClient,
ProcessConfig,
SecretScopeConfig,
TriggerEvent,
)
logging.disable(logging.WARNING)
os.environ["TICKETS_TOKEN"] = "tk-0c55e2" # set by your deployment in production
async def main():
runtime = AgentRuntime()
process = await runtime.add_process("support-agent", ProcessConfig(
model="openai:gpt-5-mini",
instructions=(
"You are a support agent. When a run asks for it, fetch the tickets_token "
"secret with get_secret and report only how many characters it has. "
"Never write a secret value in your reply."
),
execution_mode=ExecutionMode.OPEN, # adds the get_secret meta-tool
journal=JournalConfig(level="full", backend="memory"),
secrets=SecretScopeConfig(
enabled=True,
secrets={"tickets_token": "${TICKETS_TOKEN}"},
default_ttl=8 * 3600, # one working day; restart or rotate before it ends
),
))
token = secrets.token_urlsafe(24)
api = OrchestrationAPI(runtime, port=9100, auth_token=token)
await api.start()
await process.start()
async with OrchestrationClient("http://127.0.0.1:9100", token=token) as client:
print("secrets: ", await client.list_secrets("support-agent"))
print("rotate: ", await client.rotate_secret("support-agent", "tickets_token", "tk-rotated-4be1"))
try:
await client.rotate_secret("support-agent", "tickets-token", "typo")
except httpx.HTTPStatusError as exc:
print("bad name: ", exc.response.status_code, exc.response.json()["error"])
await process.inject(TriggerEvent(
trigger_id="ops", trigger_type="manual", payload={"task": "check the tickets token"}))
while process.status()["invocation_count"] == 0:
await asyncio.sleep(0.2)
print("\njournal after one run:")
for entry in await client.get_journal("support-agent", limit=20):
if entry["entry_type"] in ("secret_access", "tool_call", "tool_result", "invocation_result"):
data = entry["data"]
shown = data.get("response") or data.get("result") or data
print(f" {entry['entry_type']:<17} {shown}")
print("\naccess log:")
for entry in await client.get_journal("support-agent", entry_type="secret_access"):
print(f" {entry['timestamp']} {entry['data']['action']:<9} {entry['data']['secret_name']}")
print("\nstatus: ", {k: v for k, v in (await client.get_process("support-agent")).items()
if k in ("state", "active_secrets")})
await process.stop()
print("stopped: ", {k: v for k, v in (await client.get_process("support-agent")).items()
if k in ("state", "active_secrets")})
await api.stop()
asyncio.run(main())secrets: [{'name': 'tickets_token', 'active': True}]
rotate: {'name': 'support-agent', 'secret_rotated': 'tickets_token'}
bad name: 404 {'code': 'PROCESS_NOT_FOUND', 'message': "Process 'support-agent' not found"}
journal after one run:
secret_access {'action': 'rotation', 'secret_name': 'tickets_token'}
tool_call {'tool': 'get_secret', 'args': {'name': 'tickets_token'}}
secret_access {'action': 'access', 'secret_name': 'tickets_token'}
tool_result tk-rotated-4be1
invocation_result 15 characters.
access log:
2026-10-19 17:59:19.092567+00:00 rotation tickets_token
2026-10-19 17:59:21.865077+00:00 access tickets_token
status: {'state': 'running', 'active_secrets': 1}
stopped: {'state': 'stopped', 'active_secrets': 0}The API never returns values. GET /api/v1/processes/{name}/secrets lists names and whether each is active. PATCH /api/v1/processes/{name}/secrets/{secret} with {"value": "..."} rotates one at once, and DELETE /api/v1/processes/{name}/secrets revokes all of them while the process keeps running. Rotating a name that doesn't exist answers 404 with the misleading code PROCESS_NOT_FOUND, as the "bad name" line shows. The client's rotate_secret() accepts a ttl argument, but the server ignores it. The API itself is covered in the AI agent control API.
`get_secret` puts the value in the model's context. The agent called the tool, the scope logged an access, and the tool returned the rotated token as its result. The model followed its instructions and replied with only the length. But the value was in its context for that run, and with JournalConfig(level="full") the runtime journals every tool result, so tk-rotated-4be1 is now in the journal in plain text. With the default file backend, that is a file on disk.
Treat every get_secret call as exposure:
Prefer tool-side credentials from step 3, so no tool call needs the value.
Don't combine `get_secret` with `level="full"` journals, or write them to storage that you protect like the secrets themselves.
Keep TTLs short for any secret an open-mode agent can read, and rotate after incidents.
Remember the reply. If an injection makes the agent repeat the value, it lands in the conversation buffer, the invocation_result entry and checkpoints.
The access log is a journal filter. get_journal(name, entry_type="secret_access") returns one timestamped entry per rotation and read, by name only.
Stopping revokes. After stop(), active_secrets dropped to 0. The next start() resolves the environment again.
[09]
Step 5: Redact secret values from output
sanitize_text(text) replaces every value the scope currently holds with [REDACTED]. The runtime doesn't call it on replies, journal entries or events, so call it wherever text leaves your control, such as before you forward a reply or ship logs. It is an exact string match, so three cases slip through:
Old values. After a rotation, the previous value is no longer in the scope, as step 2 showed.
Changed values. A key split across lines, encoded in base64 or partly masked isn't matched.
Secrets the scope doesn't hold, such as a server spec's token or a key a tool returned.
As a second layer, the runtime's guardrails option can scan output for credential-shaped strings; see AI agent guardrails.
[10]
The complete example
The project has two files: scope_basics.py from step 2 and secret_process.py from step 4.
secrets-demo/
├── scope_basics.py # SecretScope alone: resolve, expire, rotate, redact, revoke, audit
└── secret_process.py # process + API: list, rotate, get_secret in open mode, journalpip install "promptise>=1.4.3"
python scope_basics.py
export OPENAI_API_KEY=sk-...
python secret_process.py[11]
Running it in production
Inject values from a secret manager
Promptise reads secrets from environment variables and has no built-in client for HashiCorp Vault or AWS Secrets Manager. Let the platform deliver them: a Kubernetes Secret or the External Secrets Operator mapped into the container environment, Vault Agent writing an env file, or Docker secrets loaded by your entrypoint. For dynamic credentials that change while the agent runs, have a small rotation job fetch the new value and call the rotate route of the API. For container setups, see deploy an AI agent with Docker.
Size TTLs to restarts
A TTL can't be extended at runtime: rotation keeps the original expiry, and an expired secret stays gone until a restart reads the environment again. A restart within the same OS process re-reads the same environment, so values your platform changed arrive only with a new container or pod. Pick a TTL longer than your redeploy cycle, or plan a restart before it ends.
Protect the journal and the API
The access log lives in the process journal, by default files under .promptise/journal, which also hold tool results at level="full". Restrict the directory like a credentials store. The management API can rotate and revoke secrets, so give it a strong bearer token and bind it to localhost or a private network.
One process, one set of keys
Split work across processes by trust level: the agent that reads web pages shouldn't hold the payments key. Running multiple AI agents shows how one runtime hosts several processes, each with its own config. Combine scoping with approvals for actions that spend money, as in human-in-the-loop approval.
[12]
Common problems and fixes
Symptom | Cause | Fix |
|---|---|---|
Process fails at start with | A ${VAR} reference has no value in the environment | Inject the variable, or fix its name |
A header contains the literal text ${TOKEN} | ${VAR} must be the whole value in SecretScopeConfig | Store the bare token, add Bearer in code |
get() returns None mid-run | The TTL passed; rotation doesn't reset it | Restart the process, or use a longer TTL |
Rotating over the API answers 404 PROCESS_NOT_FOUND | The secret name is wrong or the secret expired | Check GET .../secrets; the process is fine |
revoke_on_stop=False still wipes secrets | Every stop revokes in 1.4.3 | Expect a fresh resolve on every start |
A secret value appears in the journal | get_secret result journaled at level="full", or the agent repeated it | Use tool-side credentials, or a lower journal level |
sanitize_text() misses a key | The key was rotated away, transformed, or never in the scope | Redact known patterns too, and keep keys away from the model |
The ttl argument of rotate_secret() has no effect | The server ignores it | Set TTLs in the config |
[13]
Limitations
Environment variables and literals only. No native secret-manager integration and no automatic refresh; rotation is a push through the API.
Static TTLs. They start when the process starts and can't be extended without a restart.
No automatic redaction. sanitize_text() exists, but you call it, and it only matches current values exactly.
`get_secret` exposes values to the model. There is no "use but don't reveal" mode. Strict-mode tools can't read the scope; tool credentials go in server specs.
Memory hygiene is best effort. Values are encrypted in memory and zero-filled on revoke, but decrypted copies may linger until garbage collection.
Manifest values are resolved at load. The process config then holds them in plain text for the process's life.
[14]
Frequently asked questions
How should AI agents store API keys?
Keep API keys in the tool, not the agent. An MCP server holds its own key or receives it through the connection spec, so the model only sees tool names and results. For credentials the agent's process must handle, use a per-agent scope that loads values from the environment at start, keeps them in memory, expires them and logs every access by name.
Can an LLM see the secrets I give my agent?
Only if they reach its context. Values in a Promptise SecretScope never go into the system prompt, state or logs on their own. An open-mode agent can read one with the get_secret tool, and then the value is in that run's context like any tool result. An MCP server's bearer token never reaches the model.
How do I rotate an AI agent's credentials without a restart?
Send the new value to PATCH /api/v1/processes/{name}/secrets/{secret} with a JSON body {"value": "..."}, or call rotate_secret() on the Python client. The next read returns the new value. Rotation keeps the secret's original expiry, so a secret close to its TTL still expires on time; restart the process to reload the environment and start fresh TTLs.
What happens when an agent secret expires?
The next read returns None, the secret disappears from the name list, and the journal records an expired entry. A tool or get_secret call that needs it fails with "not found or expired". The value doesn't come back by itself: restart the process so it resolves the environment again, or choose a TTL that outlasts your restart cycle.
How do I use HashiCorp Vault with an AI agent?
Let Vault deliver values as environment variables, for example through Vault Agent templates or the External Secrets Operator on Kubernetes, and reference them as ${VAR} in the secret config. For dynamic credentials, run a job that reads the new lease from Vault and pushes it to the agent with the rotate route of the management API. Promptise has no built-in Vault client.
How do I audit which secrets an agent used?
Every read, rotation, expiry and revocation is a secret_access entry in the process journal, with the action and the secret name but never the value. Read them with GET /api/v1/processes/{name}/journal?type=secret_access or the client's get_journal(name, entry_type="secret_access"). The journal is on by default, as files under .promptise/journal.
[15]
Next steps
Why secrets in prompts leak: secrets in prompts.
Replace static keys with workload identity: AI agent identity and authentication.
Rotate and revoke over HTTP: the AI agent control API.
Understand open mode before you enable get_secret: self-modifying AI agents.
Secure the MCP servers that hold the keys: MCP security best practices.
Reference: secret scoping and the orchestration API in the Promptise docs.