Open the console

CLI and config

CLI reference

elan is how you scaffold, validate, deploy, operate and exercise agents from a terminal. Every command below is one the CLI actually builds, with its real flags and defaults.

elan <command> [options]
elan <command> --help

Installing

uv tool install elan-cli      # or: pipx install elan-cli
uv tool upgrade elan-cli
elan --version                      # elan <version> (elan-sdk <version>)

It needs Python 3.12 or newer. uv also has to be on your PATH for elan env and elan init, which build your project's local environment with it.

--version names the SDK separately because the two are published independently, so a current-looking CLI can be sitting on an older SDK. Nothing sends either number to the platform: it answers “which one have I got”, not “is mine too old”.

Configuration and credentials

WhatWhere
Configuration$ELAN_HOME/config.json if that variable is set, else $XDG_CONFIG_HOME/elan/config.json, else ~/.config/elan/config.json. Written with mode 0600.
CredentialsYour OS keyring, under the service name elan-cli, keyed by issuer. Never a file: there is no fallback to disk.
Per-project state.elan/ beside elan.yaml, holding what elan adk generates. It ignores itself, and elan init adds it to .gitignore too.

The config file holds only public values: the API URL, and the sign-in details the platform answered with at elan login (issuer, client id, audience, token scope, device page). The refresh token, and a cached access token, go to the keyring — which is the difference between a stolen laptop and a stolen credential.

Environment variableEffect
ELAN_HOMEUse this directory instead of the XDG one — for a second platform, or a test.
ELAN_APITalk to a non-production platform without rewriting the stored config.

Which platform a command talks to

Production needs nothing: the CLI defaults to it, so a fresh install signs in with a bare elan login. Everything below is the override for a platform that is not production — a staging environment, or a platform of your own. Highest precedence first:

  1. elan login --api URL. A flag of login only: it signs in to that URL and writes it to the config file, which is how every later command remembers it.
  2. $ELAN_API, read by every command.
  3. The api stored in config.json.
  4. Otherwise, the production API built into the CLI.

Signing in to the platform serving these docs is elan login.

The variable outranks what login remembered

After --api has pointed you somewhere, a shell with ELAN_API set still talks to the variable's platform. And elan login without --api saves whichever platform it resolved, the variable's included. elan whoami prints the API in use.

Commands that talk to the platform run from anywhere. validate, deploy, new, adk, env, rollback and destroy read your project and must run in a directory containing elan.yaml, or below one — the CLI searches upward, like git. invoke, wallet and files read the project from it when you are standing in one.

elan login

elan login [--api URL]

elan login            # the platform serving these docs

Signs in with a device code, so it works on any machine — including one with no browser. The terminal prints a URL on the platform's own sign-in page with the code already in it, and prints the code separately in case you type it instead. Open it anywhere, approve, and the CLI picks the session up.

Argument or flagDefaultMeaning
--api URLempty (production)A non-production platform's URL. Production needs no flag; anywhere else, give it once and later commands remember it. See which platform a command talks to, above.

Everything else about signing in — issuer, client id, scopes — is asked of the platform at GET /v1/auth rather than configured, so there is nothing to keep in step. It ends by printing the subject and tenant the token carries, and warns if the token has no tenant claim (every request would then be refused) or if the issuer returned no refresh token (the session could not be renewed).

elan logout

elan logout

Forgets the stored refresh token and the cached access token for the configured issuer. The configuration stays, so signing in again to a non-production platform needs no --api.

elan whoami

elan whoami

Who the stored credential says you are — subject, tenant, issuer and the API in use. The fastest check that you are pointed at the right platform as the right person. It reuses a cached access token when one is live, so running it does not rotate your session. Not signed in exits 1.

riya@acme.example
  tenant  acme
  issuer  https://<issuer>
  api     https://api.openelan.ai

elan projects

A project is one repository producing one image. Registering one first is optional: the first deploy of a project registers it, taking the repository from source.repo.

projects ls

elan projects ls

PROJECT            AGENTS DEPLOYS  LAST       REPOSITORY
agents                  3       7  succeeded  https://github.com/acme/agents

LAST is the state of the most recent deployment. A project that exists only because something was deployed to it shows from deploy history in place of a repository.

projects create

elan projects create <slug> [--name NAME] [--repo URL]
Argument or flagDefaultMeaning
<slug>requiredThe project's slug: 3–16 characters, lowercase.
--name NAMEemptyWhat a person sees in the console.
--repo URLemptyThe repository, shown beside the project. A first deploy fills it in if this is empty.

elan init

elan init --tenant SLUG --project SLUG [--repo URL]
          [--directory PATH] [--agent SLUG]

Creates a complete repository: elan.yaml, the agents/ and workflows/ directories the platform scans, one working agent declared in both files, and — unless they already exist — a .gitignore, a .env for local development, a README.md, and a CLAUDE.md and .claude/settings.json for an AI coding assistant (below). It runs git init if the directory is not inside a repository already, then builds the .venv that validate and adk need, exactly as elan env does. If that last step fails (no network, no uv), init still succeeds and tells you to run elan env.

Argument or flagDefaultMeaning
--tenant SLUGrequiredYour tenant: 3–16 characters, checked before anything is written.
--project SLUGrequiredThe project: 3–16 characters.
--repo URLhttps://github.com/ORG/REPOWritten into source.repo; edit it later if you like.
--directory PATHnone (the current directory)Where to scaffold. Created if it does not exist.
--agent SLUGhello-agentThe first agent. A project with no targets does not validate, so one is always created.

init refuses to overwrite an existing elan.yaml. The file it writes leaves source.ref as forty quoted zeroes until your first deploy, and defaults every target to 500m / 1Gi, zero to three replicas, the model gemini-flash-latest and a Google key from the secret named default. The quickstart walks through it.

An AI-ready repository

CLAUDE.md tells an assistant the rules a deploy enforces — bump MANIFEST.version when a target changes, add targets with elan new, push before deploying, never put a secret in a file — and links these docs and their llms-full.txt, which holds every page in one file. The link is the console of the platform you signed in to; signed in nowhere, the file says where the docs live in words. .claude/settings.json lets the assistant run read-only commands such as elan validate, status and logs without asking, asks before deploy, rollback, destroy, secret set, wallet and git push, and refuses to read .env. Both are yours to edit, neither is overwritten by a later init, and .claude/settings.local.json — one developer's own permissions — is git-ignored.

elan new

elan new agent <slug>
elan new workflow <slug>

Scaffolds a target and declares it: the module at agents/<module>/agent.py (or workflows/<module>/workflow.py) under targetsRoot, where the module is the slug with hyphens turned into underscores, and a targets: entry in elan.yaml. Writing both halves is the point of the command — the two files are checked against each other on every deploy.

The module is written first, so a failure there leaves the deploy file untouched rather than declaring a target that does not exist. The entry is appended as text after your last target, so your comments and ordering survive. A slug must be 5–16 characters matching ^[a-z][a-z0-9]*(-[a-z0-9]+)*$; one already declared, or whose module directory exists, is refused. See building an agent for what to put in the module.

elan env

elan env

Builds the .venv beside elan.yaml that validate and adk import your agents with: it creates the environment if it is missing, then installs the platform runtime from PyPI into it, which brings ADK with it. It takes no arguments and asks for the runtime unpinned, because the runtime and the CLI are versioned independently. elan init already runs it, so you need it only to rebuild the environment. Needs uv on PATH; a failed install exits 1 with uv's own output above it.

elan adk

elan adk <args...>

elan adk web                   # ADK's developer UI, every target in this repo
elan adk run invoice-triage    # one target, in the terminal
elan adk --help                # everything else ADK offers

Runs ADK's own command line against your repository, with everything after adk passed to it — including options like --port. This is how you exercise an agent before deploying it: a deploy costs a build, this costs nothing.

Your targets export MANIFEST and build() rather than the root_agent ADK looks for, so the CLI generates a small module per target under .elan/adk/ (rewritten on every run) that builds the agent the way a pod does, platform plugins included. Then:

  • web and api_server get that directory appended as their agents directory.
  • run <slug> has an exact slug replaced with its generated module.
  • Anything else — eval included — is passed through unchanged.

It runs the adk installed beside your project's interpreter (an activated virtualenv, then .venv), strips TARGET_SLUG from the environment it launches — with it set, every target would build as that one — and exits with ADK's own code. There is no ledger locally, so runs are unbilled and the runtime says so.

elan validate

elan validate [--python PATH]

Parses elan.yaml with the same model the service uses, then imports your agents to read their MANIFESTs and runs the same crosscheck the service runs after a build. It reports every problem, not the first.

Argument or flagDefaultMeaning
--python PATHnone (an activated virtualenv, then .venv)The interpreter to read your agents with. It needs the platform runtime installed, which the CLI's own environment deliberately does not have.
/home/riya/agents/elan.yaml
  tenant   acme
  project  agents
  targets  3 declared
  found    2 in /home/riya/agents

refused — 2 problem(s):

  targets.receipt-ocr
    declared here but no such agent or workflow was found in the repo. …

  targets.invoice-triage.llm.models
    the agent asks for model 'gemini-2.5-pro', which is not allowed here. …

Run it before every deploy; it catches most refusals without spending a build. Two things it cannot know: whether your token's tenant matches the file, and what is deployed now, which the version rule compares against. Those are checked on submit. When it passes and an egress rule lists paths, it also reminds you that paths are recorded and not enforced.

elan deploy

elan deploy [--ref REF] [--no-wait]

Builds and rolls out the whole project. It is repo-scoped, not agent-scoped, because one image serves every target in the repository.

Argument or flagDefaultMeaning
--ref REFHEADA branch, tag or sha, resolved with git to a 40-character sha and written into source.ref before submitting.
--no-waitoffSubmit, print the deployment id and elan status line, and return.
  1. It resolves --ref and refuses a commit that is on no remote-tracking branch — the builder clones the remote, so it would fail a minute into the build with reference is not a tree. git push, or git fetch if you pushed from elsewhere.
  2. It rewrites source.ref as a text edit, keeping your comments, and leaves that change uncommitted in your working tree.
  3. It submits the file and follows the deployment, polling every five seconds and printing each phase once, when it first sees it.
elan.yaml: source.ref -> 6f1c0a9e4b2d8f0713ac5e29b4d1f6a8c0e37d52
deploying acme/agents at 6f1c0a9e4b2d
  dep_3f9c2a71b04e8d65
  validate
  build
  scan
  crosscheck
  sign
  provision
  render
  commit
  sync
  verify

image sha256:60a23ddf69f4…

  target                     digest
  expense-notes              sha256:1f04b7c2b91d
  invoice-triage             sha256:60a23ddf69f4  <- new

Not every target moves. A change under one agent's directory moves that agent, a change to shared code moves all of them, and an untouched agent stays on the digest it is running. The table lists every target either way, because “what is my agent running” is the question. If deploy.canary is set, that target moves first, alone.

A refusal lists every problem by field. A failure after provisioning says so explicitly — those resources exist and are billed. A second deploy of a project while one is running is refused. To deploy from your own CI instead, see the deployments API.

elan status

elan status <deployment-id>

One look at a deployment: its id, state and phase, then the same report elan deploy prints at the end — problems, the failure, the image digest, the per-target table and the vulnerability scan (including whether it was skipped, so a clean image and an unscanned one do not look alike). Exits 1 for a refused or failed deployment.

elan logs

elan logs <deployment-id> [--tail [N]]
elan logs --workload NAME --project SLUG [--since MINUTES] [--probes] [--tail [N]]

Two logs from two places: a deployment's build output, or what a running workload is saying now. Name exactly one; both or neither exits 2.

Argument or flagDefaultMeaning
<deployment-id>emptyA deployment, for its build log. A deploy refused before building says so instead.
--workload NAMEemptyA workload, for its container log. That is the target's slug — or the group's name, for a shared target — and not the pod's ag- name.
--project SLUGemptyRequired with --workload and not read from the project file: a workload name is unique within a project, not within a tenant.
--since MINUTES60How far back to read a workload's log.
--probesoffInclude health-check requests, which are most of an idle pod's output.
--tail [N]0 (the whole log)Only the last N lines; 50 when the flag is given bare.

An empty workload log is usually a pod scaled to zero, not a fault. When you run it inside that project's checkout and the name is not one of its workloads, it says so and lists them, exiting 2.

elan rollback

elan rollback <to-deployment-id> [--yes] [--no-wait]

Puts this project back to a previous deployment. It takes a deployment id — from elan status or the console — and not a digest (a project has several images) or a ref (a commit may have been force-pushed away). Nothing is rebuilt: the images it returns to already exist and were already scanned and signed.

Argument or flagDefaultMeaning
<to-deployment-id>requiredThe deployment to restore.
--yesoffSkip the confirmation.
--no-waitoffSubmit and return instead of following it.
Rolling agents back to dep_4b18c0730a1e9f27
  started   2026-09-21T09:14:02Z
  state     succeeded
  targets   3
    expense-notes            sha256:1f04b7c2b91d
    invoice-triage           sha256:1f04b7c2b91d
    vendor-lookup            sha256:9c3ae04f18b7

Every target above moves at once — a rollback is not staged the way a deploy is.
Continue? [y/N]

elan agents

elan agents [--project SLUG]

AGENT                    PROJECT          VERSION    DIGEST
invoice-triage           agents           1.4.0      sha256:60a23ddf69f4
expense-notes            agents           0.9.2      sha256:1f04b7c2b91d

What each agent is actually running, read from the platform rather than your checkout — the two differ whenever somebody else deployed.

Argument or flagDefaultMeaning
--project SLUGempty (every project)Only this project's agents.

elan invoke

elan invoke <target> --text TEXT [--session ID] [--timeout SECONDS]

Sends an agent one message and prints what it says back. It opens the event stream and subscribes before invoking, so a fast reply is not lost.

Argument or flagDefaultMeaning
<target>requiredThe agent's slug. Name the agent, never a group: whether it shares a pod is invisible here.
--text TEXTrequiredThe message.
--session IDempty (a fresh cli-… id)Continue a conversation instead of starting one. Left empty, the CLI starts one called cli- plus twelve hex characters.
--timeout SECONDS300See the note below.
elan invoke invoice-triage --text "what can you do?"

invocation f27bfbb4a1c34e…
session     cli-9a3f1b2c4d5e

  queued — a sleeping agent is scheduled and started before it reads its queue; the first reply after a quiet period is the slow one.
invoice-triage: I can triage supplier invoices — …

Run it inside your project. The project comes from elan.yaml; outside one it sends none, which addresses the platform's own project rather than yours.

OutcomeExitWhat to do
It replied0—
It sent an error1The error is printed; read the agent's logs.
It finished without saying anything1Not a timeout. Check that build() returns something that speaks.
It paused0 if it spoke first, else 1Answer it from code or the console — this command cannot.

A pause names what answers it: a workflow input needs runtime.resume(), a tool confirmation needs a decision on its batch (actions.decide_batch() or the console's approvals page), and a long-running tool needs your backend to call tools.submit(). There is deliberately no elan resume: a pause can outlast any command. See the SDK runtime and approvals pages.

--timeout does not bound a silent run

The deadline is checked only after a reply has printed, and reading the socket has no timeout of its own. So a run that has started replying stops being followed once the deadline passes, but a run that prints nothing waits until it ends or you interrupt it. Its progress is still readable with runs.get() or GET /v1/runs/<invocation>.

elan secret

Your own credentials, by kind. llm is a model provider's key, referenced from llm.keys — the platform picks the variable it arrives under. secrets is anything else, referenced from a target's secrets: list — you pick the variable. Setting the value is one step of three; the secrets guide covers declaring where it lands and reading it.

secret set

elan secret set <kind> <name> [--from-file PATH]

elan secret set llm default --from-file ~/keys/gemini.txt
elan secret set secrets payments-api-key          # reads stdin
Argument or flagDefaultMeaning
<kind>requiredllm or secrets.
<name>requiredA name within your own secrets: lowercase letters, digits and hyphens.
--from-file PATH-The file holding the value, or - for stdin.

There is deliberately no --value: a secret on the command line is a secret in your shell history. One trailing newline is stripped and no more, because a provider answers a key ending in \n with a 401 that says the key is invalid. An empty value is refused. Running pods keep the value they started with until something replaces them.

secret ls

elan secret ls

Names and when each was last updated — never values. A secret written straight to the secret store by some other means still works and is not listed.

secret rm

elan secret rm <kind> <name>

Removes the secret and every version of it. A pod already running is unaffected until it restarts — and then a pod that declares it will not start.

elan files

The artifacts an agent reads and writes. The scope is in the filename, exactly as a tool addresses it: report.pdf is one conversation's, user:prefs.json one end user's, agent:tone.md one agent's and project:policy.md every agent's in the project. See files and multi-modal for what each scope means.

Every subcommand takes the target first, and these three:

Argument or flagDefaultMeaning
<target>requiredThe agent or workflow slug.
--project SLUGempty (the project you are in)The project. Pass it explicitly — see the note below.
--user IDemptyOne of your end users, for a file that is theirs.

Pass --project

Reading the project from elan.yaml currently fails with a Python traceback rather than a message, so give --project on every files command until that is fixed.

files ls

elan files ls <target> [--session ID] --project SLUG

What a conversation can see, grouped by scope; the two shared scopes are marked read-only.

files get

elan files get <target> <filename> [--session ID] [--version N] [-o PATH] --project SLUG
Argument or flagDefaultMeaning
<filename>requiredFor example report.pdf or project:policy.md.
--session IDemptyThe conversation, for a file in its scope.
--version Nnone (the newest)A specific version.
-o, --out PATHempty (the file's own name)Where to write it.

files put

elan files put <target> <path> [--name NAME] [--session ID] [--content-type TYPE] --project SLUG
Argument or flagDefaultMeaning
<path>requiredThe local file. Empty files and files over 512 MiB are refused before anything is sent.
--name NAMEempty (the file's own name)The artifact name, which chooses the scope. Prefix project: or agent: for a shared document, which needs a tenant admin.
--session IDemptyThe conversation, for a file in its scope.
--content-type TYPEapplication/octet-streamSigned into the upload URL; a later read reports it.

The bytes go straight to storage on a signed URL, never through the platform, and that request carries no platform token.

files rm

elan files rm <target> <filename> [--session ID] --project SLUG

Removes one file and every version of it.

elan wallet

Credit you hold for your own end users — not what the platform charges you. A wallet belongs to one project, because ada in one of your products is not ada in another: standing in a project, elan.yaml answers it, and every subcommand takes --project SLUG for a script that is not. There is no --tenant; it comes from your token. Reading is open to any member; create, credit, adjust, freeze and unfreeze need a tenant admin. To do this from your backend, see charging your users.

wallet ls

elan wallet ls [--limit N] [--project SLUG]

Your users, their available balance and status, and any amount held by a run in flight. --limit defaults to 50.

wallet show

elan wallet show <user> [--project SLUG]

One user's status, available amount, held amount and balance.

wallet create

elan wallet create <user> [--label TEXT] [--project SLUG]

Idempotent, and it says nothing about creating: run it on every sign-in if that is easiest — there is only ever one wallet per user. --label is for you, reading a list.

wallet credit

elan wallet credit <user> <dollars> [--ref TEXT] [--project SLUG]
elan wallet credit <user> --micros N [--ref TEXT] [--project SLUG]

$ elan wallet credit ada 5 --ref promo:launch
credited $5.00 to ada  txn_9f21c4e8b7a3

Amounts are dollars: 5 is five dollars, 2.50 two fifty. --micros takes the integer in micro-USD instead, for a script that read a balance back. A credit must be positive. It is not idempotent — running it twice adds the credit twice — so fill in --ref, which is what makes a double credit visible on the statement.

wallet adjust

elan wallet adjust <user> <dollars> [--micros N] [--ref TEXT] [--project SLUG]

A correction in either direction: negative claws credit back. Zero is refused. The original movement stays on the statement — the books are corrected by a second entry, never by editing the first.

wallet freeze

elan wallet freeze <user> [--project SLUG]

Stops this user spending and keeps their balance. Their runs are refused as unavailable rather than out of credit. There is no close here: closing needs a zero balance and is not meant to be reversed, so it lives in the console beside the balance.

wallet unfreeze

elan wallet unfreeze <user> [--project SLUG]

Lets a frozen user spend again.

wallet statement

elan wallet statement <user> [--limit N] [--project SLUG]

2026-10-07T12:05:00  run              -$0.0072  inv-4c1a
2026-10-07T12:00:00  credit              $5.00  promo:launch

One user's movements, newest first. --limit defaults to 25.

elan doctor

elan doctor

Checks the platform's record of what it provisioned for your tenant against what actually exists, and reports two lists:

  • Recorded but absent — nothing is running that should not be, but the record is wrong, and a destroy will report success for these.
  • Could not be checked — a provider it could not reach, with the reason.

Findings are a bookkeeping matter, not a broken deployment, so it exits 0 either way; only a failed request exits 1.

elan destroy

elan destroy <agent> [--yes]
elan destroy --project [--yes]

Removes an agent, or the whole project, and everything provisioned for it. Run it inside the project; the project comes from elan.yaml.

Argument or flagDefaultMeaning
<agent>emptyThe agent to remove. Omit it with --project.
--projectoffRemove the whole project — every agent in it, and its namespace.
--yesoffSkip the confirmation.
This removes 'expense-notes' and everything provisioned for it:
  its database, its topics, its secrets and its IAM.

The database is snapshotted first and kept for ninety days.
The image is left alone — its siblings are running it.

Type 'expense-notes' to confirm:

You type the name, not a yes. Re-running is safe: what is already gone is skipped. Anything that could not be removed is listed with its reason, still exists, and makes the command exit 1.

elan tenant

Creates and lists tenants. Platform staff only, enforced by the service rather than the CLI; anybody else gets the service's refusal.

tenant create

elan tenant create <slug> [--name NAME]
Argument or flagDefaultMeaning
<slug>requiredThe tenant's slug. Reserved slugs are refused.
--name NAMEempty (the slug)What a person sees.

Prints the tenant's identity organization and the audience its tokens will carry. The organization starts with no users: adding the customer's first administrator is a separate, deliberate step. Running it for a tenant that exists says so rather than failing.

tenant ls

elan tenant ls

Every tenant, with its creation date and display name.

Exit codes

CodeMeaning
0It worked. That includes doctor with findings, a log with nothing in it, an invoke that replied and then paused, and an init whose environment step failed.
1It ran and the answer was no: validate, deploy or status refused or failed; an invoke that errored, said nothing or paused silently; a declined confirmation; a destroy that left something behind; whoami with no session; env when uv fails.
2It could not run: bad arguments or a missing subcommand; no elan.yaml, or one that does not parse; a commit on no remote; logs without exactly one source.
otheradk exits with ADK's own code.

A failed request is 1 or 2 depending on the command. Not signed in, a 403 or a service refusal exits 1 from deploy, status, logs, rollback, projects, agents, doctor, destroy and files, which report it themselves, and 2 from secret, wallet and tenant. A script should test for non-zero rather than for a particular code. Errors are printed to stderr, phrased for you, without a traceback.

See also

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