NotusBase

1. Quick start

No package is public yet: the downloads open with the first public version. The steps below show how NotusBase works once you have one.

From an empty folder to a project with a table, two functions, a server that redeploys on every save, login and an end-to-end test. You need neither Go nor a C compiler, and you do not need the server code. Sections 1 to 4 were run on 2026-11-10 on a Mac, from an empty folder, with a binary built from the repository that day; the rest on 2026-11-09 with notusbase 0.0.0-8cdd215. The outputs are the ones that appeared, with the paths shortened. Port 3490 is just the one used here.

You need Node 22 or newer, for npm, tsc and the test. The server itself does not use Node, except for "use node".

0. The binary

notusbase is a single binary: server, esbuild, runtime and CLI. There is one package per platform:

PackageNeedsCarries
notusbase-<version>-darwin-arm64.tar.gzApple Silicon, macOS 14 or newerThe binary (the development MCP is inside it: notusbase mcp), this guide in guide/ and CHANGES.md
notusbase-<version>-linux-x86_64.tar.gz, notusbase-<version>-linux-aarch64.tar.gzglibc 2.38 or newer (Ubuntu 24.04 yes; Debian 12 no)The same, plus the systemd unit and the production scripts: First server

Where to get it. There is no published release yet: the package is built by whoever has the repository and handed to whoever will use it. On each system, with Go, Node and a C compiler (V8's cgo does not cross-compile):

bash notusbase/ops/package.sh

The file lands in tmp/dist/. An Intel Mac or another glibc version means building right there, with the same command.

Install. With the package in hand, put the binary in a folder on your PATH:

tar xzf notusbase-*-darwin-arm64.tar.gz
mkdir -p ~/.local/bin
mv notusbase-*-darwin-arm64/notusbase ~/.local/bin/
notusbase version
notusbase 0.0.0-8cdd215, PocketBase v0.41.1

On Linux the names end in -linux-x86_64 or -linux-aarch64 instead. If notusbase is not found, add ~/.local/bin to your PATH. The binary is not signed: if the Mac refuses a file downloaded through the browser, xattr -d com.apple.quarantine ~/.local/bin/notusbase removes the mark (not tested here; copied with scp it opens directly).

Update. CHANGES.md, in the package, says what each version changes for you. Stop notusbase dev, replace the file with the one from the new package and start again. Use mv: copying over a running binary makes macOS kill the next one that starts. pb_data remains valid, and a project keeps working because it carries its own kit.mjs. A production server has its own path, with a way back: update.sh, in the Linux package.

1. Project

notusbase init my-system --port 3490
cd my-system
npm install
project created in my-system

Next steps:
  cd my-system
  npm install
  notusbase dev   (server, redeploying on every save, until Ctrl-C)
  npm test

init writes the functions and nothing else: no login of its own, no teams and no UI. Those are the system's, written by you or by an AI following the rules; the example project shows companies, members and access by tenant. --port goes into notusbase.json; without it the port is 8090.

FileWhat it is for
convex/schema.tsOne table, tasks, with an index
convex/tasks.tsA query (list) and a mutation (create)
convex/tsconfig.jsonThe functions' tsc, with the bundle target
convex/_generated/Types, regenerated on every publish. Do not edit
test.mjs, kit.mjsThe test, through the kit: one check, to grow from. kit.mjs follows the binary: do not edit it
notusbase.jsonAddress and data folder, so you do not repeat --url and --dir on every command
CLAUDE.mdThe rules for whoever writes the system, human or AI, written by notusbase init. An existing file is kept
.mcp.jsonThe development MCP (notusbase mcp), for an AI in the editor

The function code is what you would write for Convex: convex/server and convex/values come from the official package. npm install may warn install-scripts about esbuild. You can ignore it: notusbase uses the esbuild it carries inside.

2. Start

notusbase dev
2026-11-10 16:08:00Z dev: every file saved in .../my-system/convex redeploys the functions
2026-11-10 16:08:00Z no superuser: create one with notusbase superuser upsert EMAIL, which asks for the password
2026-11-10 16:08:00Z data in .../my-system/pb_data
2026-11-10 16:08:00Z WAL sync every 1000 ms (PB_WAL_SYNC_MS)
2026-11-10 16:08:00Z login: the users collection counts as identity, with signup open
2026-11-10 16:08:00Z dev: version 1 live: 2 functions
2026-11-10 16:08:00Z warning: 2 public functions never read the login, so anyone with the address can call them: tasks:create, tasks:list. ...
2026-11-10 16:08:00Z Server started at http://127.0.0.1:3490

The warning is about the two functions init wrote: they are open to anyone, which is fine while only you call them. Section 4 adds the login, and the warning goes away for each function that reads it.

notusbase dev publishes what is in convex/, republishes on every saved file and runs tsc alongside, without delaying the publish. It listens on loopback only, shows error details, keeps the data in ./pb_data and goes to disk every second. Each save prints a line like dev: deploy 2 live: 3 functions, in 42 ms, with what changed in the database in plan: lines. Ctrl-C stops it.

In another terminal, in the project folder, the commands find the server through notusbase.json:

notusbase run tasks:create '{"text":"buy bread"}'
notusbase run tasks:list
notusbase sql "select count(*) from tasks"
{"status":"success","value":"tasks:5yk2ack3sf57rfa"}
{"status":"success","value":[{"_id":"tasks:5yk2ack3sf57rfa","_creationTime":1794326881399,"text":"buy bread","done":false}]}
{"count(*)":1}

notusbase run calls as an administrator, without a user, and also reaches internal functions, which the client cannot. Without the client, public functions also exist over HTTP, at POST /api/query and /api/mutation with {"path":"tasks:list","args":{}}.

From your code, the client is the convex package, already installed. A live query, in a file live.mjs at the project root, run with node live.mjs while notusbase dev runs:

import { ConvexClient } from "convex/browser";
import { api } from "./convex/_generated/api.js";

const client = new ConvexClient("http://127.0.0.1:3490");
client.onUpdate(api.tasks.list, {}, (tasks) => console.log(tasks.map((task) => task.text)));

It prints the list now and again on every change: notusbase run tasks:create '{"text":"milk"}' in another terminal makes it print. Ctrl-C stops it. In a browser it is the same code, with the address of the server; React uses ConvexProvider and useQuery, as in Convex.

3. Test

npm test
ok: a task created and listed

The test does not use the server from notusbase dev: the kit starts another on a free port, with disposable data in tmp/kit/, deploys and stops everything at the end. It takes a few seconds. What the kit offers (client signed in as a test user, addUser, run, waitJobs, runCron, advance, emails) is in the kit README, and every new step of the system gets a line in test.mjs.

4. Login

A project from notusbase init has no login of its own: it uses PocketBase's users collection, through three routes:

WhatRouteBodyAnswer
Sign inPOST /api/collections/users/auth-with-password{ "identity": email, "password": password }{ token, record }; record.id is the subject
Create an accountPOST /api/collections/users/records{ "email", "password", "passwordConfirm" }The record. Only with signup open; 403 when closed
Renew the tokenPOST /api/collections/users/auth-refresh{}, with the header Authorization: <token>{ token, record }. An expired token is not renewed

A signed-in client in plain Node, with one more function in convex/tasks.ts that returns who is calling:

export const whoami = query({
  args: {},
  handler: async (ctx) => (await ctx.auth.getUserIdentity())?.subject ?? null,
});
import { ConvexHttpClient } from "convex/browser";
import { api } from "./convex/_generated/api.js";

const url = "http://127.0.0.1:3490";
const email = "ana@example.com", password = "a-test-password";

async function post(path, body) {
  const response = await fetch(url + path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
  if (!response.ok) throw new Error(`${path}: ${response.status} ${await response.text()}`);
  return response.json();
}

// Only with signup open. With it closed, the account comes from `notusbase user add`.
await post("/api/collections/users/records", { email, password, passwordConfirm: password });
const { token, record } = await post("/api/collections/users/auth-with-password", { identity: email, password });

const client = new ConvexHttpClient(url);
client.setAuth(token);
console.log(record.id, await client.query(api.tasks.whoami, {}));
v7z72vyklcgqm1y v7z72vyklcgqm1y

With subscriptions, the client is ConvexClient from convex/browser, and setAuth takes a function: client.setAuth(async ({ forceRefreshToken }) => token). The client calls it with forceRefreshToken near expiry and when the server refuses the token: that is the moment to call auth-refresh. In React, ConvexProviderWithAuth takes the same function through a useAuth of your own. In the UI, the login form uses onSubmit with preventDefault: React 19's formAction clears the fields after an error.

Four names for who is calling. They show up in the code, the commands and the dashboard, and they are different things:

NameWhat it isWhere it lives
userThe account that signs in to your system: email and passwordPocketBase's users login collection. Created by signup or by notusbase user add
subjectWho is calling, as the functions see it: (await ctx.auth.getUserIdentity()).subjectThe token: the record id in users
memberThe row of your own that says which tenant a subject belongs to and with which roleA members table in your convex/schema.ts, which your code creates
superuserThe owner of the server: opens the dashboard at /_/ and sees every tablePocketBase's _superusers collection. Created by notusbase superuser upsert. Never signs in to the system

A superuser is not a user: the functions do not see it. Who sees what is your code's: every public function starts by finding the caller's member, and the tenantId comes from there, never from the client. The rules and 3. Modeling say how; the example project does it with companies.

Errors. The function throws ConvexError({ code }), and the UI translates the code. In production, any other error arrives as [Request ID: …] Server Error (section 7).

Later, every so often, and files. They are written as in Convex. A short example of each, with the full list of what a function can call, is in the reference: ctx.scheduler.runAfter(ms, internal.file.name, args) runs a function later, convex/crons.ts with cronJobs() every so often (notusbase sql "select name, next from _crons" shows when, and notusbase cron run <name> fires one now), and ctx.storage.generateUploadUrl() in a mutation gives the client an address to POST the file to, which answers { storageId }; ctx.storage.getUrl(storageId) gives the address that serves it.

A function that calls another one of the same file through internal or api needs its return type written out, or tsc stops with TS7022: 'name' implicitly has type 'any' (in Convex too). notusbase dev publishes all the same and prints the error; notusbase deploy refuses. The fix is on the handler:

export const later = mutation({
  args: { text: v.string() },
  handler: async (ctx, { text }): Promise<void> => {
    await ctx.scheduler.runAfter(500, internal.tasks.write, { text });
  },
});

Changing the schema. With test-only data, stop the server, delete pb_data and start again. With data that matters, a new required field is refused until every document has it:

dev: deploy refused: document tasks:... does not satisfy the new schema. Fix or delete the document, or make the field optional

The three cases (new field, rename, remove) are in Changing the schema with data.

5. Day to day

notusbase.json is read from the folder where the command runs. --url, NOTUSBASE_URL and --dir override it. It only accepts a local address in url: the file goes into git, and a downloaded project must not be able to decide where your deploy key goes. A remote server goes in remotes ("remotes": { "production": { "ssh": "deploy@server.example.com" } }), for --to production, or through --url or NOTUSBASE_URL, with the key in --key or NOTUSBASE_DEPLOY_KEY. Every --to first prints where it goes, as ssh resolves it: user, host, port and the pb_data there. A deploy or a deploy --rollback through --to then asks before changing anything; on a terminal you answer, and in a script --yes answers. The first --to production to a host ssh does not know also shows ssh's own host-key question.

CommandWhat it does
notusbase devThe development server, until Ctrl-C
npm testThe end-to-end test, on a disposable server
notusbase deployGenerates types, runs tsc, bundles and publishes to a running server
notusbase run module:function '{...}'Runs a function, internal ones included. Large argument: @args.json, or - to read from stdin
notusbase user add you@example.comCreates an account with signup closed; asks for the password in the terminal
notusbase logsFollows the logs live
notusbase logs --since 1h --function tasks --limit 50Shows the past, filtered, and exits
notusbase sql "select count(*) from tasks"Read-only query, one statement at a time
notusbase sql "select name, next from _crons"The crons the server knows, and when each runs next, in milliseconds
notusbase export, notusbase env, notusbase superuserExport data, variables and the dashboard owner

Every command with its flags, and every name a function can call, is in the reference, generated from the code.

6. In production

notusbase serve differs from dev on purpose:

NOTUSBASE_AUTH_COLLECTIONS=users notusbase serve --env production --http 127.0.0.1:3490 --origins https://app.example.com
notusbase deploy
notusbase user add you@example.com

Before deploying, notusbase deploy --plan shows what would change in the database, without deploying anything: new tables, columns and indexes, dropped indexes, fields leaving the schema with how many documents still hold a value (the deploy refuses those, like Convex), and the tables that will be read in full for validation. A normal deploy prints the same summary in plan: lines, and both warn about process.env variables with no value on the server and about differences from Convex that the code uses, without blocking.

"Deploy 1" is the empty server. To deploy without tsc, use --skip-typecheck. A UI goes to production as static files, served by any static host, pointing at the server's address. A real server, with systemd, its own HTTPS and backup, is a walkthrough of its own: First server.

7. When something goes wrong

In production, the caller only sees what the function threw on purpose with ConvexError. Everything else becomes [Request ID: …] Server Error, in Convex's format, and the reason stays in the server log, one line per failure, with the same id:

SituationThe client receivesnotusbase logs shows
throw new Error("internal secret")[Request ID: …] Server Error[ERROR] [Request ID: …] with the message and convex/file.ts:line:column
throw new ConvexError({ code: "X" })e.data with the object[ERROR] Uncaught ConvexError
Argument that does not match the validator[Request ID: …] Server ErrorPath and validator, with the value hidden
Invalid id[Request ID: …] Server ErrorPath and validator, with the value hidden
Function that does not exist[Request ID: …] Server ErrorCould not find with the name
notusbase logs --since 1h
notusbase logs --since 1d --text 3f9c2a7d81e04b56
2026-11-06 20:25:43Z tasks:create [ERROR] [Request ID: 3f9c2a7d81e04b56] [Deploy: 2] [User: k3w9x0pl2a7d1qz] ArgumentValidationError: Value does not match validator.
  Path: .text
  Value: (hidden)
  Validator: v.string()

[Deploy: 2] is the deploy that was live when the function failed. [User: …] shows up only when the caller was signed in, and it is the record id (or the token's sub), never the email; the arguments and the IP stay out of the log.

--text accepts the whole error pasted as the client received it, and so does the Text field of the functions screen: the id finds the execution. The rejected value stays out of the production log because it may be a password or personal data. In notusbase dev, or with NOTUSBASE_DEV_ERRORS=1, it shows up, and the client receives the detail. The functions' console.log also goes to notusbase logs, with or without --since. The history lives in pb_data/notusbase.log, plain text, up to 64 MB and the dashboard's log days (5).

The commands' messages say what to do:

MessageCause
nothing answered at http://127.0.0.1:3490. Start the server with notusbase dev or notusbase serve...Server stopped or wrong address
... refused the deploy key (...). Point --dir or notusbase.json at that server's pb_data...The command read the key from another pb_data. The server shows its own on startup, under data in
the convex package is not installed: run npm install in the project foldernpm install was missing
notusbase.json: url ... is not local. A remote server goes in "remotes", for --to, or through --url or NOTUSBASE_URLThe file pointed at another machine

8. Next step

The example project shows companies and members, an attached file, a webhook route and a task scheduled for later, also tested through the kit. The rules for a real product are in 3. Modeling, and what behaves differently from Convex is in 2. Migrating from Convex.