Open the console

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 prefixServed byWhat lives there
/v1/invocations, /v1/tickets, /v1/actions, /v1/approvals, /v1/tools, /v1/autonomy, /v1/artifacts, /v1/sessions, /wsrt-serviceStarting and resuming runs, the event stream, tickets, approvals, policies and file transfer.
everything elsedeploy-serviceThe 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 the detail shape, 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

StatusMeaning
200 / 201Here it is, or it was created.
202Accepted and queued. Nothing has happened yet; the reference page says where the outcome arrives.
204Done, nothing to return.
207A destroy that removed some of what it set out to and not all — the body lists what failed.
400A value the route understood and refused: a reserved state key, a bad grain, an id with a colon in it. The message names it.
401No token, or one that did not verify.
402Your account is suspended for an unpaid balance, so invocations are refused until it is settled.
403The token is fine and this is not something it may do.
404No such thing — for you.
409A conflict with the current state: the name is taken, it was already decided, a deploy is already running.
422The request does not match the route's model — a missing or unknown field, a wrong type — or a deploy refused before anything was built.
501This deployment has no such backend wired, such as no ledger or no ticket signing secret. A configuration, not a crash.
502 / 503A 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/invocations starts 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 isRoutesA 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 addressPOST /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 filterGET /v1/actions, GET /v1/actions/labels, GET /v1/autonomy/policies, POST /v1/autonomy/policies/{policy_id}/reset403 for another project; naming none narrows to its own.
On the recorded rowresume, decide, answer an approval, deliver a tool result404 for a run or action recorded in another project.
On the run recordGET /v1/runs, GET /v1/runs/{invocation_id}Narrowed to its own project; 404 for another.
Not checked — tenant onlyGET 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.

  1. The server sends {"type": "ready"} unprompted once you are in.
  2. Send {"type": "subscribe", "session_id": …} for each conversation, and wait for subscribed before you invoke — nothing is buffered for a session nobody is watching yet.
  3. 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.

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