For coding agents
Paste this into your coding agent so it uses Vennbase and pulls the package docs instead of inventing a backend.
How it works
Every piece of data in Vennbase is a row. A row belongs to a collection defined in your schema, holds typed fields, and has its own identity.
Rows can be nested. A card lives inside a board; a recentBoard lives inside the built-in user collection. The parent relationship defines visibility — gaining access to a parent automatically grants access to its children.
Access is explicit-grant only. To let someone into a row, generate a share link and send it to them. They accept it, they're in. There are no rule expressions to write and no policy surface to misconfigure.
Schema
Define your collections once. TypeScript infers field types throughout the SDK automatically.
import { collection, defineSchema, field } from "@vennbase/core";
export const schema = defineSchema({
boards: collection({
fields: {
title: field.string(),
},
}),
recentBoards: collection({
in: ["user"],
fields: {
boardRef: field.ref("boards").key(),
openedAt: field.number().key(),
},
}),
cards: collection({
in: ["boards"],
fields: {
text: field.string(),
done: field.boolean(),
createdAt: field.number().key(),
},
}),
});
export type Schema = typeof schema;
collection({ in: [...] })—inlists the allowed parent collections.field.string()/.number()/.boolean()/.date()/.ref(collection)— typed fields; chain.key(),.optional(), or.default(value)as needed
Fields are for metadata that you want to query. Mark structural/queryable fields with .key(). The canonical CRDT pattern is: row fields hold metadata and row refs, while the CRDT document holds the collaborative value state for that row.
Querying
Vennbase queries always run within a known scope. For cards, that scope is a board, so you pass in: board. For collections declared as in: ["user"], pass in: CURRENT_USER.
Queries never mean "all accessible rows". in is always required, and collections not declared in another cannot be queried.
Imperative
import { CURRENT_USER } from "@vennbase/core";
const recentBoards = await db.query("recentBoards", {
in: CURRENT_USER,
orderBy: "openedAt",
order: "desc",
limit: 10,
});
// Multi-parent queries run in parallel, then merge and sort their results
const cards = await db.query("cards", {
in: [todoBoard, bugsBoard],
orderBy: "createdAt",
order: "asc",
limit: 20,
});
With React
@vennbase/react ships a useQuery hook that polls for changes and re-renders automatically:
import { useQuery } from "@vennbase/react";
const { rows: cards = [], status } = useQuery(db, "cards", {
in: board,
orderBy: "createdAt",
order: "asc",
});
Sharing rows with share links
Access to a row is always explicit. There is no rule system to misconfigure — no typo in a policy expression that accidentally exposes everything. A user either holds a valid invite token or they don't.
In React, prefer useShareLink(db, row, { role: "editor" }) for the sender and useAcceptInviteFromUrl(db, ...) for the recipient. Underneath, readable invites still follow the same three-step flow:
// 1. Generate a token for the row you want to share
const shareToken = db.createShareToken(board, "editor").value;
// 2. Build a link the recipient can open in their browser
const link = db.createShareLink(board, shareToken);
// → "https://yourapp.com/?db=..."
// 3. Recipient opens the link; your app calls acceptInvite
const sharedBoard = await db.acceptInvite(link);
If you do not need the token separately, you can create the link directly from a role:
const editorLink = db.createShareLink(board, "editor").value;
acceptInvite accepts either a full invite URL or a pre-parsed { ref, shareToken? } object from db.parseInvite(input). In React, useAcceptInviteFromUrl(db, ...) handles the common invite-landing flow for you.
For blind inbox workflows, create a submitter link instead:
const submissionLink = db.createShareLink(board, "submitter").value;
const joined = await db.joinInvite(submissionLink);
// joined.role === "submitter"
"submitter" members can create child rows under the shared parent and can run db.query(..., { select: "keys" }) to see only anonymous key-field projections from sibling rows. Key-query results expose id, collection, and key fields only; they do not include row refs, base URLs, owners, or other locator metadata. Submitters still cannot read the parent row, fetch full sibling rows, inspect members, or use sync. Apps that need a submitter to revisit their own submissions should persist the created child refs separately.
Setup
Create one Vennbase instance for your app and pass it an appBaseUrl so that share links point back to your app:
import { Vennbase } from "@vennbase/core";
import { schema } from "./schema";
export const db = new Vennbase({ schema, appBaseUrl: window.location.origin });
Auth and startup
import { useSession } from "@vennbase/react";
function AppShell() {
const session = useSession(db);
if (session.status === "loading") {
return <p>Checking session…</p>;
}
if (!session.session?.signedIn) {
return <button onClick={() => void session.signIn()}>Log in with Puter</button>;
}
return <App />;
}
Creating rows
// Create a top-level row
const board = db.create("boards", { title: "Launch checklist" }).value;
// Create a child row — pass the parent row or row ref
db.create("cards", { text: "Write README", done: false, createdAt: Date.now() }, { in: board });
db.create("cards", { text: "Publish to npm", done: false, createdAt: Date.now() }, { in: board });
create and update are synchronous optimistic writes. Use .value on the returned receipt when you want the row handle immediately.
To update fields on an existing row:
db.update("cards", card, { done: true });
Membership
Once users have joined a row you can inspect and manage the member list:
// Flat list of usernames
const members = await db.listMembers(board);
// With roles
const detailed = await db.listDirectMembers(board);
// → [{ username: "alice", role: "editor" }, ...]
// Add or remove manually
await db.addMember(board, "bob", "editor").committed;
await db.removeMember(board, "eve").committed;
Membership inherited through a parent row is visible via listEffectiveMembers.
Real-time sync (CRDT)
Vennbase includes a CRDT message bridge. Connect any CRDT library to a row and all members receive each other's updates in real time.
Sending CRDT updates requires "editor" access, but all members can poll and receive them.
In React, here is the recommended Yjs integration:
import * as Y from "yjs";
import { createYjsAdapter } from "@vennbase/yjs";
import { useCrdt } from "@vennbase/react";
const adapter = createYjsAdapter(Y);
const { value: doc, flush } = useCrdt(board, adapter);
// Write to doc normally, then push immediately when needed
await flush();
@vennbase/yjs uses your app's yjs instance instead of bundling its own runtime, which avoids the multi-runtime Yjs failure mode.
Example apps
packages/todo-app is the code from this README assembled into a working app — boards, recent boards, cards, and share links. Run it with:
pnpm --filter todo-app dev
For a fuller picture of how the pieces fit together in a real app, read packages/woof-app. It uses CRDT-backed live chat, user-scoped history rows for room restore, child rows with per-user metadata, and role-aware UI — the patterns you'll reach for once basic reads and writes are working.
pnpm --filter woof-app dev
packages/appointment-app goes further into access-control territory: a blind booking inbox where customers can add rows without seeing each other, run anonymous slot-availability queries using select: "keys", and schema design that controls exactly what key-only queries expose. Read PATTERNS.md for a recipe-style walkthrough of each pattern.
pnpm --filter appointment-app dev