Skip to Content
SDK reference

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-sdk

Stable since 0.3.0. Apps Bool generates already depend on it at ^0.3.0. A next channel 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.

PropertyWhat it is
bool.entitiesTable data — CRUD, queries, live views. Most of this page.
bool.authYour app’s own end-user accounts.
bool.dbThe underlying supabase-js client (Storage, and tables that aren’t entities).
bool.schemaThis 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:

FieldTypeNotes
dataT[]Live rows — server state with your in-flight writes layered on top.
loadingbooleantrue only until the first load settles.
errorunknownLast 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:

  1. It’s a React hook. Call it at the top of a component, unconditionally.
  2. Don’t copy data into useState. The duplicate goes stale and makes rows flicker. Render todos.data directly.
  3. Handle all three states. Rendering only data turns 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 columns

Pagination: 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 level

Operators: $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 + bulkCreate

These 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/$mul are atomic. $push/$pull can’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 entity

Use 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.codeWhat happened
secret_not_setThe key exists by name but hasn’t been filled in yet — show a “not set up yet” state
unknown_secretNo key by that name; add it in the Secrets tab
host_not_allowedThat key isn’t registered for the host you called
rate_limitedToo many calls from one visitor
out_of_app_creditsThe 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

SituationWhat you get
useQuery mutationsNever throw — resolve to null/false and set error
Promise methodsThrow; the error carries the Postgres code, details, hint
.get(id) on a missing rowThrows
limit over 5000Throws (rather than silently truncating)
.useQuery() without the React entry importedThrows, naming the exact fix
Unique violationPostgres 23505, with a details line naming the duplicate value
bool.ai failureThrows BoolAiError with code + status
bool.fetch, API respondedNo error — check res.ok; a 4xx is the API’s answer
bool.fetch, call never madeThrows BoolFetchError with code + secrets

Environment and keys

VariableWherePurpose
BOOL_API_KEYserver / scripts onlyAdmin 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_TOKENyour machinePersonal 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

Last updated on