Open the console

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.

Request
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

PythonTypeScript
Namessnake_case methods and keyword argumentscamelCase methods and an options object: decide_batch(batch, approved=True) is decideBatch(batch, { approved: true })
Returned objectsTyped dataclasses and modelsThe wire's own JSON, so its fields stay snake_case: reply.invocation_id, wallet.balance_micros. Only an UploadTicket is camelCase.
Derived factsProperties: reply.ok, balance.available_units, call.expiredFunctions: replyOk(reply), availableUnits(balance), expired(call), toReply(run), forTool(scopes, …)
Removing a fileartifacts.delete()artifacts.remove()
Uploadingartifacts.upload() sends the bytes for youdeclareUpload(), then putBytes(ticket, body)
Watchingasync with runtime.watching(id) as stream, then stream.events()runtime.watching(id, async (stream) => …); the stream takes handlers and has no async iterator
A conversationasync with; one socket for every turn; approve() and reject() return the resumed ReplyA plain object; each say() opens its own socket; approve() and reject() return Accepted
A dropped socketEnds the read; open a new streamReconnects with backoff and resubscribes; events in the gap are lost
Credential mistakesValueError; a ticket passed as a token is recognized and refusedAmbiguousCredential; only a token starting tkt_ or ticket_ is refused as a ticket
Error textexc.messageerror.sentence
SyncElan, a blocking facadeAsync 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.

ExportWhat it gives you
ElanProviderHolds 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.

Request
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 });
}
Request
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 with expires_in. Start the run with invoke() and endUserId, never with run(): 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

PropWhat it isWhat it may do
ticketOne 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.
sessionThe 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.

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