Aria SDK

Put Aria, the revenexx assistant, into a JavaScript or TypeScript app with a typed client for threads, whole answers, streamed answers and conversation history.

@revenexx/aria-sdk is the typed client for the Aria API, for developers putting the revenexx assistant into their own app. It creates threads, asks for a whole answer or streams one as typed events, and lists, reopens and deletes a user's conversations. It is ESM, framework agnostic, ships its TypeScript types, and is the same client the Aria chat widget runs on.

Your app stays in charge of the user's session. The SDK never signs anyone in and never assumes a Cockpit session: you give it a function that returns the user's token, and it calls that function on every request.

Status: shipped (0.x), MIT licensed. The API can still change between minor versions before 1.0, see Versioning.

Install

Bash
npm install @revenexx/aria-sdk

The package is public on npm, so you need no npm login and no .npmrc entry. If an old .npmrc maps @revenexx to another registry (for example @revenexx:registry=https://npm.pkg.github.com), remove that line: it sends every @revenexx install away from npm, where 0.3.0 and later are published.

For a page without a bundler there is a standalone browser build that sets a global AriaSDK:

HTML
<script src="https://cdn.jsdelivr.net/npm/@revenexx/aria-sdk@0.5/dist/aria-sdk.global.js"></script>
RuntimeMinimum
Node20
Chrome, Edge80
Firefox105
Safari14.1
BundlerAny that resolves package exports and ESM

The package is ESM only; there is no CommonJS build, so require() does not load it.

Quick start

TypeScript
import { createAriaClient } from '@revenexx/aria-sdk';

const aria = createAriaClient({
  getToken: async () => session.accessToken,
  getTenantId: () => currentTenant,
  getLanguage: () => 'en',
});

// One conversation: create a thread once, then stream turns into it.
const threadId = await aria.createThread();

for await (const event of aria.stream({
  threadId,
  message: 'How did revenue move last quarter?',
})) {
  if (event.type === 'delta') {
    process.stdout.write(event.text);
  }
}

Each turn takes an optional model, a model id from the API's model list; leave it out for Aria's default.

For a card that has nowhere to show partial text, ask() returns the whole answer at once. Leave out threadId and the turn is stateless: nothing is kept.

TypeScript
const answer = await aria.ask({ message: 'Summarise yesterday in two sentences.' });
render(answer.text);

endpoint defaults to the production API, https://aria.revenexx.ai/api. Set it only to reach a different deployment. A client with a wrong configuration therefore talks to production instead of failing, which matters most in tests.

Authentication for your app

The SDK sends whatever getToken() returns as Authorization: Bearer <token>. That is a signed-in user's access token from ID, the same one your app already holds after sign-in. After a 401, the SDK calls getToken(true) once so you can refresh, then retries the request once. Return null to send no token.

The token must be a JWT, carry the user's organisation, and be issued for the Revenue Cloud, and the user must hold a Revenue Cloud role; Aria and the model service behind it check these. The Aria API page explains each condition and the error you get when one is missing.

getTenantId() sends X-Tenant-Id. The token picks the organisation, not the tenant, so return the tenant the user is working in. It must be one of the organisation's tenants, or the request fails with 403. You can leave it out when the organisation owns exactly one tenant; with several, it is required. Return null (or an empty string) while your app has no active tenant, and no header is sent.

All providers (getToken, getTenantId, getLanguage, getHeaders, and includeFollowUps when it is a function) are read on every request, so a user can switch tenant or language without you rebuilding the client. A provider that throws counts as one that returned nothing: the request goes out without that header instead of failing.

Streaming events

stream() yields a discriminated union. Branch on event.type. The names are the SDK's own, in camelCase: answerDone is the server's done event, toolTrace its tool_trace, and a server error event becomes a thrown ApiError rather than an event.

typePayloadMeaning
deltatextThe next piece of the answer. Append in order.
statusstatusA tool call started. status.runId pairs it with its toolTrace.
toolTraceentryA tool call finished or failed, with its input and output.
generatingnoneA tool finished and the model is writing again.
answerDoneusageThe answer is complete. Follow-ups may still arrive.
followUpsitemsSuggested next questions. Only when you turned them on.
pausedreason, message, usageThe turn ran out of steps and can be continued. See Pauses.
unknownname, dataAn event this SDK version does not know, passed through instead of dropped.

Two things to know before you write that switch:

  • answerDone is not the end of the stream when follow-ups are on: they arrive after it.
  • generating arrives once per tool, not on a timer. Nothing arrives during a long tool call, so it cannot drive a liveness timeout.

Tool icons come from each tool's own metadata, so treat them as untrusted: pass them through safeIconSrc() and render the result only as <img src>. toolLabel() gives a display name for a tool that never comes out blank.

The wire format behind these events is on the Aria API page.

Follow-up suggestions

Follow-ups are off by default, because they cost a second model call. Turn them on with includeFollowUps: true, or pass a function to decide per turn:

TypeScript
let suggestionsOn = true;

const aria = createAriaClient({
  getToken: async () => session.accessToken,
  includeFollowUps: () => suggestionsOn,
});

With them off, the server skips that call entirely and the stream closes right after answerDone.

Pauses and resuming

A long turn can run out of steps. That arrives as a paused event, or from ask() as canContinue: true with a pausedReason. It is not a failure: the thread keeps its progress, and you continue by sending a turn with resume: true and no message.

TypeScript
for await (const event of aria.stream({ threadId, resume: true })) {
  if (event.type === 'delta') {
    appendText(event.text);
  }
}

A stateless turn (no threadId) can never pause and never be resumed, because there is no thread to keep its progress in. If it runs out of steps, it fails.

Conversation history

A user's earlier conversations can be listed, reopened and deleted. All three calls see only the threads of the user behind the token, in the active tenant. A thread with no activity for 30 days is deleted.

listThreads() returns the most recent activity first, one page at a time. Pass a page's nextCursor back as cursor for the next, older page; it is null on the last one. Cursors are opaque, so store them as they are.

TypeScript
const first = await aria.listThreads({ limit: 20 });
for (const thread of first.threads) {
  render(thread.title ?? 'New conversation');
}

if (first.nextCursor !== null) {
  const older = await aria.listThreads({ cursor: first.nextCursor });
}

getMessages() reads one thread back. The first page holds the newest messages and its nextCursor leads to older ones, while each page is in chronological order: render the first page, then put each older page above it. Tool calls are left out unless you pass includeToolCalls: true. An assistant message with isStopped: true belongs to a turn that never finished its answer.

TypeScript
const page = await aria.getMessages(threadId, { includeToolCalls: true });
for (const item of page.messages) {
  render(`${item.role}: ${item.content}`);
}

To continue a reopened thread, send turns with its threadId as usual. deleteThread() removes a thread and its whole history:

TypeScript
await aria.deleteThread(threadId);

getMessages() and deleteThread() on a thread that does not exist, or belongs to another user or tenant, fail with serverCode: 'not_found': the server never confirms that someone else's thread exists. A turn is different: ask() or stream() with an unknown threadId, including a deleted one, starts that thread with no history, and only another user's thread fails. So before you continue an old conversation, read it with getMessages(). The history calls arrived in 0.5.0.

Language

getLanguage() returns a language tag such as de or en. The SDK sends it as Accept-Language, with English as the fallback, which makes the server write the answer, follow-ups, pause message and Aria's own error messages in that language. Return a tag Aria does not support and you get English. Leave getLanguage out and the answer follows the language the user wrote in.

Page context

getContext() is reserved for passing what the user is looking at. The server does not accept it yet, so the value is not sent (as of 2026-10). You can wire it now; it takes effect once the server supports it.

Extra headers and a custom fetch

getHeaders() adds headers of your own to every request, such as a request id for tracing. It is read on every request and may be async:

TypeScript
const aria = createAriaClient({
  getToken: async () => session.accessToken,
  getHeaders: () => ({ 'X-Request-Id': crypto.randomUUID() }),
});

The SDK's own headers win. A Content-Type, Authorization, X-Tenant-Id or Accept-Language you return is dropped whenever the SDK sets that header itself, in any letter case. A getHeaders() that throws sends no extra headers instead of failing the request.

fetch replaces the global fetch the client sends with, for an instrumented or proxied client or a runtime without one. It is called as a plain function, so passing window.fetch unbound is safe:

TypeScript
const aria = createAriaClient({
  getToken: async () => session.accessToken,
  fetch: (input, init) => fetch(input, { ...init, keepalive: true }),
});

Errors

Every failed call rejects with an ApiError, so one catch covers the whole client. Branch on code for what happened and on serverCode for why. The exception is an argument that can only be a bug in your code, such as an empty thread id: that rejects with a TypeError or RangeError before any request goes out.

TypeScript
import { ApiError } from '@revenexx/aria-sdk';

async function send(text: string): Promise<void> {
  try {
    const answer = await aria.ask({ message: text });
    render(answer.text);
  } catch (error) {
    if (!(error instanceof ApiError)) throw error;

    switch (error.code) {
      case 'aborted':
        return; // the user stopped it
      case 'network_error':
        return showOffline(); // never reached the server
      default:
        return showError(error.serverCode, error.serverMessage);
    }
  }
}
codeWhenstatus
request_failedThe server refused the request before streaming started.The HTTP status
stream_errorThe stream was open and then reported an error.0
network_errorThe request never reached the server.0
abortedYour AbortSignal fired. A stop, not a failure.0

serverCode is the server's machine-readable code, such as unauthorized, forbidden or rate_limited, and serverMessage its text, in the language getLanguage() asked for where the server translates it. The SDK writes no user-facing text of its own. The Aria API page lists every server code.

AI switched off for the tenant. A tenant can switch AI off in its settings. Every turn is then refused before any model call, with status: 403 and serverCode: 'feature_disabled'. Nothing the client sends changes that, so hide the assistant instead of retrying:

TypeScript
const switchedOff = (error: unknown) =>
  error instanceof ApiError && error.status === 403 && error.serverCode === 'feature_disabled';

Wallet exhausted. When the tenant's AI credit wallet is empty, the model call is refused. There is no dedicated code for that yet (as of 2026-10). It reaches you as serverCode: 'bad_gateway' with a serverMessage that names upstream status 402: from ask() as code: 'request_failed' with status: 502, and from stream() as code: 'stream_error', because the model call starts after the stream has opened. Treat bad_gateway as "not available right now" and don't retry it automatically.

Threads refused. A caller that may not use threads at all gets serverCode: 'forbidden' with a reason, which threadRefusalOf(error) returns (null for any other error, including a forbidden about the tenant):

ReasonCallerRefused
impersonation_forbiddenAn operator acting for a tenantEvery thread call, and ask() or stream() with a threadId. Stateless turns still work.
user_requiredA token that names no usercreateThread(). Such a token has no threads to list or find.

Cancelling

There is no timeout option, because an AbortSignal already is one:

TypeScript
const controller = new AbortController();
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]);

for await (const event of aria.stream({ threadId, message, signal })) {
  if (event.type === 'delta') {
    appendText(event.text);
  }
}

A cancelled turn throws ApiError with code: 'aborted'. A 30-second cap like this one suits ask(); for a streamed answer, cap the whole turn generously, because a long tool call sends nothing for a while. AbortSignal.any() needs Node 20.3 or a current browser; a plain controller.signal works everywhere.

Versioning

The package follows semantic versioning. While it is 0.x, a minor release may change the public API, so pin a minor range (~0.5.0) if that matters to you. Every release is described in CHANGELOG.md, which ships in the package, and on the revenexx changelog. Ask the registry for the current version: npm view @revenexx/aria-sdk version. Versioning & stability covers what a 0.x version promises across the revenexx SDKs.

Where to go next

Was this page helpful?