Guide · Platform / operations engineer
How to set API keys after upgrading to Rasa 3.21
After a Rasa upgrade, write each model API key as an environment variable reference, and make sure the variable is set.
${NAME}
what the provider call received as the key when the variable was unset
2 releases
3.20.0 and 3.21 both accept the new form in integrations.yml
Key takeaways (3)
- Rasa no longer accepts the old key-name setting. Point each API key at an environment variable instead.
- The new form also worked before the upgrade in our tests, so you can change it first.
- Rasa checks how the key is written, not that the variable is set. A missing variable can reach the provider call as text.
You upgraded a Rasa project to 3.21, and now it fails validation. Every model
group that names its key with api_key_env stops with this error:
2026-10-02 18:39:53 ERROR rasa.mantle.validation - {"event_info": "Model group at index 0 in 'integrations.yml' has invalid credentials: Model group 'orchestrator' uses 'api_key_env', which is no longer supported. Replace it with 'api_key: ${ENV_VAR_NAME}'.", "docs_url": null, "event": "mantle.validation.config.invalid_model_group_credentials", "level": "error"}
That is one line of output from Rasa’s validator on Rasa Pro 3.21.0.dev5, unedited. When Rasa builds the model client, it refuses with a similar message.
The fix is to change each old line to this form, without quotes, and keep the same variable name:
api_key: ${ANTHROPIC_API_KEY}
The error does not tell you one thing. Rasa checks how the key is written,
not whether the variable is set. In our tests, when the variable was missing,
the model client passed the text ${ANTHROPIC_API_KEY} to the provider call
as the key, with or without quotes. Without quotes, Rasa’s server start-up
should catch it first. With quotes, none of the Rasa steps we ran caught it. So write the reference without
quotes, and make sure the variable exists wherever the agent runs.
The new form also worked on Rasa 3.20.0, so you can make the change before you upgrade. If you roll back, you do not have to change it again.
Why the old setting stopped working
This guide is about Rasa Mantle, the agent runtime in the rasa-pro
package. A model group is a named block of model settings: the provider, the
model and its API key. A Mantle agent’s main model group lives in its
integrations file, integrations.yml. Other Rasa features can define model
groups in the endpoints file, endpoints.yml.
On Rasa 3.20.0 the integrations file accepted two ways to name the key:
# Rasa looked up the variable when it built the model client
api_key_env: ANTHROPIC_API_KEY
# The model client fills in the variable each time it calls the provider
api_key: ${ANTHROPIC_API_KEY}
Rasa 3.21 keeps the second form and drops the first. It now checks every model group when you validate or train the project, and again when it builds the model client. The check has two rules:
- The old setting is no longer accepted.
- Each secret value, such as the API key or the AWS credentials, must be a plain reference: a dollar sign, then the variable name in braces. A literal key, a name without braces or a reference with a default value does not pass.
Rasa applies the same check to model groups in the endpoints file.
Why the placeholder stays in the config
If you load your config and look at the API key, you see the reference, not your key. That is on purpose. Rasa’s YAML loader leaves secret values as placeholders, so a loaded config that is logged or passed around does not carry the key. The model client swaps in the real value at the moment it calls the provider.

- Rasa 3.21 checks how each secret is written and rejects the old setting. It does not check that the variable is set.
- The loader and the model client both keep the placeholder, so printing them shows no key.
- The value is read from the environment only when the client calls the provider. If the variable is missing, the placeholder text is sent instead.
We tested this with a stub in place of the provider call that records what it receives. On 3.20.0 and on 3.21, the stub received the real key when the variable was set. With the one function that fills in the value switched off, it received the placeholder instead. So the value is filled in at the call.
If you followed our earlier advice
In September our companion repository told readers that the reference form had never worked on 3.20. It moved its examples to the old setting. That advice was wrong. Its test stopped at the loader, saw the placeholder, and missed the step at the call that fills in the key.
The repository’s migration notes have since been corrected. If you copied the old setting from those examples, the fix below is the one you need.
How to change your configuration
Do this in your own project, in every integrations file and endpoints file that defines a model group.
Find every line to change
From your project folder, list the lines that still use the old setting:
grep -rn "api_key_env" --include="*.yml" .Then list the secret values that are not a plain reference. The 3.21 check
covers these keys: api_key, aws_access_key_id, aws_secret_access_key,
aws_session_token, public_key, private_key, client_id,
client_secret, token_url and secret. This command prints each of them
whose value is quoted, has no braces, has a default or is a literal:
grep -rnE "^\s*(- )?(api_key|aws_access_key_id|aws_secret_access_key|aws_session_token|public_key|private_key|client_id|client_secret|token_url|secret):" --include="*.yml" . | grep -vE ':\s*\$\{[A-Za-z0-9_]+\}\s*$'Each match inside a model group is a line to change. A match outside a model group, such as a channel setting, is not covered by this check.
Replace each line with the new form
Keep the variable name. Only the key and its spelling change. Here is the model group from this site’s quickstart agent in the old form, as used in the validation run above:
# Before (rejected by Rasa 3.21)
model_groups:
- id: orchestrator
models:
- provider: anthropic
model: claude-haiku-4-5
api_key_env: ANTHROPIC_API_KEY
temperature: 0.0The new form is the same in both files. Pick the file you are changing:
Integrations file
This excerpt is from the quickstart agent’s examples/quickstart/integrations.yml:
model_groups:
- id: orchestrator
models:
- provider: anthropic
model: claude-haiku-4-5
api_key: ${ANTHROPIC_API_KEY}
temperature: 0.0Endpoints file
This excerpt is from a voice agent’s endpoints file in the companion repository:
model_groups:
- id: openai-llm
models:
- provider: openai
model: gpt-5.2
api_key: ${OPENAI_API_KEY}
temperature: 0.0Change the other matches from the second command the same way. Remove the quotes, add the braces, drop the default, and move a literal key into a variable.
Set the variable where the agent runs
The variable name has not changed. If the old setting worked before, the variable is already set. On a new machine, set it in the shell that runs the agent. Replace the example value with your own key:
- macOS
export ANTHROPIC_API_KEY=sk-ant-your-key- Linux
export ANTHROPIC_API_KEY=sk-ant-your-key- Windows
$env:ANTHROPIC_API_KEY='sk-ant-your-key'
The Rasa command line also reads a file named .env in your project folder.
It never overwrites a variable that is already set.
In a container or a CI job, the variable must be set in that container’s or
job’s own environment, for example from your secret store. A .env file on
your laptop is not there unless you copy it in.
Which forms Rasa accepts
For each form below, we wrote a real integrations file into a scratch project. We loaded it with Rasa’s own readers: the one that sets up channels, and the one that builds the model client. Then we recorded the key that the provider call received. A stub replaced the provider call, so no request left the machine. We ran each form with the variable set and with it unset.
| Line in the model group | Rasa 3.20.0, variable set | Rasa 3.21, variable set | Variable unset |
|---|---|---|---|
api_key: ${NAME} | Real key sent | Real key sent | Client sends the text ${NAME}. Channel set-up stops and names the variable |
api_key: "${NAME}" | Real key sent | Real key sent | Client passes the text ${NAME}. No step we ran stopped it |
api_key_env: NAME | Real key sent | Rejected | 3.20.0: client not built, variable reported missing. 3.21: rejected |
api_key: $NAME | Real key sent | Rejected | 3.20.0: client sends the text $NAME. 3.21: rejected |
api_key: ${NAME:-default} | Text sent; channel set-up stops | Rejected; channel set-up stops | Same as when set |
| A literal key | Sent as written | Rejected | Not affected |
Keep only the first form. The 3.21 column is from 3.21.0.dev5, and 3.21.0.dev3 gave the same results.
For how the old setting behaved in the endpoints file on 3.20.0, see How to set the model API key in Rasa endpoints.yml.
How to check the fix
Run Rasa’s validator from your project folder:
- Any system
rasa data validate
On a Mantle project, this runs the same project checks as training. It needs
your Rasa licence, so a CI job that runs it needs RASA_LICENSE set as well.
With the old line, it printed the error at the top of this guide and exited
with status 1. With the new line, it printed this line, unedited:
2026-10-02 18:40:17 INFO rasa.cli.data - {"event_info": "Mantle project validation completed with no errors.", "event": "cli.data.validate.mantle_project_valid", "level": "info"}
The validator checks how the key is written. It does not check that the variable is set, or that the provider accepts the key. So also start the agent once, in the environment it will run in, and send it one message.
That is the check we ran. On 3.21.0.dev3, the quickstart agent with the new form ran its scripted acceptance conversations against Anthropic and passed. The same setup also passed on 3.20.0.
Trade-offs
Keys must come from environment variables. Rasa 3.21 rejects a literal key in a model group. That keeps keys out of your repository, but a quick local test also needs the variable set.
You will not see the key in the loaded config. The config and the built model client both hold the placeholder. That protects the key, but printing the config does not tell you what the provider receives. To check that, test at the provider call, as we did above. The test is folded below.
Show the offline test of each form
This script writes each form into a real integrations file in its own
scratch folder. It loads the folder with Rasa’s channel reader and with the
reader that builds the model client, then calls the client. Before that, it
replaces the provider call with a stub that records its arguments and stops,
so no request leaves the machine. PROBE_KEY is a fake value. The script sets
it for the first six runs and removes it for the last six. It prints a
recorded key only if it is one of its own fake values or starts with a dollar
sign.
The script, as run:
import os, sys, asyncio, shutil, pathlib, hashlib, base64, glob
from importlib.metadata import version, files
print("rasa-pro", version("rasa-pro"))
import rasa.shared.providers.llm._base_litellm_client as base
from rasa.mantle.llm.client import LLMClient
from rasa.mantle.model_archive.bootstrap import resolve_integration_llm_config
from rasa.mantle.config.channels import load_integration_channels
seen = {}
async def fake_acompletion(**kw):
seen.update(kw); raise RuntimeError("stopped before network")
base.acompletion = fake_acompletion
SAFE = {"sk-probe-real-value", "sk-literal-probe"}
def show(v):
return v if (v is None or v in SAFE or str(v).startswith("$")) else "<other value, redacted>"
TEMPLATE = """llm:
model_group: orchestrator
model_groups:
- id: orchestrator
models:
- provider: anthropic
model: claude-haiku-4-5
{line}
temperature: 0.0
"""
FORMS = ["api_key: ${PROBE_KEY}", 'api_key: "${PROBE_KEY}"', "api_key_env: PROBE_KEY",
"api_key: $PROBE_KEY", "api_key: ${PROBE_KEY:-fallback}", "api_key: sk-literal-probe"]
work = pathlib.Path(sys.argv[1])
for state in ["set", "unset"]:
for i, line in enumerate(FORMS):
root = work / f"{state}-{i}"
shutil.rmtree(root, ignore_errors=True); root.mkdir(parents=True)
(root / "integrations.yml").write_text(TEMPLATE.format(line=line))
if state == "set": os.environ["PROBE_KEY"] = "sk-probe-real-value"
else: os.environ.pop("PROBE_KEY", None)
out = []
try:
load_integration_channels(root); out.append("channels loader: ok")
except Exception as e:
out.append(f"channels loader raised {type(e).__name__}")
try:
cfg = resolve_integration_llm_config(root)
class B: llm = cfg
c = LLMClient.from_bootstrap(B())
seen.clear()
try: asyncio.run(c._client.acompletion("hi"))
except Exception: pass
out.append(f"model client built; stub received api_key={show(seen.get('api_key'))!r}")
except Exception as e:
out.append(f"model client path raised {type(e).__name__}")
print(f"PROBE_KEY {state:5} | {line!r:36} | " + " | ".join(out))
import rasa.shared.utils.yaml as y
f = pathlib.Path(y.__file__)
digest = "sha256=" + base64.urlsafe_b64encode(hashlib.sha256(f.read_bytes()).digest()).rstrip(b"=").decode()
rec = [l for l in open(glob.glob(str(f.parents[3] / "rasa_pro-*.dist-info/RECORD"))[0]) if l.startswith("rasa/shared/utils/yaml.py,")][0].strip()
print("rasa/shared/utils/yaml.py file", digest, "RECORD", rec.split(",")[1], "MATCH" if rec.split(",")[1] == digest else "MISMATCH")Save it as probe_real_path.py. Run it from a folder with no .env file in
it or any folder above it, in an empty environment, so no real key can be
loaded. The command we used is below. We passed a full scratch path where it
says ./work:
env -i HOME=$HOME PATH=/usr/bin:/bin:$(dirname $(command -v uv)) uv run --no-project --python 3.12 --prerelease allow --with rasa-pro==3.21.0.dev5 python probe_real_path.py ./work 2>/dev/null | grep -v '\[debug'The output on 3.21.0.dev5, unedited:
rasa-pro 3.21.0.dev5
PROBE_KEY set | 'api_key: ${PROBE_KEY}' | channels loader: ok | model client built; stub received api_key='sk-probe-real-value'
PROBE_KEY set | 'api_key: "${PROBE_KEY}"' | channels loader: ok | model client built; stub received api_key='sk-probe-real-value'
PROBE_KEY set | 'api_key_env: PROBE_KEY' | channels loader: ok | model client path raised InvalidConfigException
PROBE_KEY set | 'api_key: $PROBE_KEY' | channels loader: ok | model client path raised InvalidConfigException
PROBE_KEY set | 'api_key: ${PROBE_KEY:-fallback}' | channels loader raised RasaException | model client path raised InvalidConfigException
PROBE_KEY set | 'api_key: sk-literal-probe' | channels loader: ok | model client path raised InvalidConfigException
PROBE_KEY unset | 'api_key: ${PROBE_KEY}' | channels loader raised RasaException | model client built; stub received api_key='${PROBE_KEY}'
PROBE_KEY unset | 'api_key: "${PROBE_KEY}"' | channels loader: ok | model client built; stub received api_key='${PROBE_KEY}'
PROBE_KEY unset | 'api_key_env: PROBE_KEY' | channels loader: ok | model client path raised InvalidConfigException
PROBE_KEY unset | 'api_key: $PROBE_KEY' | channels loader: ok | model client path raised InvalidConfigException
PROBE_KEY unset | 'api_key: ${PROBE_KEY:-fallback}' | channels loader raised RasaException | model client path raised InvalidConfigException
PROBE_KEY unset | 'api_key: sk-literal-probe' | channels loader: ok | model client path raised InvalidConfigException
rasa/shared/utils/yaml.py file sha256=GPBtn_rXpPs07bz75BeQSEcvLHSXA8wZsar-occyr5I RECORD sha256=GPBtn_rXpPs07bz75BeQSEcvLHSXA8wZsar-occyr5I MATCHLimits
- Rasa 3.21 is a prerelease. The check, its messages and the point where the key is filled in may change before a stable release.
- We tested 3.20.0, not 3.20.1.
- The live runs were one pass per release, on one provider and one model.
- The unset-variable results come from Rasa’s readers called directly, not a recorded server start. The order of channel set-up and model loading comes from Rasa’s source.
- The call-time check covered Rasa’s default model client. We did not test other provider clients.
- The table covers the model group in the integrations file. For the endpoints file, we read the 3.21 source, which runs the same check, but did not run the same test.