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 app credits (your app’s runtime pool), 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 uses about 1 app credit, and longer prompts and bigger responses use 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 app credits, this app’s daily
limit, too many requests, or a plain failure — and only offers Try again when
retrying can actually work. Your app credits are one pool shared across everything
your app runs (bool.fetch draws from it too), 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 app is out of app 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