Aria SDK
@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
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:
<script src="https://cdn.jsdelivr.net/npm/@revenexx/aria-sdk@0.5/dist/aria-sdk.global.js"></script>
| Runtime | Minimum |
|---|---|
| Node | 20 |
| Chrome, Edge | 80 |
| Firefox | 105 |
| Safari | 14.1 |
| Bundler | Any that resolves package exports and ESM |
The package is ESM only; there is no CommonJS build, so require() does not load it.
Quick start
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.
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.
type | Payload | Meaning |
|---|---|---|
delta | text | The next piece of the answer. Append in order. |
status | status | A tool call started. status.runId pairs it with its toolTrace. |
toolTrace | entry | A tool call finished or failed, with its input and output. |
generating | none | A tool finished and the model is writing again. |
answerDone | usage | The answer is complete. Follow-ups may still arrive. |
followUps | items | Suggested next questions. Only when you turned them on. |
paused | reason, message, usage | The turn ran out of steps and can be continued. See Pauses. |
unknown | name, data | An event this SDK version does not know, passed through instead of dropped. |
Two things to know before you write that switch:
answerDoneis not the end of the stream when follow-ups are on: they arrive after it.generatingarrives 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:
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.
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.
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.
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:
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:
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:
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.
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);
}
}
}
code | When | status |
|---|---|---|
request_failed | The server refused the request before streaming started. | The HTTP status |
stream_error | The stream was open and then reported an error. | 0 |
network_error | The request never reached the server. | 0 |
aborted | Your 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:
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):
| Reason | Caller | Refused |
|---|---|---|
impersonation_forbidden | An operator acting for a tenant | Every thread call, and ask() or stream() with a threadId. Stateless turns still work. |
user_required | A token that names no user | createThread(). Such a token has no threads to list or find. |
Cancelling
There is no timeout option, because an AbortSignal already is one:
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
- Aria API: the HTTP API underneath, with the event model and every error code.
- Authentication: how users sign in through ID.
- @revenexx/aria-sdk on npm.