Open the console

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 default

Top level

FieldTypeDefaultMeaning
apiVersionstringrequired<group>/v1 — for example elan.dev/v1. Any group is accepted; the version must be v1.
kindstringrequiredAlways Project.
tenantslug, 3–16requiredYour tenant. A file naming a tenant other than your token's is refused on submit.
projectslug, 3–16requiredOne 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.
sourceblockrequiredWhat to build. See below.
deployblock{}How a new image rolls out.
artifactsblock{}The project's artifact bucket. See project artifacts.
defaultsblock{}Inherited by every target.
groupsmap of slug → group{}Shared workloads. Empty is the ordinary case.
targetsmap of slug → targetrequiredEvery agent and workflow, by slug. It may not be empty: a project with no agents deploys nothing.

source

FieldTypeDefaultMeaning
repostringrequiredThe git remote the builder clones.
refstringrequiredA 40-character lowercase hex git sha, matching ^[0-9a-f]{40}$. Not a branch, not a tag, not a short sha.
targetsRootstring.The directory, relative to this file, containing agents/<module>/agent.py and workflows/<module>/workflow.py.
runtimestringpython3.12Accepted 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

FieldTypeDefaultMeaning
canarytarget slugnullMoved 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.

FieldTypeDefaultMeaning
resourcesresources block{cpu: "500m", memory: "1Gi"}Used by a target, or a group, that states no resources of its own.
scalingscaling block{minReplicas: 0, maxReplicas: 5}Used by a target, or a group, that states no scaling.
llmllm block{models: [], keys: {}}Used by a target that states no llm — shared or not, since a group has none.
egresslist 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

FieldTypeDefaultMeaning
ownerowner block{}Not inherited.
deploymentdeployment block{runtime: isolated, group: ""}Whether it gets a pod of its own.
resourcesresources blockfrom defaultsA shared target may not state it; the group's applies.
scalingscaling blockfrom defaultsA shared target may not state it; the group's applies.
llmllm blockfrom defaultsPer target, even when shared.
egresslist of egress rulesfrom defaultsA shared target may not state it; the group's applies.
secretslist of secret refs[]Not inherited.
databasedatabase block{enabled: true, sizeGb: 10}For a shared target, replaced by the group's.
artifactsartifacts block{enabled: true}For a shared target, replaced by the group's.
envmap of name → string{}Not inherited.
labelsmap 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.

FieldTypeDefaultMeaning
cpustring"500m"Millicores (500m) or cores (1, 1.5), matching ^([0-9]+m|[0-9]+(\.[0-9]+)?)$.
memorystring"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.
BoundValueWhy
Floor250m, 512MiEvery pod is billed at least this. Asking for less is not cheaper; it is the same price described wrongly.
Memory per core1024Mi–6656MiOutside this ratio the pod is rounded up at scheduling, so it costs more than the file says.
Fits a node1330m, 3300MiWhat 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 ceiling4000m, 8192MiThe 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 pod665m, 1650MiAt 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

FieldTypeDefaultMeaning
minReplicasint ≥ 000 scales to zero and pays a cold start on the next invocation.
maxReplicasint ≥ 15The 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

FieldTypeDefaultMeaning
modelslist of string[]The models this target's agents may use. Every MANIFEST.model must be one of them.
keysmap 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.

FieldTypeDefaultMeaning
hoststringrequiredA 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.
pathslist 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

FieldTypeDefaultMeaning
namestringrequiredA 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>.
envstringrequiredThe 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

FieldTypeDefaultMeaning
enabledbooltrueProvisions 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.
sizeGbint ≥ 110Accepted 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

FieldTypeDefaultMeaning
enabledbooltrueStorage 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
PrefixScopeWho writes it
noneone conversationthe agent, or an upload into that conversation
user:one end user, across their conversationsthe agent, or an upload for that user
agent:one agent, across all its usersa tenant admin
project:every agent in the projecta 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.

FieldTypeDefaultMeaning
retentionDaysint or nullnull1–1095. Deletes an object version this many days after it was written; null keeps everything until the project is destroyed.
originslist 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

FieldTypeDefaultMeaning
teamstring""Recorded with each deploy and shown with the agent.
contactslist 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

FieldTypeDefaultMeaning
runtimeisolated or sharedisolatedWhether this target gets a pod to itself.
groupstring""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

IsolatedShared
Scalingper agentper group — a busy member scales its quiet siblings too
New imagewhen its own code, or shared code, changeswhen any member's does — members move together
Resource contentionnonemembers compete for one pod's CPU and memory
A crashtakes down one agenttakes down every member
Databaseone per agentone per group, shared by every member
Artifactsonly this agent can write its folderevery member can read and write every other member's
Cold startper agentonce 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 whenWhy
A name is both a group and a targetThey would share every derived name — deployment, service account, database — and fight over them.
runtime: shared names no groupA shared target has to say which pod it shares.
group names a group that is not declaredA typo, caught here rather than as a missing workload.
runtime: isolated with a group setA group on an isolated target does nothing, and being silently ignored is worse than being refused.
A shared target states resources, scaling or egressThose describe the pod, so they belong on the group.
A group has no membersIt 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 providerOne 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.

FieldTypeDefaultMeaning
resourcesresources blockfrom defaultsThe pod's size, shared by every member.
scalingscaling blockfrom defaultsThe pod's replica range.
egresslist of egress rulesfrom defaultsThe pod's allowlist, which every member gets.
databasedatabase block{enabled: true, sizeGb: 10}One database for the whole group.
artifactsartifacts block{enabled: true}Whether the members get artifact storage.
labelsmap 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_SUBSCRIPTION would point your pod at another agent's invocations — reading somebody else's messages and acknowledging them away.
  • TENANT_SLUG and PROJECT_SLUG name every topic and subscription a pod derives, so they are the same attack, less obviously.
  • MODEL_OVERRIDE substitutes the model after the llm.models check has passed, so the allowlist would stop describing what runs. It works only on your own machine, under AGENTKIT_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

WhatLimit
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 slugs3–16 characters
Target and group slugs5–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_]*$
CPU500m or 1.5 form; 250m to 1330m in practice (schema ceiling 4000m)
Memory512Mi or 2Gi form; 512Mi to 3300Mi in practice (schema ceiling 8192Mi)
Memory per core1024Mi–6656Mi
minReplicas0 or more, at most maxReplicas; above 4 never fits
maxReplicas1 or more
database.sizeGb1 or more
artifacts.retentionDays1–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 targets has no matching module in the repository;
  • a module in the repository is not declared in targets;
  • a MANIFEST.model is not in that target's resolved llm.models;
  • a tool binding's URL names a host that is not one of that target's egress hosts;
  • 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 tenant must be your token's;
  • a target whose own directory changed must bump its MANIFEST version — 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.

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