@revenexx/aria-sdk
v1.0.0
Published
Typed client for the Aria API - buffered and SSE-streamed chat turns.
Readme
@revenexx/aria-sdk
Typed client for the Aria API: create conversation threads, ask for one whole answer, stream an answer as typed events, and list, reopen or delete a user's earlier conversations. ESM, framework agnostic, with TypeScript types included. It is the same client the Aria chat widget runs on.
Your app stays in charge of the user's session. The SDK never logs anyone in and never assumes a revenexx Cockpit session: you hand it a token provider, and it calls that per request.
Install
npm install @revenexx/aria-sdkThe package is public on npm, so no login and no .npmrc entry are needed. If an old
.npmrc maps @revenexx to another registry (for example
@revenexx:registry=https://npm.pkg.github.com), remove that line: it reroutes every
@revenexx install away from npm.
For a page without a bundler there is a standalone browser build that exposes a global
AriaSDK:
<script src="https://cdn.jsdelivr.net/npm/@revenexx/aria-sdk@1/dist/aria-sdk.global.js"></script>@1 loads the latest 1.x release, so the page picks up new releases without changing.
For a production page that must load the same file every time, pin the exact version
(@1.0.0) and add an integrity attribute with the file's SRI hash.
Supported runtimes
| Runtime | Minimum |
| ------- | ------------------------------------------- |
| Node | 20 (declared in engines) |
| Chrome | 80 |
| Edge | 80 |
| Firefox | 105 |
| Safari | 14.1 |
| Bundler | Any that resolves package exports and ESM |
The browser minimums come from what the SDK uses at runtime: fetch, ReadableStream,
TextDecoderStream and ES2022 syntax. The package is ESM only; there is no CommonJS
build.
Quick start
import { createAriaClient } from '@revenexx/aria-sdk';
const aria = createAriaClient({
getToken: async () => session.accessToken,
getTenantId: () => 'your-tenant',
getLanguage: () => 'en',
});
// One conversation: mint 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);
}
}For a one-shot card that has nowhere to show partial text, ask() returns the whole
answer at once. Leave out threadId and the turn is stateless: no history 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 misconfigured client therefore talks to production
rather than failing, which matters most in tests.
Authentication for your app
The SDK sends whatever getToken() returns as Authorization: Bearer <token>. After a
401 it calls getToken(true) once so you can refresh, then retries the request once.
Return null to send no token.
The token must be a Zitadel access token for a signed-in user on the revenexx identity realm. Aria checks it, then forwards it to the revenexx AI gateway, which checks it again. It passes both only if:
- It is a JWT, not an opaque token. Set your Zitadel client app's Auth Token Type to JWT.
- It carries the organisation claim. Request the
urn:zitadel:iam:user:resourceownerscope. Without it every request fails with401. - Its audience includes the Revenue Cloud project, and the user holds a Revenue Cloud
role. An app inside the Revenue Cloud project gets that audience automatically. An
app in another project requests
urn:zitadel:iam:org:project:id:<revenue-cloud-project-id>:aud(revenexx provides the id when your app is onboarded). Never request the Revenue Cloud Platform audience. Without the audience aria and the gateway reject the token; without a role the gateway answers403.
A complete scope list is therefore:
openid profile offline_access urn:zitadel:iam:user:resourceownerplus urn:zitadel:iam:org:project:id:<revenue-cloud-project-id>:aud for an app outside
the Revenue Cloud project.
The token picks the organisation; it does not pick the tenant. getTenantId() sends
X-Tenant-Id, which must be one of that organisation's tenants or the request fails
with 403. You can omit it when the organisation owns exactly one tenant; it is
required when it owns several. It may also return null (or an empty string) while
your app has no active tenant: then no X-Tenant-Id is sent.
All providers (getToken, getTenantId, getLanguage) are read per request, so you
can switch tenant or language mid-session without rebuilding the client. A provider
that throws is treated as one that returned nothing: the request goes out without that
header rather than failing.
Streaming events
stream() yields a discriminated union. Branch on event.type:
| type | Payload | Meaning |
| ------------ | ---------------------------- | -------------------------------------------------------------------------- |
| delta | text | A chunk of the answer. Append in order. |
| status | status: ToolStatus | A tool call started. status.runId pairs it with its toolTrace. |
| toolTrace | entry: ToolTrace | 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 after it. |
| followUps | items: FollowUp[] | Suggested next questions. Only when you set includeFollowUps. |
| paused | reason, message, usage | The turn ran out of steps but can be continued. See below. |
| unknown | name, data | A frame 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.generatingis edge-triggered, not periodic. Nothing arrives during a long tool call, so it cannot back 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 that never renders blank.
Follow-up suggestions
Follow-ups are off by default, because generating them costs a second model call. Turn
them on with includeFollowUps: true, or pass a function to decide per turn:
import { createAriaClient } from '@revenexx/aria-sdk';
let suggestionsOn = true;
const aria = createAriaClient({
getToken: async () => session.accessToken,
includeFollowUps: () => suggestionsOn,
});With them off, the server skips the second 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
checkpoint, 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 can never be resumed, because there
is no thread to hold the checkpoint.
Conversation history
A user's earlier conversations can be listed, reopened and deleted. All three calls are scoped to the user behind the token, in the active tenant.
listThreads() returns the newest activity first, one page at a time. Pass a page's
nextCursor back as cursor to get 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 prepend 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);A thread that does not exist, or belongs to another user or tenant, fails with
serverCode: 'not_found': the server never confirms that someone else's thread exists.
Some callers may not use threads at all, which threadRefusalOf() tells you; see
Errors.
Language
getLanguage() returns a language tag such as de or en. The SDK sends it as a
weighted Accept-Language, which makes the server write the answer, follow-ups, pause
message and error messages in that language. Omit it and the server uses its own
default.
Extra headers and a custom fetch
getHeaders() adds headers of your own to every request. It is read per request, like
the other providers, and may be async. Use it for what the SDK has no option for, such
as a request id for tracing:
import { createAriaClient } from '@revenexx/aria-sdk';
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,
whatever its letter case. One the SDK does not set, such as Authorization while
getToken() returns null, is sent as you return it. A getHeaders() that throws
sends no extra headers rather than 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:
import { createAriaClient } from '@revenexx/aria-sdk';
const aria = createAriaClient({
getToken: async () => session.accessToken,
fetch: (input, init) => fetch(input, { ...init, keepalive: true }),
});Page context
getContext() is reserved for passing what the user is looking at. The server does not
accept it yet, so the value is currently not sent. You can wire it now and it takes
effect once the server supports it.
Errors
Every failed call is an ApiError, so one catch covers the whole client. Branch on
code for what happened and on serverCode for why. The exception is a call made with
an argument that can only be a bug in your code, such as an empty thread id: it 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. | 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. serverMessage is its text, already in the language
getLanguage() asked for. The SDK produces no user-facing copy of its own.
AI switched off for the tenant
A tenant can switch AI off in its settings. A turn is then refused before any model
call, with status: 403 and serverCode: 'feature_disabled': AI is switched off for
this tenant. The server decides this from the tenant alone; nothing the client sends
changes it. Hide the AI surface rather than retrying.
import { ApiError } from '@revenexx/aria-sdk';
function isSwitchedOff(error: unknown): boolean {
return (
error instanceof ApiError && error.status === 403 && error.serverCode === 'feature_disabled'
);
}Wallet exhausted
When the tenant's AI credit wallet is empty, the AI gateway refuses the model call.
There is no dedicated code for that yet. It reaches you as serverCode: 'bad_gateway',
with a serverMessage that names upstream status 402:
- from
ask(), ascode: 'request_failed'withstatus: 502; - from
stream(), ascode: 'stream_error', because the model call starts after the stream has opened.
Treat bad_gateway as "not available right now" and do not retry it automatically. If
a dedicated code is added, the changelog will say so.
Threads refused
A caller that may not use threads at all gets serverCode: 'forbidden' with a reason.
threadRefusalOf(error) returns that reason, or null for any other error, including a
forbidden about the tenant:
import { ApiError, threadRefusalOf } from '@revenexx/aria-sdk';
try {
await aria.deleteThread(threadId);
} catch (error) {
if (!(error instanceof ApiError)) throw error;
const refusal = threadRefusalOf(error);
if (refusal === 'impersonation_forbidden') {
// An operator acting for a tenant: offer stateless turns only.
} else if (error.serverCode === 'not_found') {
// Already deleted, or not this user's thread.
} else {
showError(error.serverCode, error.serverMessage);
}
}| Reason | Caller | Refused by |
| ------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------- |
| impersonation_forbidden | An impersonation token: an operator acting for a tenant. | Every thread call, and ask() or stream() with a threadId. |
| user_required | A token that names no user. | createThread(). Such a token has no threads, so it lists and finds none. |
An impersonation token can still send turns without a threadId.
Cancelling
There is no timeout option, because 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'. AbortSignal.any() needs
Node 20.3 or a current browser; a plain controller.signal works everywhere.
Versioning
The package follows semantic versioning. Since 1.0.0 the public API changes only with a
major release, so a caret range (^1.0.0) picks up fixes and additions safely. Every
release is described in CHANGELOG.md, which ships in the package.
Licence
MIT. See LICENSE.
