API
Projects and secrets
A project is one repository producing one image, and the unit everything else is addressed by. These routes create and list projects, report what each is running, hold the secrets agents read, validate an elan.yaml before you deploy it, and tear down what you no longer need.
Most of these answer for the whole tenant
Only the routes with the project in their path — deleting and destroying a project — check a project-scoped credential's claim. Listing projects, listing agents, destroying one agent, the doctor report, secrets and validation answer for the whole tenant, whatever project the credential was issued for. The overview's project-scoping table covers every route.
Secrets belong to the tenant, not to a project
One tree per tenant, referenced by name from each project's elan.yaml. Any credential in the tenant can set or delete one — the admin role is not required — so treat secret writes as something every service user you issue can do.
Removing a project and destroying one are different calls
Destroying removes the agents and everything they use, and is admin only. Deleting the project then removes its registration, and is refused while anything is still deployed.
The samples use $API and $TOKEN, which the API overview sets.
Create a project
POST/v1/projectsdeploy-service
Registers a project: one repository, one elan.yaml, one image, and a name nobody else in your tenant has taken.
You do not have to call this before deploying
A deploy registers the project it names, so the CLI and a CI job can skip this. What it adds is a name, a display name and a repository to show before the first build has run.
Any member may create one, and the name is checked here
No admin role is needed. The slug follows the same rule elan.yaml is validated against — it becomes a Kubernetes namespace and an image path — so a name the spec would refuse is refused now rather than at the end of a build. A credential scoped to one project is not stopped from creating another.
curl -X POST "$API/v1/projects" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"slug": "salmon", "display_name": "Salmon",
"repo_url": "https://github.com/acme/salmon"}'Body
| Field | Type | Description | |
|---|---|---|---|
| slug | string | required | 3–16 characters, lowercase letters, digits and single hyphens, starting with a letter: [a-z][a-z0-9]*(-[a-z0-9]+)*. |
| display_name | string | optional | What people see. Free text, never used to derive a name. |
| repo_url | string | optional | The git remote it builds from, up to 256 characters. Filled in from elan.yaml by the first deploy if you leave it out. |
Responses
| Status | Meaning |
|---|---|
| 201 | Created. {project}. |
| 401 | No token, or one that did not verify. |
| 409 | That slug is already a project in this tenant. Nothing was changed. |
| 422 | A slug that does not match the pattern, or an unknown field — named in problems. |
| 503 | This deploy service has no store configured. |
Response body
201 {"project": "salmon"}List projects
GET/v1/projectsdeploy-service
Every project in your tenant, each with enough to draw a row: how many agents it runs, how many deploys it has had, and the last one.
A project nobody registered still appears
One that exists only because something was deployed to it is listed with registered: false, so the list is the index of everything rather than of what somebody typed into a form.
The whole tenant, whatever the credential
A credential scoped to one project still lists every project.
curl "$API/v1/projects" -H "Authorization: Bearer $TOKEN"
Responses
| Status | Meaning |
|---|---|
| 200 | {projects}, sorted by slug. |
| 401 | No token, or one that did not verify. |
| 503 | This deploy service has no store configured. |
Response body
{
"projects": [
{
"slug": "salmon",
"display_name": "Salmon",
"repo_url": "https://github.com/acme/salmon",
"created_at": "2026-10-02T08:11:40+00:00",
"registered": true,
"agents": 3,
"deployments": 9,
"last_deployment_id": "dep_b6a540abdb18482e",
"last_state": "succeeded",
"last_started_at": "2026-10-23T13:06:05+00:00"
}
]
}Remove a project's registration
DELETE/v1/projects/{project}deploy-service
Forgets the project's record. Nothing is torn down — that is what destroying a project does.
Refused while anything is still deployed
Removing the record of a project whose agents are running would leave workloads, a database and an egress port that nothing claims. Destroy the project first, then remove it.
curl -X DELETE "$API/v1/projects/salmon" \ -H "Authorization: Bearer $TOKEN"
Responses
| Status | Meaning |
|---|---|
| 204 | Gone — or was never there. Repeating it is harmless. |
| 401 | No token, or one that did not verify. |
| 403 | Not a tenant admin, or a credential scoped to another project. |
| 409 | It still has deployed agents, named in the message. |
| 503 | This deploy service has no store configured. |
List what is deployed
GET/v1/agentsdeploy-service
Every deployed agent and workflow, what each one is running, and what its manifest declared — read from what the cluster applies, not from the deploy history.
digest is per agent, and agents move independently
A deploy moves only the targets it changed, so two agents of one project can be running different images. This is the answer to "what is my agent running", one row per agent.
manifest is a summary, and absent means not recorded
kind says agent or workflow; input_names lists a workflow's declared inputs by name (their types and defaults are in your own manifest); billing, limits and tools are what the agent declared. An agent last deployed before a field was recorded simply lacks it — read that as unknown, not as "declares nothing".
workload is the pod it runs in
Its own slug for an isolated agent; its group's name for agents that share a pod, restart together and scale as one.
curl "$API/v1/agents?project=salmon" \ -H "Authorization: Bearer $TOKEN"
Parameters
| Field | Type | Description | |
|---|---|---|---|
| project | query | optional | One project. Omitted, every project in the tenant — whatever project the credential was issued for. |
Responses
| Status | Meaning |
|---|---|
| 200 | {agents}. |
| 401 | No token, or one that did not verify. |
| 422 | A project that could not be a project slug. |
| 503 | This deploy service has no config repository configured. |
Response body
{
"agents": [
{
"project": "salmon",
"slug": "hotel-concierge",
"version": "1.4.0",
"digest": "sha256:60a23ddf69f4bc62...",
"workload": "hotel-concierge",
"manifest": {
"kind": "agent",
"display_name": "Hotel concierge",
"description": "Answers guests and books late checkout.",
"model": "gemini-flash-latest",
"limits": {"max_concurrent": 4, "max_llm_calls": 20, "timeout_s": 120},
"tools": [{"ref": "google_search", "kind": "builtin"}]
}
}
]
}Destroy an agent
DELETE/v1/agents/{agent}deploy-service
Takes one agent out of the cluster and removes what it was using: its database and roles, topics and subscriptions, secrets, grants and file storage.
project is required, and that is not pedantry
Agent slugs are unique within a project, not within a tenant, so two projects may each have a worker. Leaving project out is a 422 rather than a guess.
Type the name back
confirm must equal the agent's slug, or nothing happens and you get a 409. The workload leaves the cluster first and its dependencies go second, so nothing restarts against a database that no longer exists. A database is snapshotted before it is dropped. The image is left alone: the agent's siblings are running it.
A grouped agent leaves the shared database behind
Agents that share a pod share its database, which outlives every member but the last. The response names them in kept_for, so "destroyed" does not read as "all of it gone".
The project is a query parameter, so a scoped credential's claim is not checked
Unlike destroying a project, this route checks the tenant and the admin role and not which project the credential was issued for.
curl -X DELETE "$API/v1/agents/hotel-concierge?project=salmon" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"confirm": "hotel-concierge"}'Parameters
| Field | Type | Description | |
|---|---|---|---|
| project | query | required | The project the agent is in. |
| confirm | string | required | The agent's slug, typed back. The body accepts nothing else. |
Responses
| Status | Meaning |
|---|---|
| 200 | Everything it set out to remove is gone. |
| 207 | Some of it survived; failed lists each resource and why. Run it again — what is already gone is skipped. |
| 401 | No token, or one that did not verify. |
| 403 | Not a tenant admin — a service user needs Can write. |
| 409 | confirm does not match the agent's slug. |
| 422 | No project, or a body with an unknown field. |
| 503 | This deploy service has no teardown configured. |
Response body
{
"destroyed": "hotel-concierge",
"commit": "9c1e5d2…",
"removed": 7,
"already_gone": 0,
"failed": []
}Destroy a project
POST/v1/projects/{project}/destroydeploy-service
Every agent in the project, everything they were using, and the project's own resources and namespace.
The same teardown as one agent, for all of them
confirm must equal the project's slug. The response has the same shape, and re-running is safe. Removing the project's registration afterwards is a separate call.
curl -X POST "$API/v1/projects/salmon/destroy" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"confirm": "salmon"}'Body
| Field | Type | Description | |
|---|---|---|---|
| confirm | string | required | The project's slug, typed back. |
Responses
| Status | Meaning |
|---|---|
| 200 | All of it is gone. |
| 207 | Some of it survived; see failed, and run it again. |
| 401 | No token, or one that did not verify. |
| 403 | Not a tenant admin, or a credential scoped to another project. |
| 409 | confirm does not match the project's slug. |
| 422 | A body with an unknown field. |
| 503 | This deploy service has no teardown configured. |
Response body
{
"destroyed": "salmon",
"commit": "4b7a01e…",
"removed": 23,
"already_gone": 1,
"failed": []
}Check what is provisioned
GET/v1/doctordeploy-service
What the platform's record of your resources claims, checked against the cloud. It reports and changes nothing.
unprovisioned is the one to read first
An agent that is running and has no invoke topic recorded: its pod is healthy, the console shows it, and every invocation goes nowhere. Redeploying the project finishes provisioning it.
Ghosts, not orphans
A ghost is a resource the record lists and the cloud no longer has. Something that exists with no record cannot be found by asking the record, so it is not reported here. unknown is what could not be checked at all.
curl "$API/v1/doctor" -H "Authorization: Bearer $TOKEN"
Responses
| Status | Meaning |
|---|---|
| 200 | The report, for the whole tenant. |
| 401 | No token, or one that did not verify. |
| 503 | This deploy service has no reconciler configured. |
Response body
{
"checked": 31,
"healthy": 30,
"unprovisioned": [
{"project": "salmon", "agent": "fish-facts",
"detail": "deployed with no invoke topic recorded, so invocations of it go nowhere. Re-deploy the project to finish provisioning it."}
],
"ghosts": [],
"unknown": []
}Set a secret
POST/v1/secretsdeploy-service
Stores one credential in your tenant's own tree. The value is write-only: no route ever returns it.
Two kinds, and elan.yaml decides where each lands
llm is a model provider key, referenced from llm.keys. secrets is anything else, referenced from a target's secrets: list and read from the environment inside the pod.
A running pod keeps the value it started with
Setting a new value is not live until something replaces the pod — the next deploy that moves it, or a restart.
Tenant-wide, and no admin role needed
Secrets belong to the tenant, not to a project, and any credential in the tenant can set one — including a service user issued for a single project without Can write.
curl -X POST "$API/v1/secrets" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"kind": "llm", "name": "default", "value": "'"$MODEL_KEY"'"}'Body
| Field | Type | Description | |
|---|---|---|---|
| kind | string | required | llm or secrets. |
| name | string | required | 1–63 characters of lowercase letters, digits and hyphens, not starting or ending with a hyphen. No slash and no dot, so it cannot be read as a path; the platform prefixes it with your tenant. |
| value | string | required | The secret. Not empty. |
Responses
| Status | Meaning |
|---|---|
| 204 | Stored. Setting it again adds a new version. |
| 401 | No token, or one that did not verify. |
| 422 | An unknown kind, a name off the pattern, an empty value or an unknown field. |
| 503 | This deploy service has no secret store configured. |
List secrets
GET/v1/secretsdeploy-service
The names that are set and when each last changed. Never a value.
Only what was set through this API
The list is the platform's own record of what it stored, so a value written into the secret store some other way works and is not listed.
curl "$API/v1/secrets" -H "Authorization: Bearer $TOKEN"
Responses
| Status | Meaning |
|---|---|
| 200 | {secrets}, for the whole tenant. |
| 401 | No token, or one that did not verify. |
| 503 | This deploy service has no store configured. |
Response body
{
"secrets": [
{"kind": "llm", "name": "default", "updated_at": "2026-10-21T10:02:11+00:00"},
{"kind": "secrets", "name": "pms-api-key", "updated_at": "2026-10-22T16:40:03+00:00"}
]
}Delete a secret
DELETE/v1/secrets/{kind}/{name}deploy-service
Removes the secret and every version of it.
The failure it causes is delayed
A pod already running keeps the value it read at startup, so nothing breaks at once — and then the next restart does not start at all, because there is nothing left to fetch. Remove the reference from elan.yaml and deploy before deleting a secret something uses.
curl -X DELETE "$API/v1/secrets/secrets/pms-api-key" \ -H "Authorization: Bearer $TOKEN"
Responses
| Status | Meaning |
|---|---|
| 204 | Gone, or was never set. Neither kind nor name is checked here, so a typo also answers 204 — list afterwards to be sure. |
| 401 | No token, or one that did not verify. |
| 503 | This deploy service has no secret store configured. |
Validate an elan.yaml
POST/v1/validatedeploy-service
A dry run with no side effects: nothing is stored, built or provisioned. The same check the CLI's validate command and a deploy both run.
Send the manifests to get the crosscheck
With spec_yaml alone only the file is checked: that it parses and that its tenant is yours. With manifests — what your agents export about themselves — it is also checked against them, which is where most refusals come from: a target declared with no agent behind it, a secret referenced and never declared.
Every problem, not the first
An empty problems means a deploy would be accepted. A non-empty one lists all of them, each with where and message, so one round of fixes is enough.
curl -X POST "$API/v1/validate" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n --rawfile spec elan.yaml '{spec_yaml: $spec}')"Body
| Field | Type | Description | |
|---|---|---|---|
| spec_yaml | string | required | The contents of elan.yaml, not a path to it. |
| manifests | array | optional | The exported manifests of the targets, one object each. |
Responses
| Status | Meaning |
|---|---|
| 200 | {ok, problems} — 200 whether or not there are problems. |
| 401 | No token, or one that did not verify. |
| 422 | A body with a missing spec_yaml or an unknown field. A YAML file that does not parse is a 200 with one problem, not this. |
Response body
{
"ok": false,
"problems": [
{"where": "targets.receipt-ocr",
"message": "declared here but no agent or workflow of that slug exists"}
]
}