@rekhu/assistant-node

Rekhu Assistant for Node.js: a custom AI assistant inside your app, for your own customers. Adds the chat route to your back end, runs your actions as the signed-in user, and meters usage per customer. Express, Next.js, Nuxt, Hono, Bun and Deno.
893
Install
npm install @rekhu/assistant-node
Latest Version:0.4.0
License:Custom
Last Updated:Oct 22, 2026
Links: npm
Maintainer: reymonzakhary

Rekhu Assistant for Node.js

npm version Node.js 20+ No dependencies TypeScript

Getting started · Frameworks · Actions · Public chat · Security · Configuration


Rekhu Assistant for Node.js puts a custom AI assistant inside your app, for your own customers. It answers from your help, sends people to the right page, and carries out tasks through the actions you define, as the signed-in user. Every conversation is metered per customer by Rekhu, so you can offer the assistant to your customers and bill them for it.

This package is the back end half. It adds a chat route to your Node.js server, holds your secret key, and runs your actions. The front end half is the widget, @rekhu/assistant-widget, which talks only to your route.

Feature What it means
Your own functions as actions defineAction({ name, description, input, run }): the input is checked against its schema before run is called.
Runs as the signed-in user Your sign-in decides who the user is; each action gets that user and does its own permission check.
Clicks, not words Destructive actions wait for a click on a confirm card; a typed "yes" runs nothing.
Pages and help It sends users to the right page and answers from your markdown help, in their language.
A public chat Optionally for visitors who are not signed in, with caps per visitor, per IP address and per day.
One project per app Each app is a project in your Rekhu account, with keys per environment, one monthly limit, a pause, and usage per user or per tenant to bill from.
Any Node.js server Express, Next.js, Nuxt, Hono, Remix, SvelteKit, Bun and Deno. No dependencies.

Contents

  1. How it works
  2. Requirements
  3. Getting started
  4. Frameworks: Express · Next.js · Nuxt · Hono, Bun and Deno
  5. Actions
  6. Pages
  7. Help
  8. Projects, usage and limits
  9. A public chat for visitors
  10. Reports
  11. Running on more than one server
  12. Security
  13. Configuration
  14. Checking your setup · Troubleshooting

How it works

Like Stripe: the secret key lives on your server, the browser only talks to your own routes.

Browser                              Your Node.js server                       Rekhu
┌──────────────────────┐   POST      ┌────────────────────────────────┐ secret ┌──────────┐
│ @rekhu/assistant-    │ /api/rekhu/ │ @rekhu/assistant-node          │ ─key─▶ │ model,   │
│ widget  (no key)     │ ──chat────▶ │ · who is signed in? (your auth)│        │ metering,│
│                      │ ◀─stream─── │ · runs your actions as them    │ ◀───── │ limits   │
└──────────────────────┘             └────────────────────────────────┘        └──────────┘
  1. The widget sends the user's message to your chat route, with the user's own session cookie or token.
  2. The package asks your user() function who that is, and builds the assistant's view of your app for this user: the actions and pages they may use, and your help.
  3. It calls Rekhu with your secret key. Rekhu meters the call for the user's company and forwards it to the model.
  4. When the model asks for an action, the package checks the input and calls your run function, as the user, and reports the result back.
  5. The answer streams back to the widget: text, steps, and cards (a page to open, an action to confirm, a result).

Requirements

Requirement Version
Node.js 20, 22 or 24 (tested); it uses the built-in fetch and web streams
Widget @rekhu/assistant-widget 0.3.0 or newer for report links under answers
Storage none of its own: conversations are kept in the store, in memory by default
Rekhu an account with credit and a secret key: sign up, then top up from $10 under Billing

Complete examples

The examples folder is a small shop wired up file by file: an assistant with one action of each kind, its route behind your sign-in, help topics and a React chat of your own. It runs as it is, and is not part of what npm installs.

Getting started

1. Get a secret key

In your Rekhu account, put credit on the account under Billing, then create a project under Projects → New project: choose Node.js app, monolith or multi-tenant, and name it (for example "Shop"). Its assistant is on from the start; on its Connection tab add a key for your first environment (local). Add one per environment later (staging, production); they all count toward the same project. The key starts with agk_ and is shown once.

Put it in your server's environment, never in front-end code:

REKHU_SECRET_KEY=agk_…

2. Install

npm install @rekhu/assistant-node @rekhu/assistant-widget

3. Create the assistant

// assistant.js (server side)
import { createAssistant, defineAction, Forbidden } from '@rekhu/assistant-node';
import { orders } from './orders.js';

export const assistant = createAssistant({
    appName: 'My shop',
    user: (req) => req.user ?? null,                       // your own sign-in decides who this is
    tenant: (user) => ({ id: user.companyId, name: user.companyName }),
    help: './help',                                        // markdown files, one topic each
    actions: [
        defineAction({
            name: 'reorder',
            description: 'Reorder a past order: the same product as a new order; the quantity can be changed',
            input: { orderNumber: 'string', quantity: 'integer?' },
            run: async ({ orderNumber, quantity }, { user }) => {
                const order = await orders.find(orderNumber);
                if (!order || order.ownerId !== user.id) throw new Forbidden();   // your permission check
                const created = await orders.reorder(order, quantity);
                return { message: `Order ${created.number} placed.`, page: `/orders/${created.number}` };
            },
        }),
    ],
});

4. Add the route

For Express:

import express from 'express';
import { assistant } from './assistant.js';

const app = express();
app.use('/api/rekhu', assistant.express());   // serves /api/rekhu/chat

Other frameworks are under Frameworks.

5. Add the widget

In the front end, for a signed-in user:

import { mount } from '@rekhu/assistant-widget';

mount({ endpoint: '/api/rekhu/chat' });

Ask "order my last business cards again". The widget's README shows it for Next.js, Nuxt, Vue, React and plain HTML. A Flutter app uses rekhu_assistant with the same route, behind the middleware that checks its token.

Frameworks

The package serves two routes under the path you mount it on: …/chat for signed-in users, and …/visitor/chat for the public chat. Each answers GET, POST and DELETE.

Express

app.use('/api/rekhu', assistant.express());

user(req) receives the Express request: req.user from Passport, req.session.user from express-session, or whatever your sign-in sets. express.json() before it is fine but not needed. Nest on Express mounts the same middleware with a MiddlewareConsumer.

Next.js

Route handlers, on the Node.js runtime (the default):

// app/api/rekhu/chat/route.ts
import { assistant } from '@/lib/assistant';

export const GET = assistant.fetch;
export const POST = assistant.fetch;
export const DELETE = assistant.fetch;
// lib/assistant.ts
import { createAssistant } from '@rekhu/assistant-node';
import { auth } from '@/auth';   // Auth.js, or your own session

export const assistant = createAssistant({
    user: async () => (await auth())?.user ?? null,
    // …actions, pages, help
});

For the public chat, add the same three lines in app/api/rekhu/visitor/chat/route.ts.

Nuxt

A server route that hands the user over from your session:

// server/api/rekhu/chat.ts
import { assistant } from '~/server/assistant';

export default defineEventHandler(async (event) => {
    const { user } = await getUserSession(event);   // nuxt-auth-utils, or your own session
    return assistant.fetch(toWebRequest(event), { user: user ?? null });
});

assistant.fetch(request, { user }) uses the user you pass instead of calling user(). For the public chat, add server/api/rekhu/visitor/chat.ts with assistant.fetch(toWebRequest(event)).

Hono, Bun and Deno

assistant.fetch is a standard (Request) => Promise<Response> handler:

// Hono
app.all('/api/rekhu/*', (c) => assistant.fetch(c.req.raw, { user: c.get('user') ?? null }));

// Bun
Bun.serve({ fetch: (request) => assistant.fetch(request) });

Remix and SvelteKit hand it their request the same way.

Actions

An action is a function the assistant may call for the user. The package checks the input against its schema, then calls run with the input and the user.

import { defineAction, Forbidden, ActionError } from '@rekhu/assistant-node';

defineAction({
    name: 'cancel_order',
    description: 'Cancel an order that has not shipped yet',
    input: { orderNumber: 'string' },
    danger: true,                                   // asks for a click first, with a red button
    run: async ({ orderNumber }, { user }) => {
        const order = await orders.find(orderNumber);
        if (!order || order.ownerId !== user.id) throw new Forbidden();
        if (order.status === 'shipped') throw new ActionError('This order has shipped already and cannot be cancelled.');
        await orders.cancel(order);
        return { message: `Order ${orderNumber} is cancelled.` };
    },
});

[!IMPORTANT] Check permissions inside run. The package gives run the signed-in user; deciding whether that user may touch this record is your code's job, exactly as in your own route handlers. Throw Forbidden when they may not.

Option Required Meaning
name yes The tool's name: letters, digits, - and _.
label — What the user reads while it runs and on its cards: short, in their words, a text or one per language. The description is for the assistant and is never shown; without a label the user reads a neutral "Working on it…" in their language.
description yes What the action does, written for the assistant: what it returns, what its input means, when to use it. Never shown to users.
run(input, context) yes Does the work. context has user, visitor, request and tenant.
input no The input: the short form or a JSON Schema (below).
allow(user) no Whether this user sees the action at all (roles, plans). Not a replacement for the check in run.
confirm no The user clicks Confirm before it runs.
danger no A destructive action: confirmation with a red button.
readonly no A read action: no result card; the answer says what was found.
guests no Also offered in the public chat.
hide no Input fields not shown on the confirm card.

Input. The short form names each field's type, with ? when it is optional:

input: {
    orderNumber: 'string',
    quantity: 'integer?',
    size: ['small', 'medium', 'large'],                               // one of these
    note: { type: 'string', maxLength: 200, optional: true },          // a JSON Schema of one field
}

Or give a full object JSON Schema ({ type: 'object', properties, required }). Supported checks: type, required, enum, minimum, maximum, minLength, maxLength, pattern, minItems, maxItems and the type of array items. Fields not in the schema are dropped. Numbers sent as text ("5") are accepted for integer and number.

Results. Return what the assistant should know (an object, a list, text). On the result card, message is shown as the outcome and page as a button. Throw ActionError('…') for a refusal the user should hear, and Forbidden when the user may not do it. Any other error is logged, and the assistant is told the action failed.

Confirmations. For an action with confirm or danger, nothing runs when the card is shown. The action and its checked input are kept on the server; only the user's click on the card runs it, once, with exactly that input. A typed "yes", or anything the model writes, cannot run it.

Writing good actions

How well the assistant works depends mostly on how you describe your actions. The examples show every kind.

  • The description is for the assistant: what the action does, what comes back, what each input means, when to use it and when not. Never shown to users. Not 'Orders', but 'List the user\'s orders, newest first: number, date, status, total in EUR. "status" keeps one status; "search" matches an order number. Use it to find an order before showing or changing it.'
  • The label is for the user: a few words in their language, { en: 'Looking up your orders', nl: 'Ik zoek je bestellingen' }. Without one they read "Working on it…".
  • The name stays the same: short, snake_case, a verb and a thing (list_orders, change_address).
  • Read first, then change: give the assistant a way to find a record before every action that changes one.
  • Changes are confirmed: confirm: true for anything the user should see first, danger: true for what cannot be undone. Nothing runs until the click.
  • Say when it does not apply ("of an order that has not shipped yet"), and still refuse in run with ActionError when it does not.
  • Return what helps: the facts the user asked about, ids for the next step, and message and page for the result card.
What Where
How the app works, step by step Help files, one topic each (help)
How the assistant behaves for your users: tone, language, what it never does The project's Knowledge in your Rekhu account (no deploy needed)
Rules that belong with the code instructions
What users can click in a fresh chat quick

Pages

Pages the assistant may send the user to. The user gets a button; the assistant never navigates on its own.

import { definePage } from '@rekhu/assistant-node';

pages: [
    definePage({ name: 'orders', label: 'My orders', path: '/orders' }),
    definePage({ name: 'order', label: 'An order', path: '/orders/:number', help: 'One order with its status and delivery address' }),
    definePage({ name: 'invoices', label: 'Invoices', path: '/invoices', allow: (user) => user.role === 'admin' }),
    definePage({ name: 'pricing', label: 'Prices', path: '/pricing', guests: true }),
]

:parameters in the path are filled in by the assistant. allow(user) hides a page from users who may not open it; guests: true also offers it in the public chat. With the front end on another origin, set pagesBaseUrl.

Help

A folder of markdown files, one topic per file; the first # heading is the topic's title:

help: './help',          // ./help/shipping.md, ./help/returns.md, …

or topics in code: help: [{ title: 'Shipping', text: 'Orders placed before 15:00 ship the same day.' }]. The assistant searches them for how-to questions and answers from them in the user's language; when the help does not cover a question, it says so instead of guessing.

Projects, usage and limits

Your Rekhu account keeps everything per project: one project per app. Its Connection tab holds the keys; its Assistant tab the usage, the limit and the pause.

  • Keys per environment. One secret key for local, staging and production; all of them count toward the project.
  • One monthly limit and a pause for the project's assistant, with a message on your notification channels at 80% and 100%. At the limit, or paused, the assistant answers nobody; your other projects go on. Its users read that the assistant is not available right now, and your app's log says why.
  • Usage per user or per tenant, tracked, not limited: per user for a monolith, per tenant (with its users) for a multi-tenant app, as you choose when you create the project. Conversations, messages and usage per month, and a CSV to bill from.

For a multi-tenant app, say which tenant a user belongs to, so usage shows per tenant:

tenant: (user) => ({ id: user.companyId, name: user.companyName }),

Users are identified to Rekhu by userId(user) (default user.id), never by name or e-mail.

A public chat for visitors

The same assistant for people who are not signed in, for example for a price quote. Turn it on and mark what visitors may use:

createAssistant({
    // …
    guests: { enabled: true },
    actions: [
        defineAction({ name: 'quote', description: 'A price quote', guests: true, input: { quantity: 'integer' }, run: ({ quantity }) => ({ total: quantity * 0.18 }) }),
    ],
});

Then mount the widget for visitors on the public route: mount({ endpoint: '/api/rekhu/visitor/chat', visitor: true }).

Rule Detail
What visitors reach Only actions and pages with guests: true, and the help. Keep internal information out of the help once the public chat is on.
Who a visitor is An anonymous id kept in their browser, with a conversation of its own. A visitor is never a user, even when the browser happens to be signed in.
Caps Per visitor per day (20 messages), per IP address per hour (60), for all visitors together per day (1000), shorter messages (1000 characters) and fewer steps per answer (4). Set under guests.
A cheaper model guests: { model: 'claude-haiku-4-5-20251001' } answers visitors with a cheaper model.
Cost All visitors together show as one tenant, Visitors, in the project's usage, and count toward its monthly limit. Set that limit before you turn the public chat on.

Behind a proxy or load balancer, make sure the IP address is the visitor's: in Express set app.set('trust proxy', …), or pass ip: (request) => ….

Reports

Under each answer the user sees Report a problem. They pick what went wrong, may say what they expected instead, and send it; the link becomes "Thanks, we will look into it." The report shows up on the project's Assistant tab in your Rekhu account, linked to the conversation it came from, so you can see what went wrong and improve the knowledge, the help or your actions.

No tables and no routes of your own: the route is …/chat/report next to your chat route (and …/visitor/chat/report for the public chat), the report goes to Rekhu, and onReport gives you a copy.

What a report contains. The package builds it on the server from the conversation it kept; the browser only sends the answer's id, the reason and the comment.

Sent
the reason and the comment what the user picked and wrote
the answer and the question before it as the user saw them
the conversation up to the answer the user's messages and the answers, at most the last 30
the actions the answer used their name, their input and whether they worked (or the error)
the user's language
not sent what your actions returned: it can hold your customers' personal data. Set reports.includeResults to true to send it, cut to 4000 characters per action

The user is identified by their id only, as with every call. Reports are visible to the project's team in your Rekhu account. A second report on the same answer by the same user replaces the first.

Configure it with reports:

createAssistant({
    // …
    reports: {
        enabled: true,
        reasons: {                      // key => label; null is the built-in text (en, ar, nl)
            wrong_answer: null,
            misunderstood: null,
            wrong_information: null,
            other: null,
            wrong_numbers: { en: 'The numbers are wrong', ar: 'الأرقام غير صحيحة' },   // your own
        },
        maxComment: 1000,
        perUserPerHour: 20,
        guests: true,                   // visitors of the public chat may report too
        includeResults: false,
    },
});

The widget and the history show the server's reasons in the user's language; a chat of your own builds its form from history().report and never hard-codes them. enabled: false removes the link and the route.

A copy of your own. onReport is called for every report, for a table of your own, a message to your team or a ticket:

createAssistant({
    // …
    onReport: async (report, { user, visitor, conversation, message, reason, comment }) => {
        // report: everything that was sent to Rekhu (question, answer, transcript, tools)
        await notifyTeam(`Assistant answer reported: ${reason}`, report);
    },
});

A chat of your own reports with the widget's headless client, by the answer's id from the history or from the stream's done event:

let answer;
await chat.send('Revenue in May?', (event) => { if (event.type === 'done') answer = event.message; });
const { report } = await chat.history();            // report.reasons, report.max_comment
await chat.report({ message: answer, reason: 'wrong_answer', comment: 'It was 41,200' });   // { message: thank-you }

When Rekhu cannot be reached, the user is still thanked; the failure goes to your logger and the report is tried once more. check() says whether reports are on and whether Rekhu takes them.

Running on more than one server

Conversations, confirmations and the public chat's counters are kept in memory by default, which is right for one process. With several processes or servers, give the package a shared store with four methods:

import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);

createAssistant({
    // …
    store: {
        get: async (key) => JSON.parse((await redis.get(key)) ?? 'null') ?? undefined,
        set: (key, value, ttl) => redis.set(key, JSON.stringify(value), 'EX', ttl),
        delete: (key) => redis.del(key),
        increment: async (key, ttl) => {
            const count = await redis.incr(key);
            if (count === 1) await redis.expire(key, ttl);
            return count;
        },
    },
});

Security

Guarantee How
The secret key stays on the server It is read from your server's environment; the widget never has it.
It acts as the user Only actions and pages the user may use are offered, and every action runs with the signed-in user, through your own permission check.
Clicks, not words Confirmations are held on the server. Only the click runs the action, once, with exactly the input the user saw.
Checked input Input is checked against the action's schema before run is called; unknown fields are dropped.
Cross-site requests POST and DELETE must carry the X-Rekhu-Protocol header, which the widget sends and a form on another site cannot; without it the answer is 403. Browsers only let another domain call the routes when that origin is in allowedOrigins.
Throttling The public chat is capped per visitor, per IP address and per day; reports per user or visitor per hour.
Private conversations A conversation belongs to the user (or visitor) who started it; another user's id opens an empty one.
What leaves your server The conversation, and what your actions, pages and help return, go to Rekhu and the model provider to produce the answer. Users are identified by id only.

Configuration

createAssistant(options):

Option Default Purpose
user(request) required The signed-in user of a request, or null.
secretKey process.env.REKHU_SECRET_KEY The secret key (agk_…).
userId(user) user.id The user's id as Rekhu meters it.
tenant(user) { id: appId, name: appName } The customer (company) of the user.
actions, pages [] See Actions and Pages.
help — A folder of markdown files, or topics in code.
instructions — Your own rules for the assistant, in plain words: tone, what it never does, when it hands over to a person.
appName, name 'the application', 'Assistant' The application's name and the assistant's, as it uses them.
locale 'en' The interface language, for short replies such as "yes". The answer follows the user's language.
quick — Your quick questions for a fresh chat: a text, or { text, needs }; a text can be one per language. With needs (action names) a question is only offered to users who may use one of those actions. Your front end decides how they look.
greeting — The first message of the chat, in markdown: a text, or one per language ({ en: 'Hi!', ar: 'مرحباً!' }). The widget shows it in the user's language when the front end sets none of its own. guests.greeting is the one for visitors.
model process.env.REKHU_ASSISTANT_MODEL or 'claude-sonnet-5' The model that answers.
maxTokens, maxSteps 4000, 8 How long one answer may be, and how many action rounds one turn may take.
conversationMinutes 1440 How long a conversation is kept.
guests off The public chat.
reports on Report a problem under each answer: enabled, reasons, maxComment, perUserPerHour, guests, includeResults.
onReport(report, context) — Called for every report, for a copy of your own.
store in memory See Running on more than one server.
allowedOrigins [] Front-end origins that may call the routes from another domain (CORS).
pagesBaseUrl '' Put before page paths when the front end runs on another origin.
ip(request) the framework's The visitor's IP address, for the public chat's caps.
logger console Where errors and warnings go.

Checking your setup

console.log(await assistant.check(someUser));   // or check(null) for what a visitor sees
{ connected: true, credit: '$42.10', problem: null, actions: ['reorder', 'cancel_order'], pages: ['orders', 'order'], help: ['Shipping', 'Returns'],
  reports: { enabled: true, reasons: ['wrong_answer', 'misunderstood', 'wrong_information', 'other'], taken: 'yes' } }

problem says what is wrong: a refused key, or no credit. reports.taken is 'yes' when Rekhu takes reports from this key, 'not yet' when the platform does not take them yet (users are still thanked and the failure is logged), or why it refused.

Troubleshooting

What you see What to do
403 "Requests need the X-Rekhu-Protocol header" A request did not come from the widget. A chat screen of your own must send X-Rekhu-Protocol: 1.
401 "Sign in first" user() returned null for this request. Check that the session cookie or token reaches the route.
"The assistant is not available right now" The project's assistant is paused or at its monthly limit, your Rekhu credit is used up, the key is wrong, or Rekhu could not be reached. Your server's log says which; assistant.check() checks the key and the credit.
An action is missing assistant.check(user) lists what that user is offered; allow(user) may hide it.
CORS errors in the browser Add your front end's origin to allowedOrigins.
A conversation is lost between requests You run several processes with the memory store: use a shared store.

Protocol

The widget and this package speak protocol 1. A chat screen of your own can use the same routes.

Changelog

See CHANGELOG.md.

Licence

Licensed, not sold, to holders of a Rekhu account under the Rekhu Terms of Service. See LICENSE.txt.

Related Packages

@rekhu/assistant-widget

The chat widget of Rekhu Assistant: a custom AI assistant inside your app, for y...

425
bestof2

Dual-Cloud Deployment Engine: Frontend on Cloudflare Pages + Backend on AWS Lamb...

483 0
gimc-ai-agent

GIMC 智能客服(Vue3版)

4,456
@assistant-ui/vue

Vue bindings for assistant-ui

1,101 12,692
@yak-io/nuxt

Nuxt 3 module for embedding yak chatbot

2,138

Version History

Version Released License
0.4.0 Custom
0.3.3 Custom
0.3.2 Custom
0.3.1 Custom
0.3.0 Custom
Pre-releases (1)
Version Released License
0.0.0-stage