CLI and config
elan.yaml reference
elan.yaml is the deploy contract: one file per repository, declaring every agent in it and everything the platform must provision, permit or enforce for them. It holds deploy-time facts only — what an agent is lives in its MANIFEST, and the contracts page explains why there are two files.
Most values here are enforced outside your process — by a network policy, a quota, an admission check, an injected secret — which is what makes them binding on code that does not cooperate; the few that are accepted and not yet acted on say so below. The same model parses it in elan validate and on submit, so the two cannot disagree about whether a file is well-formed.
Unknown keys are refused, everywhere
Every block rejects a field it does not declare, so a typo is an error at elan validate rather than a setting silently ignored in production. Fields are written in camelCase as shown; the snake_case spelling (min_replicas, size_gb) is also accepted.
A complete example
Four targets: one isolated agent with its own pod, two sharing a group, and one taking every default.
apiVersion: elan.dev/v1
kind: Project
tenant: acme
project: agents
source:
repo: https://github.com/acme/agents
ref: "6f1c0a9e4b2d8f0713ac5e29b4d1f6a8c0e37d52" # rewritten by elan deploy
targetsRoot: .
deploy:
canary: invoice-triage
artifacts: # the project's one bucket
retentionDays: 365
origins: ["https://app.acme.example"]
defaults:
resources: {cpu: "500m", memory: "1Gi"}
scaling: {minReplicas: 0, maxReplicas: 5}
llm:
models: [gemini-flash-latest, gemini-2.5-pro]
keys: {google: default} # a NAME in your own llm secrets
egress: []
groups:
back-office: # one pod for two agents
resources: {cpu: "500m", memory: "1Gi"}
scaling: {minReplicas: 1, maxReplicas: 4}
database: {enabled: true}
egress:
- host: api.payments.example
targets:
invoice-triage: # isolated: a pod of its own
owner: {team: billing}
resources: {cpu: "1", memory: "2Gi"}
llm: {models: [gemini-2.5-pro], keys: {google: default}}
egress:
- host: api.payments.example
paths: ["/v1/refunds/*"] # recorded, NOT enforced
secrets:
- {name: payments-api-key, env: PAYMENTS_API_KEY}
database: {enabled: true}
artifacts: {enabled: true}
env: {INVOICE_REGION: eu}
expense-notes: # shares the back-office pod
deployment: {runtime: shared, group: back-office}
secrets:
- {name: payments-api-key, env: PAYMENTS_API_KEY}
vendor-lookup: # shares the back-office pod
deployment: {runtime: shared, group: back-office}
refund-flow: {} # takes every defaultTop level
| Field | Type | Default | Meaning |
|---|---|---|---|
| apiVersion | string | required | <group>/v1 — for example elan.dev/v1. Any group is accepted; the version must be v1. |
| kind | string | required | Always Project. |
| tenant | slug, 3–16 | required | Your tenant. A file naming a tenant other than your token's is refused on submit. |
| project | slug, 3–16 | required | One repository. It is part of every derived name — namespace, database, topics, session app name — so two projects may each contain an agent called worker, and a bill breaks down by project. |
| source | block | required | What to build. See below. |
| deploy | block | {} | How a new image rolls out. |
| artifacts | block | {} | The project's artifact bucket. See project artifacts. |
| defaults | block | {} | Inherited by every target. |
| groups | map of slug → group | {} | Shared workloads. Empty is the ordinary case. |
| targets | map of slug → target | required | Every agent and workflow, by slug. It may not be empty: a project with no agents deploys nothing. |
source
| Field | Type | Default | Meaning |
|---|---|---|---|
| repo | string | required | The git remote the builder clones. |
| ref | string | required | A 40-character lowercase hex git sha, matching ^[0-9a-f]{40}$. Not a branch, not a tag, not a short sha. |
| targetsRoot | string | . | The directory, relative to this file, containing agents/<module>/agent.py and workflows/<module>/workflow.py. |
| runtime | string | python3.12 | Accepted and recorded, and read by nothing today: every image is built on the platform's own runtime base. |
You do not write ref by hand. elan deploy resolves your branch to a commit and rewrites the field before submitting, so a deployment is reproducible from this file alone. elan init writes forty zeroes until then — quoted, because forty unquoted zeroes are a YAML integer and fail as “not a valid string”.
There is no dockerfile key. The platform owns the build: a tenant-supplied build definition is an attack surface it declines to have, and its own layer order is what keeps one image per repository cheap to rebuild.
deploy
| Field | Type | Default | Meaning |
|---|---|---|---|
| canary | target slug | null | Moved to a new image first, alone; the rest follow only if it stays healthy. Must name a declared target. A rollback does not stage this way — it moves every target at once. |
defaults
Inherited by every target, and overridable per target. Only these four blocks may appear here; secrets, env, the database, artifacts, owner, labels and deployment are per-target facts with no default form.
| Field | Type | Default | Meaning |
|---|---|---|---|
| resources | resources block | {cpu: "500m", memory: "1Gi"} | Used by a target, or a group, that states no resources of its own. |
| scaling | scaling block | {minReplicas: 0, maxReplicas: 5} | Used by a target, or a group, that states no scaling. |
| llm | llm block | {models: [], keys: {}} | Used by a target that states no llm — shared or not, since a group has none. |
| egress | list of egress rules | [] | Used by a target, or a group, that states no egress. |
Merging is per block, not per field
A target that states llm replaces the default llm entirely — its models are not unioned with the default's, because a target narrowing an allowlist must not have the wider one merged back underneath. The same goes for resources: a target stating {cpu: "1"} gets the field default of 1Gi memory, not the memory in defaults.
targets
Every agent and workflow in the repository, keyed by slug (5–16 characters). The key set must equal the set of targets the repository actually contains, in both directions: an undeclared target is the one nobody reviewed, and a declared one with no module would provision a database and a topic for nothing. elan new agent <slug> writes both halves so they cannot drift. Every field is optional — {} takes every default.
Per-target fields
| Field | Type | Default | Meaning |
|---|---|---|---|
| owner | owner block | {} | Not inherited. |
| deployment | deployment block | {runtime: isolated, group: ""} | Whether it gets a pod of its own. |
| resources | resources block | from defaults | A shared target may not state it; the group's applies. |
| scaling | scaling block | from defaults | A shared target may not state it; the group's applies. |
| llm | llm block | from defaults | Per target, even when shared. |
| egress | list of egress rules | from defaults | A shared target may not state it; the group's applies. |
| secrets | list of secret refs | [] | Not inherited. |
| database | database block | {enabled: true, sizeGb: 10} | For a shared target, replaced by the group's. |
| artifacts | artifacts block | {enabled: true} | For a shared target, replaced by the group's. |
| env | map of name → string | {} | Not inherited. |
| labels | map of string → string | {} | Not inherited. |
The blocks
resources
What one pod asks for — and therefore what it is billed at. You do not choose the machine: the node is shared between tenants, so its size is a packing decision the platform makes for all of them.
| Field | Type | Default | Meaning |
|---|---|---|---|
| cpu | string | "500m" | Millicores (500m) or cores (1, 1.5), matching ^([0-9]+m|[0-9]+(\.[0-9]+)?)$. |
| memory | string | "1Gi" | Mi or Gi only, matching ^[0-9]+(Mi|Gi)$. Decimal M and G are refused: 1G and 1Gi differ by 7%, and the difference shows up as an out-of-memory kill. |
| Bound | Value | Why |
|---|---|---|
| Floor | 250m, 512Mi | Every pod is billed at least this. Asking for less is not cheaper; it is the same price described wrongly. |
| Memory per core | 1024Mi–6656Mi | Outside this ratio the pod is rounded up at scheduling, so it costs more than the file says. |
| Fits a node | 1330m, 3300Mi | What one of the platform's nodes leaves for pods after its own system daemons. A pod larger than this could never be scheduled, so it is refused whatever minReplicas says. This is the ceiling you actually meet. |
| Schema ceiling | 4000m, 8192Mi | The most one agent may ask for, so a typo cannot become an invoice. Today the node-fit bound is lower, so it is never the one that refuses you. |
| Warm pod | 665m, 1650Mi | At minReplicas: 1, because the pod has to fit twice. See scaling. |
Those ceilings bound any one agent. Your tenant as a whole is bounded by a quota on its namespace.
scaling
| Field | Type | Default | Meaning |
|---|---|---|---|
| minReplicas | int ≥ 0 | 0 | 0 scales to zero and pays a cold start on the next invocation. |
| maxReplicas | int ≥ 1 | 5 | The most pods the autoscaler will run. |
minReplicas may not exceed maxReplicas. A workload that has drained its queue returns to zero after about five minutes of quiet. A cold start was measured at a median of about thirteen seconds while a sandboxed node is available, and well over a minute when one has to be created — which is why the platform holds one such node warm for the whole cluster. minReplicas: 1 buys that warmth again, per agent, and bills you for it.
A warm agent has to fit on one node more than once
A rolling update may not take a warm replica down, so it stands the replacement up first: with minReplicas at N it needs room for N plus a quarter of N (rounded up, at least one) pods at once, on one node. At minReplicas: 1 that is two pods, which caps the pod at 665m and 1650Mi; at 5 or more it is seven or more pods, which no size can satisfy, so 4 is the practical maximum. The combination is refused at validation because getting it wrong produces an agent that reports a successful deploy and serves the old image indefinitely while the replacement sits pending. At minReplicas: 0 there is no such cap: a stalled rollout clears itself once the pod scales away.
llm
| Field | Type | Default | Meaning |
|---|---|---|---|
| models | list of string | [] | The models this target's agents may use. Every MANIFEST.model must be one of them. |
| keys | map of provider → secret name | {} | Where each provider's key comes from. The provider matches ^[a-z][a-z0-9-]*$; the value is the name of one of your llm secrets, never a key. |
models is enforced twice, and the second one is the real one. At deploy time each manifest's model must be in the list. At runtime your egress allowlist is derived from it, so a pod can reach the providers of these models and nothing else — whatever SDK your agent uses, because it is a network rule rather than a library your code could route around. A model starting gemini opens Google's endpoint; gpt, o1, o3, o4 and chatgpt open OpenAI's; claude opens Anthropic's. A model with any other prefix opens nothing, and its call fails at the proxy naming the hostname.
keys names a provider, not a variable. The platform injects the key under the variable that provider's SDK reads: google as GOOGLE_API_KEY, openai as OPENAI_API_KEY, anthropic as ANTHROPIC_API_KEY. You cannot write those names yourself because GOOGLE_ is a reserved prefix. The secret is set with elan secret set llm <name> and is scoped to your tenant, so it cannot name another tenant's. A provider other than those three passes validation and fails the deploy later, after resources have been provisioned — check the spelling.
egress
Third-party hosts your tools may reach. Default deny: anything not listed is refused. Your model providers are added from llm.models automatically.
| Field | Type | Default | Meaning |
|---|---|---|---|
| host | string | required | A hostname, not a URL — a value containing :// or / is refused. Enforced by a default-deny network policy and the egress proxy's check of the host you connect to. |
| paths | list of string | [] | Recorded, shown in the console and warned about by validate — not enforced. |
paths is not enforced, and says so
Enforcing a path means the proxy terminating TLS with a platform certificate in your agent's trust store — which means the platform could read your traffic to third parties. That is a consent decision, not a config flag, so only the host is enforced, and the CLI and console say so wherever a path appears.
secrets
| Field | Type | Default | Meaning |
|---|---|---|---|
| name | string | required | A name within your own secrets-kind secrets, matching ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ — no slash, no dot. Set with elan secret set secrets <name>. |
| env | string | required | The environment variable it arrives as, matching ^[A-Z][A-Z0-9_]*$. |
A name, never a value: the value is set out of band and appears in neither this file nor git. Not inheritable and not allowed under defaults — each workload's secret holds only what its targets declared, because a shared one would put every agent's credentials in reach of every agent. A running pod keeps the value it started with until something replaces it. The secrets guide covers the whole chain, including a secret that was declared and never set.
Unlike env, a secret's env is checked only against that pattern: the contract does not refuse a reserved name here. Do not use one.
database
| Field | Type | Default | Meaning |
|---|---|---|---|
| enabled | bool | true | Provisions a database for this workload. ADK's session store lives in it, so an agent that holds a conversation needs it; turn it off only for something genuinely stateless. |
| sizeGb | int ≥ 1 | 10 | Accepted and recorded; nothing provisions or enforces a size from it today. |
The pod connects with a role that can read and write rows and cannot change the schema; the session tables are created when the database is provisioned. Another workload's database is unreadable from yours — but the members of a group share one.
artifacts
| Field | Type | Default | Meaning |
|---|---|---|---|
| enabled | bool | true | Storage for ADK artifacts: a folder of this agent's own in the project's bucket, plus read access to the two shared scopes. false provisions nothing and leaves ARTIFACT_BUCKET empty, so ADK keeps artifacts in memory for the life of the pod. |
Retention and browser origins belong to the bucket every agent in the project shares, so they are declared on the project. Putting either here is refused with that explanation, because a per-target value would leave provisioning order deciding which one won.
A filename prefix chooses the scope, the way ADK's own user: does, so there is nothing new to call:
await tool_context.save_artifact("report.pdf", part) # this conversation
await tool_context.save_artifact("user:prefs.json", part) # this end user
await tool_context.load_artifact("agent:tone.md") # this agent
await tool_context.load_artifact("project:policy.md") # every agent here| Prefix | Scope | Who writes it |
|---|---|---|
| none | one conversation | the agent, or an upload into that conversation |
| user: | one end user, across their conversations | the agent, or an upload for that user |
| agent: | one agent, across all its users | a tenant admin |
| project: | every agent in the project | a tenant admin |
The two shared scopes are read-only to an agent, held by storage permissions and not only by a check. See files and multi-modal for what reaches a model, and the group caveat below before you share a pod.
Project artifacts
At the top level, beside defaults rather than inside it, because defaults means “inherited by every target” and these describe one bucket.
| Field | Type | Default | Meaning |
|---|---|---|---|
| retentionDays | int or null | null | 1–1095. Deletes an object version this many days after it was written; null keeps everything until the project is destroyed. |
| origins | list of string | [] | Browser origins allowed to upload to and download from the bucket directly. The console's own origin is added for you. |
Retention ages each version on its own, so old versions of a live file go before the newest does; a read with no version is unaffected, a caller pinned to an old version is not. origins has to be declared because it cannot be derived: the browser uploads cross-origin to the storage host, from a page on your site. List every origin a page might be served from, http://localhost:<port> included, or that one fails as an opaque network error with nothing in any log. The upload is still authorized by your backend; declaring an origin only configures the bucket.
owner
| Field | Type | Default | Meaning |
|---|---|---|---|
| team | string | "" | Recorded with each deploy and shown with the agent. |
| contacts | list of string | [] | Accepted and kept in the file; nothing pages or mails these addresses today. |
env
Plain environment variables for the pod, as a map. Names must match ^[A-Z][A-Z0-9_]*$ and must not be reserved; both are refused when the file is parsed. Values are visible in the rendered manifest, so anything sensitive belongs in secrets.
labels
A map of strings, on a target or a group. It is accepted and kept in the file, and the platform does not apply it to anything today — your workloads carry only the platform's own labels.
deployment
| Field | Type | Default | Meaning |
|---|---|---|---|
| runtime | isolated or shared | isolated | Whether this target gets a pod to itself. |
| group | string | "" | Which group's pod it shares. Required by shared, refused by isolated. |
Isolated and shared deployment
By default every target gets its own pod, its own service account, its own database and its own scaling, and moves to a new image only when something it is built from changes — its own directory, or code it shares with the rest of the repository. That is runtime: isolated, and every guarantee on this page holds unless you trade it away.
Why you might not want that
Every pod is billed at a 250m / 512Mi floor whatever it requests, so a project with five small agents pays for 1.25 vCPU before doing any work — and if they scale to zero, you wait through five cold starts instead of one. A group is several targets sharing one pod, one service account, one environment and one database:
groups:
back-office:
resources: {cpu: "500m", memory: "1Gi"}
scaling: {minReplicas: 1, maxReplicas: 4}
targets:
expense-notes:
deployment: {runtime: shared, group: back-office}
vendor-lookup:
deployment: {runtime: shared, group: back-office}
receipt-ocr:
deployment: {runtime: shared, group: back-office}Three agents, one pod, one bill, one warm process.
What you trade away
| Isolated | Shared | |
|---|---|---|
| Scaling | per agent | per group — a busy member scales its quiet siblings too |
| New image | when its own code, or shared code, changes | when any member's does — members move together |
| Resource contention | none | members compete for one pod's CPU and memory |
| A crash | takes down one agent | takes down every member |
| Database | one per agent | one per group, shared by every member |
| Artifacts | only this agent can write its folder | every member can read and write every other member's |
| Cold start | per agent | once for the whole group |
What does not change
Addressing. A caller names the agent — elan invoke expense-notes --text … — and there is no group anywhere in the topic, the subscription, the CLI or the API. So moving an agent into a group to save money breaks nothing that calls it, and moving it back out does not either.
Session identity. Each member keeps its own ADK app name, <tenant>--<project>--<agent>, so conversations do not blur together although their rows live in one database. Everything outside the process — the gVisor sandbox, the default-deny network policy, the egress proxy, image signing and the namespace quota — applies exactly as before.
What a group is not
It is not a security boundary. Members share a process, a service account, an environment and a database — each can already read the others' DATABASE_URL. Group agents because they are operationally alike, not because you trust them equally.
Within a group, agents are not isolated from each other's artifacts
Each member still writes under its own prefix, so files do not blur together. But storage access is granted to an identity, and a group is one pod running as one identity — which is granted every member's folder, because the pod legitimately runs every member. Nothing stops code in that pod reading or writing a sibling's files. It still cannot reach another project's bucket. An agent that must not share artifacts with its siblings has to be runtime: isolated: storage permissions cannot separate two things that share an identity.
# payroll must not read expense-notes' files:
targets:
payroll: {} # isolated: its own pod, its own identity
expense-notes:
deployment: {runtime: shared, group: back-office}The rules, and why each is a refusal
A group is checked when the file is parsed — before a build — and each of these is refused rather than defaulted, because every plausible default fails much later and much worse:
| Refused when | Why |
|---|---|
| A name is both a group and a target | They would share every derived name — deployment, service account, database — and fight over them. |
runtime: shared names no group | A shared target has to say which pod it shares. |
group names a group that is not declared | A typo, caught here rather than as a missing workload. |
runtime: isolated with a group set | A group on an isolated target does nothing, and being silently ignored is worse than being refused. |
A shared target states resources, scaling or egress | Those describe the pod, so they belong on the group. |
| A group has no members | It would provision a database and a service account for a pod that will never exist. |
Two members disagree about an env key, a secret's env, or an llm.keys provider | One pod has one environment. The refusal names both sides; the alternative is one of them silently winning at runtime. |
One thing is not refused: a shared target's own database and artifacts blocks are replaced by the group's without a word. State them on the group.
groups
A group is the pod, so every pod-shaped fact lives here. Group slugs follow the target rules — 5–16 characters — and share their namespace.
| Field | Type | Default | Meaning |
|---|---|---|---|
| resources | resources block | from defaults | The pod's size, shared by every member. |
| scaling | scaling block | from defaults | The pod's replica range. |
| egress | list of egress rules | from defaults | The pod's allowlist, which every member gets. |
| database | database block | {enabled: true, sizeGb: 10} | One database for the whole group. |
| artifacts | artifacts block | {enabled: true} | Whether the members get artifact storage. |
| labels | map of string → string | {} | Accepted; not applied today. |
There is no llm, env, secrets or owner on a group. Those stay on each member — model choice may differ between members — and only the names that become environment variables have to agree.
How values are resolved
For an isolated target:
resources = target.resources ?? defaults.resources scaling = target.scaling ?? defaults.scaling llm = target.llm ?? defaults.llm egress = target.egress ?? defaults.egress database = target.database artifacts = target.artifacts
For a shared target:
resources = group.resources ?? defaults.resources # the target cannot state these scaling = group.scaling ?? defaults.scaling egress = group.egress ?? defaults.egress llm = target.llm ?? defaults.llm # still per target database = group.database # shared artifacts = group.artifacts # shared
Always per block, never per field. secrets, env, owner and labels are the target's own in both cases.
Reserved environment variables
These are refused as a key of env, when the file is parsed. Most would let a pod point itself at somebody else's resources.
TARGET_SLUG TARGET_SLUGS TARGETS_ROOT BUILD_SHA MODEL_OVERRIDE DATABASE_URL ARTIFACT_BUCKET BROKER EVENTS_TOPIC DLQ_TOPIC INVOKE_SUBSCRIPTION RESUME_SUBSCRIPTION TENANT_SLUG PROJECT_SLUG PUSH_TRANSPORT PUSH_SA_EMAIL EXPECTED_AUDIENCE LEDGER_SERVICE_URL OBSERVABILITY_SERVICE_URL ACTIONS_SERVICE_URL AUTH_MODE JWKS_URL JWT_ISSUER JWT_AUDIENCE JWT_ALGORITHMS and every name starting: AGENTKIT_ PUBSUB_ GOOGLE_ GCLOUD_ CLOUDSDK_
INVOKE_SUBSCRIPTIONwould point your pod at another agent's invocations — reading somebody else's messages and acknowledging them away.TENANT_SLUGandPROJECT_SLUGname every topic and subscription a pod derives, so they are the same attack, less obviously.MODEL_OVERRIDEsubstitutes the model after thellm.modelscheck has passed, so the allowlist would stop describing what runs. It works only on your own machine, underAGENTKIT_ENV=local.
As noted under secrets, a secret's env is not checked against this list. Treat it as reserved there too.
Limits and patterns
| What | Limit |
|---|---|
| Every slug | ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ — starts with a letter, no trailing or doubled hyphen. -- separates tenant from project in derived names, so a slug carrying one could make two of them collide. |
| Tenant and project slugs | 3–16 characters |
| Target and group slugs | 5–16 characters |
apiVersion | ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?/v[0-9]+$, version v1 |
source.ref | ^[0-9a-f]{40}$ |
| Secret name | ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ |
llm.keys provider | ^[a-z][a-z0-9-]*$ |
| Environment variable name | ^[A-Z][A-Z0-9_]*$ |
| CPU | 500m or 1.5 form; 250m to 1330m in practice (schema ceiling 4000m) |
| Memory | 512Mi or 2Gi form; 512Mi to 3300Mi in practice (schema ceiling 8192Mi) |
| Memory per core | 1024Mi–6656Mi |
minReplicas | 0 or more, at most maxReplicas; above 4 never fits |
maxReplicas | 1 or more |
database.sizeGb | 1 or more |
artifacts.retentionDays | 1–1095, or null |
The crosscheck against your MANIFESTs
After the build, the platform reads every target's manifest out of the image — inside the sandboxed builder, never by importing your code in its own process — and checks the two files against each other. A deploy is refused when:
- a key in
targetshas no matching module in the repository; - a module in the repository is not declared in
targets; - a
MANIFEST.modelis not in that target's resolvedllm.models; - a tool binding's URL names a host that is not one of that target's
egresshosts; - an MCP tool binding has no
tool_filter, so it would offer every tool that server has — including whatever it adds next.
elan validate runs those five on your machine, after the same parse the service does. Three more checks happen only on submit, because only the platform knows the answer:
- the file's
tenantmust be your token's; - a target whose own directory changed must bump its
MANIFESTversion — not on its first deploy, and not when only shared code changed, since that would mean editing every manifest to ship one fix; - the account must have no unpaid balance.
Every problem is reported, not just the first: fixing one, pushing and waiting for a build to report the next is how a five-minute edit becomes an afternoon. A refusal on submit comes back as 422 with problems[], each naming the field by its dotted path — see the deployments API, and the projects API to validate a file without deploying it. What goes in a manifest is in building an agent.