Open the console

SDK

Install and configure

Two clients, one for Python and one for TypeScript, with one version number between them. Both are the surface a running backend needs — start runs, read them back, answer what they ask, move your users' credit — over the same API the console and the CLI use.

Install

Both are published as elan-sdk, on PyPI and on npm. The Python client needs Python 3.12 or newer and is imported as elan_sdk; the TypeScript client needs Node 22.18 or newer, has no runtime dependencies, and ships a second entry point, elan-sdk/react, for which React is an optional peer dependency.

Request
uv add elan-sdk          # or: pip install elan-sdk

The environment it reads

Four variables, and no file. Nothing is written to disk or a keyring: a backend's credential belongs to its own environment.

VariableWhat it isWhen unset
ELAN_API_URLThe platform's base URL. One host serves every route.The production API the package was published for.
ELAN_PROJECTThe project every call defaults to.Empty. Starting a run, a session call and a wallet call refuse locally; most listings read every project the credential may see.
ELAN_CLIENT_IDA service user's client id.No credential: building the client fails.
ELAN_CLIENT_SECRETThat service user's secret.As above.

The base URL resolves in one order everywhere: an explicit argument, then ELAN_API_URL, then production. The environment wins over the default even when you build the client yourself, so a shell pointed at another platform is never silently sent to production. The project and the credential are read from the environment only by from_env() and fromEnv(); the constructors take them as arguments. This console's platform answers at https://api.openelan.ai.

The CLI reads a different variable

The CLI's base URL is ELAN_API, not ELAN_API_URL. A shell that points one at a non-production platform points the other at production. Set both when you are working anywhere else.

Authenticating

A client holds exactly one credential. There are three, and which one you use depends on where the code runs.

  • A service user's id and secret, for a backend. The client exchanges them at POST /v1/token (form encoded, with no scope — the platform adds the audience itself), keeps one token for every call, and mints a new one sixty seconds before it expires rather than after a 401. Create one with Call it from your code.
  • A token you already hold, passed as token. It is used as given and never refreshed, so an expired one stays expired. You can also pass an object with an async token() method to mint your own.
  • A ticket, in a browser. Your backend mints one for one of your end users with tickets.mint() and the page hands it to the React provider. A ticket opens the event stream and nothing else; every HTTP route answers 403. The Python client recognizes one passed as token and refuses it with TicketIsNotACredential.

Passing a token together with an id and secret is refused, and so is passing nothing — a ValueError in Python, AmbiguousCredential in TypeScript. whoami() decodes the token's claims without verifying them, which answers “which tenant and project am I?” with no round trip. Authentication covers what each credential may reach.

Request
from elan_sdk import Elan

# Reads ELAN_API_URL, ELAN_PROJECT,
# ELAN_CLIENT_ID and ELAN_CLIENT_SECRET.
with Elan.from_env() as elan:
    print(elan.whoami()["tenant_id"])
    reply = elan.runtime.run("hotel-concierge", "Is late checkout available?")
    print(reply.text)
Request
import os

from elan_sdk import AsyncElan, Elan

# A service user's id and secret: the client performs the grant and keeps
# one token until shortly before it expires.
elan = Elan(project="salmon",
            client_id=os.environ["MY_CLIENT_ID"],
            client_secret=os.environ["MY_CLIENT_SECRET"])

# Or a token you already hold. It is never refreshed.
other = AsyncElan(project="salmon", token=os.environ["MY_TOKEN"])

Sync and async

AsyncElan is the implementation. Elan is the same object run on one event loop in one background thread, so every async method appears on it with the same signature and blocks instead. It keeps the token cache and the connection pool between calls, which is why it exists rather than an asyncio.run per call. It refuses to be built inside a running event loop — blocking there would deadlock it — so an async application uses AsyncElan.

Two methods are not wrapped. runtime.watching() and runtime.conversation() hold a socket open across several awaits, and a blocking wrapper would own an event loop you cannot drive. Elan hands them back unwrapped, so they work only as async with on AsyncElan. A synchronous caller that wants a reply uses runtime.run(), which does the socket work itself.

Request
import asyncio

from elan_sdk import AsyncElan


async def main() -> None:
    async with AsyncElan.from_env() as elan:
        reply = await elan.runtime.run("hotel-concierge", "Is late checkout available?")
        print(reply.text)


asyncio.run(main())

Close a client when you are done with it: with or close() for Elan, async with or aclose() for AsyncElan. The TypeScript client is async throughout and holds no pool to close: a socket opened by run() or watching() closes when that call returns, and one you open yourself with elan.stream() is yours to close().

Timeouts: thirty seconds per HTTP call, and five minutes for a whole run on the event stream, because a cold start is real. In Python pass config=Config(base_url=..., timeout=..., stream_timeout=...); in TypeScript, timeoutMs and streamTimeoutMs.

Errors

Every failure is a subclass of ElanError, with the same names in both languages, so you branch on a type rather than a status code. Each carries status (0 when nothing answered), code, problems, method, path and retryable. The service's own sentence is message in Python and sentence in TypeScript, and the printed form lists every problem, never only the first.

StatusTypeWhat it means
—TransportErrorNever reached the service: DNS, TLS, a timeout. Safe to retry a read; never retry a credit blindly.
400, 422BadRequestThe body or a parameter was refused. Every problem the service named is on it.
401UnauthenticatedNo token, or it did not verify.
401CredentialsRefusedThe token endpoint refused the id and secret. It does not say which half was wrong.
402PaymentRequiredInsufficient funds, or a suspended tenant. A resume can raise it where the original run did not.
403ForbiddenYour token is fine and this is not yours: another project, a missing admin role, a ticket on an HTTP route.
404NotFoundNo such thing for you. Another tenant's resource reads the same, so it does not prove absence.
409ConflictIt already exists, or it was already answered.
501NotWiredThis deployment has no such backend (ledger, warehouse). Render an empty state, not an error.
502DependencyRefusedA dependency refused. On an invoke, almost always a wrong project.
503UnavailableTry again. From the token endpoint it means your credential was never judged.
other 5xxServerErrorAnything else the server failed at.

Three are raised before any request is sent, because no status code could say what they say: ProjectNotNamed (an empty project means the platform's own, not yours, and would come back as a bare 502), EndUserEventsElsewhere and TicketIsNotACredential. The event stream has its own: StreamError, StreamRefused and TicketRefused for a refused credential (close code 1008, which retrying will not fix), and StreamClosed.

The SDK retries nothing, ever

Several routes are not idempotent — crediting a wallet twice issues two credits — and a client cannot tell from a timeout whether the request arrived. retryable is a fact about the failure for your own retry policy to read; only you know whether the call was safe to repeat.

Versioning and the wire types

One version number ships to both registries, so two installed clients with the same version describe the same API. What changed in each is on the changelogs page.

The event envelope, the policy types and the shared constants are not hand-copied. The Python client depends on the platform's own wire models; the TypeScript client's types are generated from those same models. Response shapes with no model behind them are written by hand in both and checked against the real services by contract tests.

A field or an event kind this version has never seen is not an error. The clients ignore unknown fields and let an unknown kind through, so a newer service does not break an installed copy. KNOWN_KINDS is the set this version was built knowing.

What it does not cover

Projects, deployments, secrets, validating an elan.yaml, service users, members, tenants and signing in. Those set a platform up rather than serve a request, and they belong to the CLI and the API. There is no deploy method, and agents only reads what is deployed. There are no webhooks either: work your backend owes an agent is polled.

Where to go next

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