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.aiText generation, billed to the app owner’s juice.
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.


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


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

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 → Access tokens.

Inside a generated app both are wired for you; you don’t reference them.


See also

Last updated on