SDK
TypeScript and React
The TypeScript client has the same namespaces as the Python one, and the other SDK pages show both side by side. This page is what differs, and the second entry point, elan-sdk/react, which is what a browser gets.
ElanClient
new ElanClient(options) takes baseUrl and project, one credential — clientId with clientSecret, or token — and optionally fetch, timeoutMs, streamTimeoutMs and wsUrl. ElanClient.fromEnv(overrides) reads the same environment the Python client does and lets anything you pass win. The base URL resolves the same way too: argument, environment, production — see Install and configure.
import { ElanClient, ProjectNotNamed, replyOk } from "elan-sdk";
// From the environment, with anything you pass taking precedence.
const elan = ElanClient.fromEnv({ project: "salmon" });
try {
const reply = await elan.runtime.run("hotel-concierge", "Is late checkout available?");
if (replyOk(reply)) console.log(reply.text);
} catch (error) {
if (error instanceof ProjectNotNamed) console.error(error.sentence);
else throw error;
}Every method returns a promise, and every failure is the same ElanError subclass the Python client raises, with the service's sentence on sentence and the whole message, every problem included, on message. The main entry has no runtime dependencies and needs Node 22.18 or newer.
Where it differs from Python
| Python | TypeScript | |
|---|---|---|
| Names | snake_case methods and keyword arguments | camelCase methods and an options object: decide_batch(batch, approved=True) is decideBatch(batch, { approved: true }) |
| Returned objects | Typed dataclasses and models | The wire's own JSON, so its fields stay snake_case: reply.invocation_id, wallet.balance_micros. Only an UploadTicket is camelCase. |
| Derived facts | Properties: reply.ok, balance.available_units, call.expired | Functions: replyOk(reply), availableUnits(balance), expired(call), toReply(run), forTool(scopes, …) |
| Removing a file | artifacts.delete() | artifacts.remove() |
| Uploading | artifacts.upload() sends the bytes for you | declareUpload(), then putBytes(ticket, body) |
| Watching | async with runtime.watching(id) as stream, then stream.events() | runtime.watching(id, async (stream) => …); the stream takes handlers and has no async iterator |
| A conversation | async with; one socket for every turn; approve() and reject() return the resumed Reply | A plain object; each say() opens its own socket; approve() and reject() return Accepted |
| A dropped socket | Ends the read; open a new stream | Reconnects with backoff and resubscribes; events in the gap are lost |
| Credential mistakes | ValueError; a ticket passed as a token is recognized and refused | AmbiguousCredential; only a token starting tkt_ or ticket_ is refused as a ticket |
| Error text | exc.message | error.sentence |
| Sync | Elan, a blocking facade | Async only |
Two smaller gaps worth knowing: the TypeScript invoke() does not refuse an endUserId while you are watching, so that mistake is a silent wait rather than an error, and its analytics.latency({ includeFailures }) option currently has no effect. The policy writes in autonomy are refused today in both clients, for different reasons — Approvals and autonomy says what to do instead.
The React entry point
elan-sdk/react is a provider, a few hooks, and the event helpers a page needs to render a run. React is a peer dependency, needed by this entry and nothing else.
| Export | What it gives you |
|---|---|
| ElanProvider | Holds one client, and at most one socket, for the tree below it. Takes a required baseUrl, and ticket or session; optionally project, wsUrl, timeoutMs, fetch, or a ready-made client. |
| useElan() | The provider's client. Throws outside a provider. |
| useAgentEvents(sessionId, { invocationId? }) | Live events for one session: events, a reply folded exactly as runtime.run() folds one, the socket's state and its last error. Without invocationId the fold covers every run on the session. Subscribes on mount and unsubscribes on unmount. |
| useRun(invocationId) | One run read back from the record: run, loading, error and reload(). |
| useElanStream() | The provider's socket, or null when it was given no ticket. |
It also exports EventStream and EventLog, the reply helpers (textOf, endsTheRun, replyOk, saidNothing, timedOut, stepsOf), the money helpers, the error classes and the wire types.
import { ElanClient } from "elan-sdk";
// A tenant-admin service user, on the server only.
const elan = ElanClient.fromEnv();
// POST /api/ticket in your own app, after you have checked who is signed in.
export async function ticketFor(userId: string): Promise<string> {
const minted = await elan.tickets.mint(userId, { origin: "https://app.example.com" });
return minted.ticket;
}
// Start the run for that user. Its events go to their ticket, not to you.
export async function ask(userId: string, sessionId: string, text: string) {
return elan.runtime.invoke("hotel-concierge", text, { sessionId, endUserId: userId });
}import { ElanProvider, useAgentEvents } from "elan-sdk/react";
// DEFINED ONCE, outside the component: the provider reconnects whenever this
// function's identity changes, so an inline arrow would reopen the socket on
// every render.
const ticket = () => fetch("/api/ticket", { method: "POST" }).then((r) => r.text());
export function App({ sessionId }: { sessionId: string }) {
return (
<ElanProvider baseUrl="https://api.openelan.ai" ticket={ticket}>
<Chat sessionId={sessionId} />
</ElanProvider>
);
}
function Chat({ sessionId }: { sessionId: string }) {
const { reply, state, error } = useAgentEvents(sessionId);
if (state === "closed" && error) return <p>{error}</p>;
if (reply.pause) return <p>{reply.pause.message}</p>;
return <p>{reply.text}</p>;
}- One socket for the whole tree. Handlers are counted per session, so two components watching one conversation share one subscription, and the server is told to stop only when the last of them unmounts. The socket closes when the provider unmounts.
- The ticket is a function, not a string, and it is called again on every connect. A ticket lives about five minutes and the socket reconnects with backoff, so a cached one would be refused on the first reconnect. Keep the function stable — define it outside the component or wrap it in
useCallback— because the provider opens a new socket whenever it changes. - Minting is your backend's job.
tickets.mint(userId, { origin })needs a tenant admin credential, pins the ticket to one origin exactly — a suffix does not match — and returns it withexpires_in. Start the run withinvoke()andendUserId, never withrun(): the events are addressed to the user, so only their ticket sees them.
What it deliberately does not export
clientCredentials and staticToken are absent, and ElanClient is exported only as a type, so a page cannot build a client holding a secret. The guarantee is the absence, not a comment. Those live on the main entry, which your backend imports.
Two credentials can reach a page, and neither is a secret
| Prop | What it is | What it may do |
|---|---|---|
| ticket | One of your end users, through a ticket your backend minted. | Open the event stream for that user, and nothing else. Every HTTP route refuses it. |
| session | The platform console's own httpOnly session cookie, with a CSRF supplier. | Whatever that signed-in person may do over HTTP. It opens no socket. |
In a ticket-only page, the client cannot make HTTP calls
A provider given only a ticket still builds a client, for its configuration, but gives it a placeholder token — so every HTTP method on useElan(), and useRun with it, fails as Unauthenticated. In an end user's page the socket is the only path. When it drops, the stream reconnects on its own but what was published in the gap is gone, so ask your own backend to read the run back with runs.get() — see when the socket drops. A provider given a session has no socket at all; useAgentEvents there stays empty and useRun is the read path.