Open the console

Guides

Secrets

Your agent needs credentials it cannot commit — a payment provider's key, your own API's token. A secret is three things in three places: the value goes to the platform's secret store and is never read back, the declaration of which variable it arrives as goes in elan.yaml, and the use is os.environ in your own code. Miss any one and the agent does not get it.

The chain

elan secret set secrets stripe-api-key
   │                       the VALUE, from a file or stdin
   ▼
the secret store        tenants/<your-tenant>/secrets/stripe-api-key
   │
   │  an ExternalSecret rendered from your elan.yaml, refreshed hourly
   ▼
a Kubernetes Secret     one per agent, in your project's namespace
   │
   │  read once, when the container starts
   ▼
os.environ["STRIPE_API_KEY"]

You write the first link and the last. Everything between is rendered from your elan.yaml — there is no step where you hold a cloud credential, and none where the value passes through your repository.

The two kinds

elan secret set <kind> <name>, where the kind is one of two trees you own:

KindForReferenced byWho names the variable
llmA model provider's API keyllm.keys in elan.yamlThe platform — GOOGLE_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY
secretsEverything elsea target's secrets: listYou

The split exists because of that last column. If you are wiring up a model key, skip to model provider keys — most of this page does not apply to it.

A secret belongs to your tenant, not to one project: stripe-api-key is one value that every project you own can declare.

1. Set the value

elan secret set secrets stripe-api-key            # reads stdin
elan secret set secrets stripe-api-key --from-file ~/keys/stripe.txt

There is deliberately no --value. A secret on the command line is a secret in the shell history of everyone who runs it. The console's Secrets page sets one too, and clears the field the moment it is saved.

One trailing newline is stripped and no more. Pressing Enter after pasting a key adds one, and a provider answers a key with a newline on the end with a 401 that says the key is invalid — which sends people off to regenerate a perfectly good credential. Leading and internal whitespace is kept exactly, because some credentials contain it. An empty value is refused.

Check what you have:

$ elan secret ls
llm/default             updated 2026-10-02
secrets/stripe-api-key  updated 2026-10-24

Names only — values are never shown. A secret written directly to
Secret Manager works and is not listed.

That last line is not a disclaimer. The list is the platform's own record of what you have set, not a listing of the store — listing the store is a permission that cannot be narrowed to one tenant, so a service able to list yours could list everybody's. A value written out of band works and does not appear here.

2. Declare where it lands

Per target, in elan.yaml:

targets:
  invoice-triage:
    secrets:
      - name: stripe-api-key      # the name you set
        env: STRIPE_API_KEY       # the variable your code will read
FieldWhat it is
nameA name inside your own secrets, not a store id. The platform prefixes it with your tenant, which is what stops one tenant projecting another's credential into their pod.
envThe environment variable it arrives as. Yours to choose, within the rules.

Both fields are required, and an entry with anything else in it is refused.

secrets: is per target and does not inherit. It may not appear under defaults:; a target that needs a secret states it, even if its neighbor states the same one. Each agent gets a Kubernetes Secret holding only what it declared, because the namespace is per project and a shared one would put every agent's keys in every agent's reach.

The exception is a target that shares a pod (runtime: shared): one pod has one environment, so it holds every member's secrets, and two members mapping the same variable to different secrets is refused.

3. Read it in your agent

It is an ordinary environment variable. There is no SDK call and nothing to import, and the ctx handed to build() carries no secrets on purpose:

import os

import httpx
from google.adk.agents import BaseAgent, LlmAgent


def issue_refund(charge_id: str, cents: int) -> dict:
    """Refund a charge."""
    key = os.environ["STRIPE_API_KEY"]
    response = httpx.post(
        "https://api.stripe.com/v1/refunds",
        headers={"Authorization": f"Bearer {key}"},
        data={"charge": charge_id, "amount": cents},
    )
    return response.json()


def build(ctx) -> BaseAgent:
    return LlmAgent(
        name=ctx.slug,
        model=ctx.model,
        instruction="Help with refunds.",
        tools=[issue_refund],
    )
  • Read it where you use it, not at import. A missing variable at import time kills the pod during startup, which surfaces as a crash loop somebody has to read logs for. Read inside the tool and the same mistake is a traceback attached to the call that needed it.
  • Index, do not .get(). .get returns None, which travels — you send Bearer None and debug a third party's 401. A KeyError names the thing that is missing.
  • Do not log it, and watch what your HTTP client logs: an error handler that prints the request prints the header.
  • The host must also be in that target's egress:. Network is default-deny, so having the key is not having the connection — see the elan.yaml reference.

Model provider keys are different

For the model your agent calls, you set the value the same way and name a provider rather than a variable:

elan secret set llm default --from-file ~/keys/gemini.txt
llm:
  models: [gemini-flash-latest]
  keys:
    google: default      # provider -> one of YOUR secret names

The platform injects it as GOOGLE_API_KEY, which is what the provider's SDK reads, so your code generally never touches it. The providers it knows are google, openai and anthropic; any other is refused.

You name a provider because you could not be allowed to name this variable. GOOGLE_ is reserved — a tenant who could set GOOGLE_CLOUD_PROJECT could point a cloud SDK at a project of their choosing — so the platform holds the provider-to-variable table, which also keeps the host your agent may reach and the key it is handed the same provider's.

Unlike secrets:, llm is inheritable: put it under defaults: once and every target in the repository gets it.

Naming rules

PatternNotes
Secret name^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$No slash, no dot — a name within your tree, never a path.
env^[A-Z][A-Z0-9_]*$And not one of the platform's own.

An env must not start with any of these prefixes: AGENTKIT_, PUBSUB_, GOOGLE_, GCLOUD_ and CLOUDSDK_ — nor be one of the variables the platform sets for itself, such as DATABASE_URL, TARGET_SLUG or INVOKE_SUBSCRIPTION. Those would point a pod at somebody else's resources; the full list is in the elan.yaml reference.

The reserved names are checked on env:, not yet on secrets:

elan validate refuses a reserved name in a target's plain env: block. For a secrets: entry it checks the pattern only, so keeping its env clear of the list above is on you. A secret that reuses a provider's variable replaces the key llm.keys would have injected.

Locally

Nothing fetches platform secrets on your machine. Put the values in the git-ignored .env beside elan.yaml that elan init created:

# .env — git-ignored
GOOGLE_API_KEY=AIza...
STRIPE_API_KEY=sk_test_...
AGENTKIT_ENV=local

Use the same variable names as production — your env: for a declared secret, and the platform's choice for a model key. Invent a local name and you have code that works on your machine and reads nothing in the pod. And use a test key: elan adk web runs your tools for real.

Rotating a secret

elan secret set on an existing name adds a new version. elan.yaml names the secret, not a version, so nothing in it changes.

A running pod keeps the value it started with

The Kubernetes Secret is refreshed about once an hour, but your process read its environment once, at startup. The new value is not in use until something replaces the pod — which is why a rotation is two steps, and only the first is instant.

elan secret set secrets stripe-api-key --from-file ~/keys/stripe-new.txt
elan deploy                      # replaces the pods

A target scaled to zero picks the new value up on a later cold start without a deploy, but only once the hourly refresh has reached it — so elan deploy is the only way to be sure. If the old credential is compromised, redeploy and then revoke it at the provider, in that order, so you are not revoking a key your pods are still presenting.

Removing one

elan secret rm secrets stripe-api-key

Every version goes, and there is no undelete. The failure it causes is delayed: a running pod holds the value in memory and is unaffected, and it breaks at its next restart — not as a failed call, but by not starting at all. So remove the declaration from elan.yaml and deploy before removing the value, unless you want the agent down.

When it goes wrong

Nothing checks that a secret you declared was ever set. elan validate is offline and checks the name's pattern, never its existence, and the deploy does not check either. The first thing that looks is the node starting your container, which is late and loud.

What you didWhat you see
Declared a name that was never set, or misspelled itThe agent's Kubernetes Secret cannot be built, and the pod sits in CreateContainerConfigError — it never starts and runs none of your code, so it has no logs either. A scaled-to-zero agent still deploys cleanly, and the symptom is an invocation that stays queued.
Set it, declared it, left out envRefused by elan validate: both fields are required.
Read it with .get and it was absentYour own call fails at the third party with a 401 about a malformed credential.
Rotated it and nothing changedThe pod is warm and holds the old value. Redeploy.
Set it correctly, the call is still refusedCheck egress: — the key is not the connection.

CreateContainerConfigError is the right failure. A pod that tolerated a missing Secret would start without its provider key and its database URL, report healthy, and fail at its first real invocation in front of a user. elan secret ls is the quickest check: if the name is not there, that is your answer.

What never happens

  • No value is ever read back. There is no secret get, no route returns one, and setting one answers 204 rather than echoing what it stored.
  • A secret never appears in your repository or in the configuration the platform renders from it — those hold a name, and the rendered object holds a reference.
  • You cannot name another tenant's secret. The tenant comes from your token, never from the request, and the name pattern refuses anything path-shaped.

From your own backend

The three commands are three routes, with the tenant always taken from your token:

RouteDoes
POST /v1/secretsBody {kind, name, value}. 204, nothing returned.
GET /v1/secrets{secrets: [{kind, name, updated_at}]}. Names only.
DELETE /v1/secrets/{kind}/{name}204. Every version.

Setting a name that fails the pattern answers 422. The full reference is Projects and secrets; getting a token is Authentication, and every flag is in the CLI reference.

Reading this as a model? llms.txt indexes every page, and each page has a “Copy for AI” control.