Open the console

Concepts

elan.yaml and MANIFEST

Two files, two owners. elan.yaml is the deploy contract — what the platform gives your code. MANIFEST is what your code declares about itself. Neither can describe the other, and a deploy refuses to go further until the two agree.

Why it is two files and not one

Because of when each can be read. elan.yaml is data: the platform parses it without running anything of yours, which is what lets a deploy be refused for a bad value before a container is ever built. A MANIFEST is the result of executing a Python module — so the only way to know what your agent declares is to build your image and ask it.

That asymmetry decides the shape of everything else. Definitions live in code, so there is no database or config plane where an agent can be edited behind your repository's back. And the comparison between the two files happens after the build and before anything is provisioned, which is the last moment a deploy can be refused leaving nothing behind.

Which file owns what

The split is what you are GIVEN versus what you ARE. Anything the platform has to create, size, allow or pay for belongs to elan.yaml: the source repo and ref, resources, scaling, the database, artifacts, egress hosts, secret bindings, the model allowlist, and whether a target shares a pod. What the agent is belongs to its manifest: its slug and version, its model, its tools, its per-run limits, what it charges your users, and what each of its tools is permitted to do unasked.

A matched pair

This is the platform's own hotel-concierge, trimmed. Note that it appears in both files and says different things in each.

# elan.yaml — the deploy contract
apiVersion: elan.dev/v1
kind: Project

tenant: acme
project: agents

source:
  repo: https://github.com/acme/agents
  ref: bca41fe06f9b8087eb687d4b4bb7c163a8e2f1d0   # rewritten by `elan deploy`
  runtime: python3.12

defaults:
  resources: {cpu: "500m", memory: "1Gi"}
  scaling:   {minReplicas: 0, maxReplicas: 3}
  llm:
    models: [gemini-flash-latest]          # the ALLOWLIST
    keys:   {google: default}              # a secret NAME of yours
  egress: []

targets:
  hotel-concierge:
    database:  {enabled: true}             # it holds a conversation
    artifacts: {enabled: true}
# agents/hotel_concierge/agent.py — what the agent IS
MANIFEST = AgentManifest(
    slug="hotel-concierge",                # must match the targets: key
    version="0.10.0",                      # you bump this when you change it
    display_name="Hotel Concierge",
    description="Checks arriving guests in, and extends checkout with the "
                "guest's approval.",
    model="gemini-flash-latest",           # must be in llm.models above
    tools=[],                              # MCP bindings, if any
    limits=Limits(max_concurrent=4, max_llm_calls=8, timeout_s=120),
    billing=Billing(                       # what YOU charge YOUR users
        sku="agent.chat.standard",
        deposit_micros=12_000,
        input_token_cost=20,
        cached_input_token_cost=10,
        output_token_cost=300,
    ),
    actions={...},                         # what its tools may do unasked
)


def build(ctx) -> BaseAgent:
    # ctx hands your code what the two files RESOLVED to: ctx.model is the
    # allowed model, ctx.manifest is the block above. Called in the pod.
    return LlmAgent(
        name="hotel_concierge",
        model=ctx.model,
        description=ctx.manifest.description,
        ...
    )

billing in the manifest is what YOU charge, not what you are charged

Those token costs are your retail prices for your own users, in US cents per million tokens. What the platform charges you is a rate card, which a platform admin owns and nothing in your repository can reach.

What the crosscheck refuses

After your image is built, the platform reads the manifests out of it and compares them with elan.yaml in both directions. All of these are refusals rather than warnings, because each one fails much later and much worse:

A slug is in targets: but no such agent or workflow is in the repo

Refused. "It would provision a database and a topic for nothing."

An agent is in the repo but not declared in targets:

Refused, and the reason is review rather than mechanics — an undeclared target is the one nobody looked at, so it is refused rather than skipped.

MANIFEST.model is not in that target's llm.models

Refused, naming both. That list also decides which provider hosts the egress allowlist opens, so the call would be refused at the proxy by hostname even if the model were available.

A tool calls a host that target's egress does not allow

Refused, naming the tool and the host. Default-deny means the call would otherwise fail at the NetworkPolicy and not at the tool — so the traceback would point at the wrong thing.

An MCP binding declares no tool_filter

Refused. An unfiltered binding takes whatever that server offers today AND whatever it adds tomorrow.

Get the same answer before you spend a build

elan validate runs the identical model locally — it reads your elan.yaml and imports your manifests, then checks them against each other the same way the service does. A local yes means the service will also say yes. It reports every problem at once, too: discovering them one deploy at a time is how a five-minute mistake becomes an afternoon.

Two rules that surprise people

A target you CHANGED must bump its own version — and only that one

The version lives in the manifest because it describes the definition, not the deploy. The rule applies to targets whose own files changed: touching something they all share moves every target to a new image without any of their definitions having changed, and demanding a bump from each would mean editing every manifest in the repository to ship one shared fix.

elan.yaml NAMES secrets; it never holds them

keys: {google: default} maps a provider to the name of one of your own secrets, set separately with the CLI — never to a key. This file is committed to your repository, so a value in it would be a value in your git history. The platform injects it under the variable the provider's SDK reads, and you name the PROVIDER rather than the variable because GOOGLE_ is a reserved prefix: a tenant setting GOOGLE_CLOUD_PROJECT could point a cloud SDK somewhere unintended.

Where to go next

Every field, default and limit is in the elan.yaml reference, and building an agent covers the manifest side field by field. agents and workflows covers what a workflow's manifest adds — declared launch inputs, which become the graph's state schema — and the deployments API is how you submit this file from your own CI, and where a refusal comes back as problems[] naming the field.

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