API
Overview
Everything the CLI and the console do, they do over this API, and so can you. This page is what the reference pages assume: where the API is, which service answers which path, the token every call carries, and the conventions that hold across all of them.
The base URL
One host serves the whole API:
https://api.openelan.ai
It is where the CLI and both SDKs point when nothing says otherwise, so they need no setting to reach it. The event stream lives on it too, as wss://api.openelan.ai/ws.
Two services, one host
Behind that host are two services, split by path prefix. The split is invisible to a caller who gets everything right, and worth knowing the moment something answers 404.
| Path prefix | Served by | What lives there |
|---|---|---|
| /v1/invocations, /v1/tickets, /v1/actions, /v1/approvals, /v1/tools, /v1/autonomy, /v1/artifacts, /v1/sessions, /ws | rt-service | Starting and resuming runs, the event stream, tickets, approvals, policies and file transfer. |
| everything else | deploy-service | The token exchange, projects, deployments, secrets, service users, wallets, runs read back, conversations under /v1/projects/{project}/sessions, usage and analytics. |
Every endpoint on the reference pages names the service that serves it, beside its path. Two things follow from the split:
- A path that is not one of rt-service's prefixes goes to deploy-service — it does not fail to route. A mistyped rt-service path therefore comes back as deploy-service's 404, for a path it has never heard of.
- You can tell which one answered from the error body. rt-service says
{"detail": …}; deploy-service says{"error": …}. A query parameter either one refuses comes back in thedetailshape, so the body tells you which service only for a refusal the handler itself wrote.
Authentication
Every route except /healthz, /readyz, GET /v1/auth, POST /v1/token and a few public routes the console and its emails use — signing in, unsubscribing, the contact form — needs a bearer token:
Authorization: Bearer <token>
A backend gets one from POST /v1/token, exchanging a service user's client id and secret. A person gets one by signing in to the console or with elan login. There are no long-lived API keys. The authentication concept covers the kinds of credential and what each one reaches.
A ticket opens the event stream and nothing else
A ticket is what you give one of your end users' browsers. It is accepted by /ws and refused with 403 on every HTTP route rt-service serves, including the artifact routes. Your backend acts; the browser watches.
Set $API and $TOKEN
Every curl sample on these pages uses $API for the base URL and $TOKEN for a bearer token, and the WebSocket samples use $WS. This sets all three:
# The platform, and a service user's id and secret from the project's # Settings → API access page. export API="https://api.openelan.ai" export WS="wss://api.openelan.ai" export CLIENT_ID="…" export CLIENT_SECRET="…" # A bearer token. It lasts about an hour — expires_in says exactly — # so fetch a new one when a call starts answering 401. export TOKEN=$(curl -s -X POST "$API/v1/token" \ -d grant_type=client_credentials \ -d client_id="$CLIENT_ID" \ -d client_secret="$CLIENT_SECRET" | jq -r .access_token) curl "$API/v1/projects" -H "Authorization: Bearer $TOKEN"
The Python and Node samples use API, TOKEN and WS with the same meaning:
import httpx
API = "https://api.openelan.ai"
WS = "wss://api.openelan.ai"
TOKEN = httpx.post(
f"{API}/v1/token",
data={ # form encoded, not JSON
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
},
).raise_for_status().json()["access_token"]const API = "https://api.openelan.ai";
const WS = "wss://api.openelan.ai";
const { access_token: TOKEN } = await fetch(`${API}/v1/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
}).then((r) => r.json());The SDKs do this exchange themselves, and refresh before the hour is up, from ELAN_CLIENT_ID and ELAN_CLIENT_SECRET. Samples labeled Python SDK and Node SDK assume a client called elan; the Python ones are written for the async client, which is why they await.
Conventions
Bodies
Requests and responses are JSON, with one exception: POST /v1/token is an OAuth token endpoint and takes application/x-www-form-urlencoded. Send Content-Type: application/json with every other body.
Unknown fields are not handled the same everywhere. Most bodies reject a field they do not know with a 422 naming it — deploys, rollbacks, secrets, validation, projects, conversations, resumes, approvals, tool results and artifact uploads among them. A few still ignore one silently, among them POST /v1/invocations, a confirmation's decide route, a stored policy and the wallet and service-user writes. Each reference page says which. Spell field names exactly; on the lenient routes a typo is not an error, it is a setting that never arrived.
Status codes
| Status | Meaning |
|---|---|
| 200 / 201 | Here it is, or it was created. |
| 202 | Accepted and queued. Nothing has happened yet; the reference page says where the outcome arrives. |
| 204 | Done, nothing to return. |
| 207 | A destroy that removed some of what it set out to and not all — the body lists what failed. |
| 400 | A value the route understood and refused: a reserved state key, a bad grain, an id with a colon in it. The message names it. |
| 401 | No token, or one that did not verify. |
| 402 | Your account is suspended for an unpaid balance, so invocations are refused until it is settled. |
| 403 | The token is fine and this is not something it may do. |
| 404 | No such thing — for you. |
| 409 | A conflict with the current state: the name is taken, it was already decided, a deploy is already running. |
| 422 | The request does not match the route's model — a missing or unknown field, a wrong type — or a deploy refused before anything was built. |
| 501 | This deployment has no such backend wired, such as no ledger or no ticket signing secret. A configuration, not a crash. |
| 502 / 503 | A service behind this one refused or could not be reached. Retry; the reference page says whether anything was recorded. |
One exception worth knowing: a deploy-service route that reads a JSON body answers 500, not 422, for a body that is not valid JSON at all.
Error bodies
Every refusal carries a sentence written for the person reading it. Its shape depends on who refused:
// deploy-service
{"error": "a project named 'salmon' already exists in this tenant."}
// deploy-service, a body that does not match the route's model
{"error": "the request body is not acceptable",
"problems": [{"where": "bogus", "message": "Extra inputs are not permitted"}]}
// rt-service, and either service refusing a query parameter
{"detail": "this credential may only act on project 'salmon'"}
{"detail": [{"type": "missing", "loc": ["query", "since"],
"msg": "Field required", "input": null}]}
// POST /v1/token, which speaks OAuth
{"error": "invalid_client",
"error_description": "that client id and secret were not accepted"}401, 403 and 404
- 401 is about the credential itself: absent, expired, malformed or not issued for this API. deploy-service answers every one of those with the same
{"error": "unauthenticated"}, on purpose, so the answer does not say which half to go and find. - 403 means the credential is good and the act is not allowed: the route needs the tenant admin role, a project-scoped credential named another project, or a ticket was used where only a token will do.
- 404 means absent for you. Another tenant's project, run, deployment log or action is reported as missing rather than forbidden, so ids and names cannot be probed. The exceptions are a project in a path (below), which a scoped credential is refused with 403, and
GET /v1/deployments/{deployment_id}, which answers 403 for another tenant's deployment.
Idempotency
There is no idempotency key header. Whether a repeat is safe is a property of each route, and the reference pages say so where it matters. The ones that bite:
- Safe to repeat: creating a wallet (200 the second time), setting a secret, deleting a secret, destroying (what is already gone is skipped), setting a wallet's status.
- Refused the second time: creating a project or a conversation that exists (409), deciding an action or answering a tool call already answered (409 — treat it as success), deploying while a deploy of that project runs (409, naming it).
- Not idempotent at all:
POST /v1/invocationsstarts a new run every time, and crediting a wallet credits it again. - Rotates rather than repeats: creating a service user whose name already exists issues it a new secret and invalidates the old one.
Paging
Lists page with limit and offset, newest first, and most return the matching total in the body beside the rows. deploy-service clamps a limit outside its range rather than refusing it — usually to 200 — while rt-service's action inbox refuses one outside 1–500 with a 422. Each list documents its own default and ceiling.
Project scoping: what is checked, and what is not
A service user is issued for one project, and its token carries that project as a claim. A person's token carries none and reaches every project in the tenant. Where a route checks the claim depends on where the project arrives, and several routes do not check it at all — a credential issued for one project reaches the whole tenant there. This is what the code does today:
| Where the project is | Routes | A credential scoped to another project gets |
|---|---|---|
| In the path | /v1/projects/{project}/… | 403, checked once for every such route on deploy-service — deploys, rollbacks, destroying a project, service users, wallets and conversations. |
| In the body or query, as an address | POST /v1/invocations, POST /v1/tickets, POST /v1/tickets/self, PUT /v1/autonomy/policies, /v1/artifacts… | 403. An empty project means the platform's own project, so a scoped credential that omits it is refused too. |
| In the query, as a filter | GET /v1/actions, GET /v1/actions/labels, GET /v1/autonomy/policies, POST /v1/autonomy/policies/{policy_id}/reset | 403 for another project; naming none narrows to its own. |
| On the recorded row | resume, decide, answer an approval, deliver a tool result | 404 for a run or action recorded in another project. |
| On the run record | GET /v1/runs, GET /v1/runs/{invocation_id} | Narrowed to its own project; 404 for another. |
| Not checked — tenant only | GET and POST /v1/projects, GET /v1/agents, DELETE /v1/agents/{agent}, /v1/doctor, /v1/secrets…, /v1/validate, GET /v1/deployments…, /v1/cost…, /v1/usage…, /v1/balance, /v1/analytics/… | The whole tenant: every project's deployments and build logs, costs, usage, run analytics and traces, and the tenant's secrets. A project parameter on these narrows the answer; it is not checked against the credential. |
So a project-scoped credential is a guard on what a backend can run and change in its own project, not on what it can read about the others. Treat any credential you hand out as able to read tenant-wide figures, and do not rely on its scope to keep a sibling project's secrets or agents out of its reach.
The admin role
A service user created with Can write carries the tenant's admin role. It is required to delete a project, destroy an agent or a project, manage service users, create, credit, adjust or freeze a wallet, mint a ticket for an end user, invoke with end_user_id, write the tenant-wide policy cap, or act for a colleague rather than for yourself or one of your end users. It is not required to create a project, deploy, roll back, or set and delete secrets — any credential in the tenant can do those.
The event stream: /ws?ticket=
Replies arrive on a WebSocket, not in HTTP responses. Connect to wss://api.openelan.ai/ws?ticket=<credential> — a ticket for a browser, or your own bearer token from a backend. The credential is checked on the handshake, before the socket is accepted: without one, or with one that does not verify, the connection is closed with code 1008. There is no authentication frame; a client that sends one and waits for an answer waits for ever.
- The server sends
{"type": "ready"}unprompted once you are in. - Send
{"type": "subscribe", "session_id": …}for each conversation, and wait forsubscribedbefore you invoke — nothing is buffered for a session nobody is watching yet. - Events for that session arrive as JSON frames. Filter them on
invocation_id, because one conversation carries many runs.
An event reaches a socket only if the run belongs to the same subject and tenant as the credential that opened it, so a run started for one of your end users goes to their ticket and not to your token. The project a ticket carries is recorded but does not filter events. The frame vocabulary, the two kinds of ticket and what a paused run looks like are on the invocations page.