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:
| Package | Needs | Carries |
|---|---|---|
notusbase-<version>-darwin-arm64.tar.gz | Apple Silicon, macOS 14 or newer | The 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.gz | glibc 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.
| File | What it is for |
|---|---|
convex/schema.ts | One table, tasks, with an index |
convex/tasks.ts | A query (list) and a mutation (create) |
convex/tsconfig.json | The functions' tsc, with the bundle target |
convex/_generated/ | Types, regenerated on every publish. Do not edit |
test.mjs, kit.mjs | The test, through the kit: one check, to grow from. kit.mjs follows the binary: do not edit it |
notusbase.json | Address and data folder, so you do not repeat --url and --dir on every command |
CLAUDE.md | The rules for whoever writes the system, human or AI, written by notusbase init. An existing file is kept |
.mcp.json | The 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.
- Login is PocketBase's
userscollection, with signup open (section 4).NOTUSBASE_SIGNUP=0 notusbase devcloses it. - The superuser warning only matters for the PocketBase dashboard, at
/_/. - The binary comes from the
PATH; the project's npm scripts also take it fromNOTUSBASE.
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:
| What | Route | Body | Answer |
|---|---|---|---|
| Sign in | POST /api/collections/users/auth-with-password | { "identity": email, "password": password } | { token, record }; record.id is the subject |
| Create an account | POST /api/collections/users/records | { "email", "password", "passwordConfirm" } | The record. Only with signup open; 403 when closed |
| Renew the token | POST /api/collections/users/auth-refresh | {}, with the header Authorization: <token> | { token, record }. An expired token is not renewed |
- The server accepts 2 sign-in attempts every 3 s per IP, a fixed limit, and answers 429 with
Retry-Afterbeyond that. Measured: after two sign-ins in a row, the next five got 429. - A wrong email or password is 400. The password has 8 characters or more.
- The token is valid for 5 days.
notusbase serveneedsNOTUSBASE_AUTH_COLLECTIONS=users;notusbase devturns it on by itself. With signup closed, the account comes fromnotusbase user add <email>.
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 = "[email protected]", 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:
| Name | What it is | Where it lives |
|---|---|---|
user | The account that signs in to your system: email and password | PocketBase's users login collection. Created by signup or by notusbase user add |
subject | Who is calling, as the functions see it: (await ctx.auth.getUserIdentity()).subject | The token: the record id in users |
member | The row of your own that says which tenant a subject belongs to and with which role | A members table in your convex/schema.ts, which your code creates |
superuser | The owner of the server: opens the dashboard at /_/ and sees every table | PocketBase'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": "[email protected]" } }), 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.
| Command | What it does |
|---|---|
notusbase dev | The development server, until Ctrl-C |
npm test | The end-to-end test, on a disposable server |
notusbase deploy | Generates 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 [email protected] | Creates an account with signup closed; asks for the password in the terminal |
notusbase logs | Follows the logs live |
notusbase logs --since 1h --function tasks --limit 50 | Shows 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 superuser | Export 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:
Error details do not reach the client.
Login needs
NOTUSBASE_AUTH_COLLECTIONS=users, and signup is closed on every start: accounts come fromnotusbase user add <email>, andNOTUSBASE_SIGNUP=1opens it.Only the origins in
--originsorNOTUSBASE_ORIGINStalk to the server from a browser.It does not deploy anything: it serves the last version published to its
pb_data, bydevor bynotusbase deploy. On a newpb_datathere are no functions untilnotusbase deploy, andnotusbase runanswersFunction ... does not exist.It needs the environment's name:
--env productionfor the server your customers use. Without it, apb_datathat already has variables stops the start, and one without them runs as development, with email held back (Environments).Variables belong to one environment. The ones set under
notusbase devaredevelopment's, and production does not see them: set them again withnotusbase env set --env production NAME, or with--env allfor a value every environment shares. The deploy warns about a variable the code reads and the server lacks.
NOTUSBASE_AUTH_COLLECTIONS=users notusbase serve --env production --http 127.0.0.1:3490 --origins https://app.example.com
notusbase deploy
notusbase user add [email protected]
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:
| Situation | The client receives | notusbase 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 Error | Path and validator, with the value hidden |
| Invalid id | [Request ID: …] Server Error | Path and validator, with the value hidden |
| Function that does not exist | [Request ID: …] Server Error | Could 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:
| Message | Cause |
|---|---|
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 folder | npm install was missing |
notusbase.json: url ... is not local. A remote server goes in "remotes", for --to, or through --url or NOTUSBASE_URL | The 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.