npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-sdk

The 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:resourceowner scope. Without it every request fails with 401.
  • 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 answers 403.

A complete scope list is therefore:

openid profile offline_access urn:zitadel:iam:user:resourceowner

plus 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:

  • answerDone is not the end of the stream when follow-ups are on: they arrive after it.
  • generating is 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(), as code: 'request_failed' with status: 502;
  • 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 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.