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.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.
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.
Batteries — bool.ai and bool.fetch
Two calls that need a credential your app must never hold. Both run on Bool’s server: the app describes what it wants, the key stays server-side, and the result comes back. Anything in your app’s source is public — it ships to every visitor — so this is the only way to use a key from a Bool.
Both spend the owning workspace’s credits — the same balance your own chats spend — and both need to be enabled for your workspace before they answer. An AI call is charged for what it costs to run: a typical AI message costs well under a hundredth of a credit, and longer prompts and bigger responses cost more.
bool.ai — AI with no API key
const text = await bool.ai.generate("Summarize this review: " + review);
// Structured output, typed, no parsing:
const { sentiment, topics } = await bool.ai.generate<{
sentiment: string; topics: string[];
}>({
prompt: review,
schema: {
type: "object",
properties: { sentiment: { type: "string" }, topics: { type: "array", items: { type: "string" } } },
required: ["sentiment", "topics"],
},
});
// Streaming, for typewriter UIs:
for await (const chunk of bool.ai.stream(prompt)) setText((t) => t + chunk);Returns the value directly and throws a BoolAiError on failure — there’s no
{ data, error }. Reach for it for generation, classification, summarization,
extraction, or a chat feature. Don’t hand-roll fetch to a model provider and
don’t ask anyone for an API key: this is the way to call a model from a Bool.
When an AI call is refused, your app already has the failure state for it.
Every Bool ships src/components/AiError.tsx: put the caught error in state and
render it where the answer would have gone.
const [error, setError] = useState<unknown>(null);
async function run() {
setError(null);
try {
setAnswer(await bool.ai.generate(prompt));
} catch (e) {
setError(e);
}
}
<Button onClick={run}>Summarize</Button>
<AiError error={error} onRetry={run} />It writes the right message for each cause — out of credits, this app’s daily
limit, too many requests, or a plain failure — and only offers Try again when
retrying can actually work. One balance funds everything your app runs and your own
chats besides, so out_of_app_credits here doesn’t necessarily mean AI usage spent
it. Your app’s users never see the reason — they
read “AI features are unavailable”, plus the day it’s back when we know one — and
you get an email each time it happens, so you hear it from us rather than
from them. Leave the button and the page where they are: an app
that hides its AI section when the credits run out looks to a visitor like an app that
never had the feature.
bool.fetch — any API, using a key you stored
Add the key in your Bool’s Secrets tab (you type it once; it’s never shown
again and never reaches your app’s users). Then write {{THE_KEY_NAME}} wherever
the key goes, and Bool substitutes the real value server-side:
const res = await bool.fetch(
"https://api.example.com/v1/things?key={{EXAMPLE_API_KEY}}",
);
if (!res.ok) return; // the API's own status, exactly like fetch
const data = await res.json();
await bool.fetch("https://api.example.com/v1/things", {
method: "POST",
headers: { Authorization: "Bearer {{EXAMPLE_API_KEY}}" },
body: JSON.stringify({ name }),
});It takes the same arguments as fetch and resolves to a real Response, so
res.ok, res.status and res.json() mean what they always mean. A placeholder
works in the URL, in a header value, or anywhere in the body.
A response from the API is never an error, including a 4xx — check res.ok
yourself. It throws a BoolFetchError only when the request was never made:
err.code | What happened |
|---|---|
secret_not_set | The key exists by name but hasn’t been filled in yet — show a “not set up yet” state |
unknown_secret | No key by that name; add it in the Secrets tab |
host_not_allowed | That key isn’t registered for the host you called |
rate_limited | Too many calls from one visitor |
out_of_app_credits | The owning workspace is out of credits |
err.secrets names the keys involved, so you can tell someone exactly which one
is missing.
Each key may only be sent to the one host its owner registered it for — so a key can’t be redirected anywhere else, even by someone using your app. Keys for a service split across hosts need one entry per host.
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 |
bool.ai failure | Throws BoolAiError with code + status |
bool.fetch, API responded | No error — check res.ok; a 4xx is the API’s answer |
bool.fetch, call never made | Throws BoolFetchError with code + secrets |
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 → API 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