SDK reference
Complete reference for bool-sdk, the client every Bool app talks to its
backend through. Use it inside a Bool (already wired), or locally / from your
own backend via the CLI.
This page is deliberately exhaustive and flat, so a coding agent can read it
once and get the whole surface. If you’re pointing an AI at Bool, give it
/llms.txt.
npm install bool-sdkStable since 0.3.0. Apps Bool generates already depend on it at
^0.3.0. Anextchannel still exists for prereleases — you only want it if you’re testing something unreleased.
The client
Inside a Bool, src/lib/supabase.ts is generated for you and exports a ready
client — import that, don’t create your own:
import { bool } from "@/lib/supabase";Outside a Bool (local script, your own backend, another frontend), create one
from the config bool create / bool link wrote:
import { createBoolClient } from "bool-sdk";
import "bool-sdk/react"; // only if you use the React hooks
import config from "./bool.config.json";
export const bool = createBoolClient({
supabaseUrl: config.supabaseUrl,
supabaseAnonKey: config.supabaseAnonKey,
schema: config.schema,
appOrigin: config.appOrigin,
slug: config.slug,
apiKey: process.env.BOOL_API_KEY, // server/script only — never ship to a browser
});createBoolClient also registers the client it returns as the default, which is
how the React hooks find it without being handed anything.
| Property | What it is |
|---|---|
bool.entities | Table data — CRUD, queries, live views. Most of this page. |
bool.auth | Your app’s own end-user accounts. |
bool.ai | Text generation, billed to the app owner’s juice. |
bool.db | The underlying supabase-js client (Storage, and tables that aren’t entities). |
bool.schema | This app’s Postgres schema name. |
Entities
bool.entities.<table> gives you a handler for any table. It’s dynamic — no
per-table setup — and typed per-table if you generate types (bool types),
which is what makes bool.entities.todso a build error instead of a runtime
surprise.
Two kinds of call, and the distinction matters:
useQuery()— a live React view. What every screen should use.- Everything else — one-shot promises. For event handlers, scripts, the CLI, and any non-React code.
Live views — useQuery(options?)
const todos = bool.entities.todos.useQuery({
filter: { done: false },
sort: "-created_at",
limit: 100,
});Returns:
| Field | Type | Notes |
|---|---|---|
data | T[] | Live rows — server state with your in-flight writes layered on top. |
loading | boolean | true only until the first load settles. |
error | unknown | Last failure, cleared by the next success. Render this. |
create(fields) | Promise<T | null> | Optimistic insert. null on failure (already rolled back). |
update(id, fields) | Promise<T | null> | Optimistic patch. |
remove(id) | Promise<boolean> | Optimistic delete. false on failure (row restored). |
refetch() | Promise<void> | Force a reload. Rarely needed — changes arrive on their own. |
What you get for free, and should not rebuild by hand: the initial load, the live subscription, merge-by-id (so a change never replaces your list), ordering so a slow response can’t rewind the view, burst coalescing, and optimistic writes with automatic rollback.
Three rules that come from real bugs:
- It’s a React hook. Call it at the top of a component, unconditionally.
- Don’t copy
dataintouseState. The duplicate goes stale and makes rows flicker. Rendertodos.datadirectly. - Handle all three states. Rendering only
dataturns a failed load into a convincing empty screen — a lie the user acts on:
if (todos.loading) return <Skeleton />;
if (todos.error) return <button onClick={() => todos.refetch()}>Couldn't load — retry</button>;
if (!todos.data.length) return <Empty />;The mutations do not throw — check the result:
const created = await todos.create({ title });
if (!created) toast.error("Couldn't save that — try again.");useEntity("todos", opts) from bool-sdk/react is the same hook by its older,
string-keyed name. Prefer useQuery.
Reads (promises)
await bool.entities.todos.list(); // newest 50
await bool.entities.todos.list("-created_at", 100, 0); // sort, limit, skip
await bool.entities.todos.filter({ done: false }, "title", 100);
await bool.entities.todos.get(id); // throws if missing
await bool.entities.todos.list("-created_at", 50, 0, ["id", "title"]); // pick columnsPagination: list/filter return the newest 50 rows by default. Max
per call is 5000 — asking for more throws rather than silently truncating.
Page with limit + skip. If a screen looks like it’s missing data, it’s
almost always this default.
Filters
MongoDB-style objects:
{ status: "active" } // equals
{ count: { $gte: 10 } } // operators
{ id: ["a", "b"] } // any of
{ archived_at: null } // is null
{ $or: [{ a: 1 }, { b: 2 }] } // $or / $and at the top levelOperators: $eq $ne $gt $gte $lt $lte $in $nin $exists $regex.
Sort is a string: "-created_at" (newest first), "title" (ascending).
Writes (promises)
await bool.entities.todos.create({ title: "Buy milk" });
await bool.entities.todos.bulkCreate([{ title: "a" }, { title: "b" }]);
await bool.entities.todos.update(id, { done: true });
await bool.entities.todos.bulkUpdate([{ id, done: true }]); // upsert by id
await bool.entities.todos.delete(id);
await bool.entities.todos.deleteMany({ done: true });
await bool.entities.todos.importEntities(csvFile); // parse + bulkCreateThese throw on error (unlike the useQuery handles) — wrap in try/catch.
Conditional writes — the concurrency tool
updateMany(query, ops) applies one update to every matching row, in one
atomic statement. Put a precondition in the query and check how many rows
changed:
const { updated } = await bool.entities.seats.updateMany(
{ id, taken_by: "" }, // only if still free
{ $set: { taken_by: me } },
);
if (updated === 0) throw new Error("Too late — that seat is taken.");Two people doing this simultaneously can’t both succeed — exactly one gets
updated: 1. Never read a row, decide, then write: the gap between those
calls is where the bug lives.
Counters are the same story — $inc adds in the database, so simultaneous
increments don’t overwrite each other:
await bool.entities.posts.updateMany({ id }, { $inc: { views: 1 } });Update operators: $set, $unset, $inc, $mul, $push, $pull. A plain
object is treated as $set. Returns { success, updated, has_more }.
$set/$unset/$inc/$mulare atomic.$push/$pullcan’t be expressed atomically over the wire, so they read-modify-write — don’t rely on them under concurrency.
subscribe(cb) — the low-level primitive
const unsubscribe = bool.entities.todos.subscribe((change) => {
// change: { table?, op?, id?, row? }
});Fires when any row in that table changes. row carries the full row when the
change’s audience is knowable at write time, and is absent otherwise — so a
consumer must handle both. useQuery is built on this — reach
for it only outside React. Do not use it to hand-roll “refetch the whole list on
every ping”: that pattern is what makes rows pop in and out.
Managed columns
Every entity table has id (uuid), created_at, and — on private entities —
owner_id. Don’t declare them; every method keys on id and sorts by
created_at.
For a private entity, owner_id defaults to the signed-in end user, so app
code never sets it. But an admin key (BOOL_API_KEY) has no end-user
identity, so a create from a script must pass it explicitly:
await bool.entities.tasks.create({ title: "Buy milk", owner_id: userId });Auth — bool.auth
Your app’s own end users, separate from your Bool account. React apps should
use the generated @/lib/bool-auth (provider, useBoolAuth(), <AuthGate>,
useSignInForm()) rather than calling these directly.
The shape mirrors supabase.auth, deliberately — so code (and models) already
know it. Passwords are hashed server-side and the session lives in an httpOnly
cookie; nothing sensitive is held client-side.
await bool.auth.signUp({ email, password }); // → { data: { user }, error }
await bool.auth.signInWithPassword({ email, password });
bool.auth.signInWithOAuth({ provider: "google" }); // redirects; not a promise
await bool.auth.signOut(); // → { error }
const { data: { user } } = await bool.auth.getUser(); // null when signed out
await bool.auth.resetPasswordForEmail(email);
await bool.auth.confirmPasswordReset({ token, password });
const sub = bool.auth.onAuthStateChange((user) => { /* … */ });
sub.data.subscription.unsubscribe();
await bool.auth.rotateApiKey(); // → { data: { apiKey } }Every call returns { data, error } rather than throwing. Note
signInWithPassword (not signIn) and signInWithOAuth({ provider: "google" })
(not signInWithGoogle) — the names match supabase-js.
Never call supabase.auth.* (i.e. bool.db.auth) — that’s the shared platform
auth, not your app’s.
Full guide: Sign-in for your app’s users.
AI — bool.ai
Text generation with no API key of your own; billed to the app owner’s juice.
// Plain text — resolves to a string, not an object.
const summary = await bool.ai.generate("Summarize this in one line: …");
// Structured output — validated against your JSON Schema, returns the parsed object.
const { sentiment } = await bool.ai.generate<{ sentiment: string }>({
prompt: `Classify: ${review}`,
schema: {
type: "object",
properties: { sentiment: { type: "string", enum: ["positive", "negative"] } },
required: ["sentiment"],
},
});Failures throw BoolAiError with a machine-readable code and the gateway
status, so you can branch without string-matching:
try {
const text = await bool.ai.generate(prompt);
} catch (err) {
if (err.code === "out_of_ai_credits") { /* 402 — the owner is out of juice */ }
if (err.code === "rate_limited") { /* 429 */ }
}Storage and non-entity tables — bool.db
bool.db is the underlying supabase-js client, with the schema pre-wired.
await bool.db.storage.from("uploads").upload(path, file);
await bool.db.from("post_tags").select(); // a table that isn't an entityUse it for file storage, and for tables that genuinely can’t be entities —
the standard case is a join table keyed (post_id, tag_id) with no id column,
since every entity method keys on a uuid id. Anything table-shaped that can
be an entity should be one.
Live data: what works and what doesn’t
Works today. Row changes reach every viewer in one hop, typically under 150ms, on a private channel that only a short-lived token minted for that viewer can join — so live data is not a “make it public” tradeoff. Your own writes show instantly and roll back if they fail. Nothing to configure.
Not yet: continuously moving signals. Live cursors, a stroke while it’s still being drawn, per-frame movement in a fast game. Those need a client-to-client channel that doesn’t exist yet.
If you’re building something like that now, don’t push positions through the database at input speed — it’s one network round trip and one write per mouse move, it floods every other viewer, and it still looks choppy. Instead:
- Persist only the durable artifact — one write when a stroke ends, not per point.
- Throttle anything presence-shaped to ≤1 update/sec per person, with stale rows cleaned up.
Errors
| Situation | What you get |
|---|---|
useQuery mutations | Never throw — resolve to null/false and set error |
| Promise methods | Throw; the error carries the Postgres code, details, hint |
.get(id) on a missing row | Throws |
limit over 5000 | Throws (rather than silently truncating) |
.useQuery() without the React entry imported | Throws, naming the exact fix |
| Unique violation | Postgres 23505, with a details line naming the duplicate value |
Environment and keys
| Variable | Where | Purpose |
|---|---|---|
BOOL_API_KEY | server / scripts only | Admin data access — full read/write, no end-user identity. Never ship to a browser. In Vite, VITE_BOOL_API_KEY is for local dev only. |
BOOL_TOKEN | your machine | Personal access token for CLI calls (create, link, types, deploy). From Settings → Access tokens. |
Inside a generated app both are wired for you; you don’t reference them.
See also
- Develop locally (CLI) —
bool create,link,types,entities push,deploy - Database — the Dashboard view, and how live updates behave
- Sign-in for your app’s users
- Connect your AI (MCP) — drive Bool from Claude or ChatGPT
bool-sdkon npm