From cc9e5e86c75c439b6dc447c8fcd5c5decaccdc54 Mon Sep 17 00:00:00 2001 From: Max Bader Date: Tue, 21 Jul 2026 10:42:48 -0500 Subject: [PATCH 1/4] =?UTF-8?q?Add=20bool.ai=20=E2=80=94=20server-side=20A?= =?UTF-8?q?I=20with=20no=20API=20key=20in=20the=20app=20bundle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bool.ai.generate/stream route through the gateway AI plane (/_bool/v1/ai/*), which runs the prompt against Bool's own provider credential and meters one AI credit against the app owner's workspace. The key never reaches the client — a static Bool becomes an AI app with no signup, no .env, no backend. Mirrors the db/users planes: credentials included, viewer/eu-session identity headers replayed for the preview. - src/client.ts: BoolAi + BoolAiSchema + BoolAiError types, `ai` on BoolClient, and the implementation (generate text/structured, stream via a text/plain reader). - src/index.ts: export the new types + BoolAiError. - src/client.test.ts: generate (text + schema), failure → BoolAiError w/ status + code, stream chunking, non-ok stream, viewer-token replay. - README + CHANGELOG; version 0.2.0-next.11 (additive, canary channel — .10 is reserved for the in-flight bool.secrets branch). Requires the gateway AI plane in the Bool platform repo (lib/gateway/ai-route.ts). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 22 ++++++++++ README.md | 30 +++++++++++-- package.json | 4 +- src/client.test.ts | 85 ++++++++++++++++++++++++++++++++++- src/client.ts | 107 +++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 3 ++ 6 files changed, 244 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df1bad7..7d8136b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 0.2.0-next.11 + +Adds `bool.ai` — the AI battery. A deployed app can call a model with NO API key +in the bundle: the call routes through the gateway's AI plane (`/_bool/v1/ai`), +which runs the prompt against Bool's own provider credential and meters one AI +credit against the app owner's workspace. The key never reaches the client. + +- `bool.ai.generate(prompt)` → `Promise` — plain text. +- `bool.ai.generate({ prompt, schema })` → `Promise` — structured output + validated against a JSON Schema; returns the parsed, typed object. +- `bool.ai.stream(prompt)` → `AsyncIterable` — text chunks for + typewriter UIs. +- New exports: `BoolAi`, `BoolAiSchema`, and `BoolAiError` (carries `status` + + machine-readable `code`, e.g. `"out_of_ai_credits"` on a 402). + +Additive on the canary channel. The plane is gated server-side by the `bool-ai` +feature flag (off by default), so `bool.ai` only works where the workspace has +been opted in. (Numbered `.11`; `.10` is reserved for the in-flight +`bool.secrets` branch.) + +Requires the gateway AI plane in the Bool platform repo (`lib/gateway/ai-route.ts`). + ## 0.2.0-next.9 Fix: `AuthGate` / `useSignInForm` no longer disagree about a pending diff --git a/README.md b/README.md index 72a4ce6..2ff47e8 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,28 @@ tested, and upgradable independently of any one app. `onAuthStateChange`, password reset) but talks to the Bool gateway's users plane, so each app has its own isolated accounts and the client never handles a credential. +- **AI battery.** `client.ai` gives a deployed app server-side AI with **no API + key in the bundle** — calls route through the gateway's AI plane + (`/_bool/v1/ai`), which runs the prompt against Bool's provider credential and + meters one AI credit against the app owner. Returns results directly and throws + a `BoolAiError` (with `status` + `code`, e.g. `"out_of_ai_credits"`) on failure: + ```ts + const text = await bool.ai.generate("Summarize this review: " + review); + + 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"], + }, + }); + + for await (const chunk of bool.ai.stream("Write a haiku")) setText((t) => t + chunk); + ``` + Requires the workspace to be opted into the `bool-ai` server flag. - **React auth layer** (`bool-sdk/react`): ``, `useBoolAuth()`, ``, and the headless `useSignInForm()` state machine that login forms bind to. @@ -83,10 +105,10 @@ create more than one. ## Compatibility -The gateway wire paths (`/_bool/v1/db`, `/_bool/v1/users`) are append-only: -new server behavior ships under a new version segment, never by mutating what -existing SDK versions call. Keep this SDK in sync with the gateway routes in -the Bool platform repo (`lib/gateway/`). +The gateway wire paths (`/_bool/v1/db`, `/_bool/v1/users`, `/_bool/v1/ai`) are +append-only: new server behavior ships under a new path/version segment, never +by mutating what existing SDK versions call. Keep this SDK in sync with the +gateway routes in the Bool platform repo (`lib/gateway/`). ## Development diff --git a/package.json b/package.json index b2998b3..3984612 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bool-sdk", - "version": "0.2.0-next.9", - "description": "Client SDK for apps built on Bool — gateway data access, end-user auth, and the React auth layer.", + "version": "0.2.0-next.11", + "description": "Client SDK for apps built on Bool — gateway data access, end-user auth, the AI battery, and the React auth layer.", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/src/client.test.ts b/src/client.test.ts index a43aefc..b8cc44f 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -1,5 +1,10 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { createBoolClient, getDefaultBoolClient, type BoolClientConfig } from "./client"; +import { + createBoolClient, + getDefaultBoolClient, + BoolAiError, + type BoolClientConfig, +} from "./client"; // Behavioral tests for the gateway client with fetch/sessionStorage stubbed. // These pin the invariants that make the client correct + secure: REST and @@ -282,6 +287,84 @@ describe("per-user API key", () => { }); }); +describe("bool.ai battery", () => { + test("generate(prompt) POSTs to the ai plane and returns text", async () => { + respond = () => + new Response(JSON.stringify({ text: "a summary" }), { + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(CONFIG); + const out = await client.ai.generate("summarize this"); + expect(out).toBe("a summary"); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe("https://bool.test/served/my-app/_bool/v1/ai/generate"); + expect(calls[0]!.init?.method).toBe("POST"); + expect(calls[0]!.init?.credentials).toBe("include"); + expect(JSON.parse(String(calls[0]!.init?.body))).toEqual({ prompt: "summarize this" }); + }); + + test("generate({prompt, schema}) sends the schema and returns the object", async () => { + respond = () => + new Response(JSON.stringify({ object: { sentiment: "positive" } }), { + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(CONFIG); + const out = await client.ai.generate<{ sentiment: string }>({ + prompt: "rate this", + schema: { type: "object", properties: { sentiment: { type: "string" } } }, + }); + expect(out).toEqual({ sentiment: "positive" }); + const body = JSON.parse(String(calls[0]!.init?.body)); + expect(body.schema).toEqual({ type: "object", properties: { sentiment: { type: "string" } } }); + }); + + test("generate throws BoolAiError carrying status + code on failure", async () => { + respond = () => + new Response(JSON.stringify({ error: "out_of_ai_credits" }), { + status: 402, + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(CONFIG); + const err = (await client.ai.generate("hi").catch((e) => e)) as BoolAiError; + expect(err).toBeInstanceOf(BoolAiError); + expect(err.status).toBe(402); + expect(err.code).toBe("out_of_ai_credits"); + }); + + test("stream yields decoded text chunks", async () => { + respond = () => new Response("Hello, world"); + const client = createBoolClient(CONFIG); + let acc = ""; + for await (const chunk of client.ai.stream("tell me a story")) acc += chunk; + expect(acc).toBe("Hello, world"); + expect(calls[0]!.url).toBe("https://bool.test/served/my-app/_bool/v1/ai/stream"); + expect(calls[0]!.init?.method).toBe("POST"); + }); + + test("stream throws BoolAiError on a non-ok response", async () => { + respond = () => + new Response(JSON.stringify({ error: "rate_limited" }), { + status: 429, + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(CONFIG); + const iter = client.ai.stream("go"); + const err = await iter[Symbol.asyncIterator]().next().catch((e) => e); + expect(err).toBeInstanceOf(BoolAiError); + expect((err as BoolAiError).code).toBe("rate_limited"); + }); + + test("replays the preview viewer token as x-bool-viewer", async () => { + respond = () => + new Response(JSON.stringify({ text: "ok" }), { + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient({ ...CONFIG, viewerToken: "viewer-123" }); + await client.ai.generate("hi"); + expect(headersOf(calls[0]!).get("x-bool-viewer")).toBe("viewer-123"); + }); +}); + describe("default client registry", () => { test("the last-created client is the default (hot reload re-registers)", () => { const first = createBoolClient(CONFIG); diff --git a/src/client.ts b/src/client.ts index 77ff09b..e045a6b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -99,6 +99,41 @@ export type BoolAuth = { * whatever you derive from that table — the ping never carries the data. */ export type BoolChangePayload = { table?: string; op?: string }; +/** A JSON Schema describing the shape `bool.ai.generate` should return. Passed + * straight to the gateway, which validates the model's output against it. e.g. + * `{ type: "object", properties: { sentiment: { type: "string" } }, required: ["sentiment"] }`. */ +export type BoolAiSchema = Record; + +/** Thrown when a bool.ai call fails. `status` is the gateway HTTP status and + * `code` its machine-readable error (e.g. "out_of_ai_credits" on a 402, + * "rate_limited" on a 429) so app code can branch without string-matching. */ +export class BoolAiError extends Error { + readonly status: number; + readonly code: string; + constructor(code: string, status: number) { + super(`bool.ai failed: ${code} (${status})`); + this.name = "BoolAiError"; + this.code = code; + this.status = status; + } +} + +/** The AI battery — server-side AI with NO API key in the app bundle. Each call + * routes through the Bool gateway (/_bool/v1/ai), which runs the prompt against + * Bool's own provider credential and meters one AI credit against the app + * owner's workspace. The key never reaches the client. Returns results directly + * and THROWS a {@link BoolAiError} on failure (same ergonomics as `entities`). */ +export type BoolAi = { + /** Generate plain text from a prompt. */ + generate(prompt: string): Promise; + /** Generate structured output validated against a JSON Schema. Returns the + * parsed object, typed as `T` when you supply it. */ + generate(opts: { prompt: string; schema: BoolAiSchema }): Promise; + /** Stream generated text as it's produced — an async iterator of text chunks, + * for typewriter UIs: `for await (const chunk of bool.ai.stream(p)) …`. */ + stream(prompt: string): AsyncIterable; +}; + /** The gateway-routed supabase-js client. Loosely typed on the schema-name * generic because each Bool runs in its own non-"public" schema. */ export type BoolDb = SupabaseClient; @@ -113,6 +148,9 @@ export type BoolClient = { entities: EntitiesModule; /** End-user auth for this app (gateway users plane). */ auth: BoolAuth; + /** The AI battery: `ai.generate(prompt)` / `ai.generate({prompt, schema})` / + * `ai.stream(prompt)`. Server-side AI with no API key in the bundle. */ + ai: BoolAi; /** This app's private Postgres schema name. */ schema: string; /** Subscribe to the app's realtime "doorbell": fires whenever any row in the @@ -445,6 +483,74 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { }, }; + // bool.ai battery — POST the prompt to the gateway AI plane + // (/_bool/v1/ai/*), which runs it against Bool's provider credential and + // meters one AI credit against the app owner. credentials:include + the + // viewer/eu-session identity headers mirror the db and users planes so the + // same live-gate identity flows (same-origin cookie deployed, viewer token + // cross-origin in preview). + function aiHeaders(): Record { + const headers: Record = { "content-type": "application/json" }; + if (viewerToken) headers["x-bool-viewer"] = viewerToken; + if (euSessionToken) headers["x-bool-eu-session"] = euSessionToken; + return headers; + } + const ai: BoolAi = { + // One impl covers both overloads (string prompt → text; {prompt, schema} → + // structured object). The public BoolAi type exposes the two typed forms. + generate: (async ( + promptOrOpts: string | { prompt: string; schema?: BoolAiSchema }, + ): Promise => { + const opts = + typeof promptOrOpts === "string" ? { prompt: promptOrOpts } : promptOrOpts; + const res = await fetch(`${GATEWAY}/_bool/${GATEWAY_API}/ai/generate`, { + method: "POST", + headers: aiHeaders(), + credentials: "include", + body: JSON.stringify({ prompt: opts.prompt, schema: opts.schema }), + }); + let body: any = null; + try { + body = await res.json(); + } catch (_) {} + if (!res.ok) throw new BoolAiError(body?.error ?? "ai_failed", res.status); + // Structured → { object }; plain → { text }. Return the inner value. + return opts.schema ? body?.object : body?.text; + }) as BoolAi["generate"], + + async *stream(prompt: string): AsyncIterable { + const res = await fetch(`${GATEWAY}/_bool/${GATEWAY_API}/ai/stream`, { + method: "POST", + headers: aiHeaders(), + credentials: "include", + body: JSON.stringify({ prompt }), + }); + if (!res.ok || !res.body) { + let body: any = null; + try { + body = await res.json(); + } catch (_) {} + throw new BoolAiError(body?.error ?? "ai_failed", res.status); + } + // The gateway streams raw text deltas (text/plain). Decode and yield each + // chunk as it arrives. + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value, { stream: true }); + if (chunk) yield chunk; + } + const tail = decoder.decode(); + if (tail) yield tail; + } finally { + reader.releaseLock(); + } + }, + }; + // Realtime "doorbell": the app schema's grants are revoked, so Supabase // `postgres_changes` never fires. Instead the server broadcasts a // row-data-free ping on the PUBLIC channel "bool:" + schema whenever any @@ -468,6 +574,7 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { db, entities: createEntitiesModule(db, subscribeToChanges), auth, + ai, schema, subscribeToChanges, }; diff --git a/src/index.ts b/src/index.ts index b14b3cd..fe37e82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,9 @@ export { type BoolClient, type BoolClientConfig, type BoolAuth, + type BoolAi, + type BoolAiSchema, + BoolAiError, type BoolUser, type BoolChangePayload, type AuthEvent, From af5dfb07a16402bf4213d90f8f2c5a52b15fc229 Mon Sep 17 00:00:00 2001 From: max-bader <154659926+max-bader@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:59:35 -0500 Subject: [PATCH 2/4] Downgrade version from 0.2.0-next.11 to 0.2.0-next.10 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3984612..863d544 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bool-sdk", - "version": "0.2.0-next.11", + "version": "0.2.0-next.10", "description": "Client SDK for apps built on Bool — gateway data access, end-user auth, the AI battery, and the React auth layer.", "type": "module", "main": "./dist/index.js", From b6397f4744ecce4987eae593ecfa929bfe3ead30 Mon Sep 17 00:00:00 2001 From: max-bader <154659926+max-bader@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:01:51 -0500 Subject: [PATCH 3/4] Revise changelog version number Update version from 0.2.0-next.11 to 0.2.0-next.10 in the changelog. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d8136b..8169e00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.2.0-next.11 +## 0.2.0-next.10 Adds `bool.ai` — the AI battery. A deployed app can call a model with NO API key in the bundle: the call routes through the gateway's AI plane (`/_bool/v1/ai`), From dac538f8791ac8a2917e87c6a4803eccb4fbb85d Mon Sep 17 00:00:00 2001 From: Max Bader Date: Tue, 21 Jul 2026 11:16:50 -0500 Subject: [PATCH 4/4] Drop stale changelog note about the reserved next.10 / bool.secrets branch Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8169e00..8887de2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,7 @@ credit against the app owner's workspace. The key never reaches the client. Additive on the canary channel. The plane is gated server-side by the `bool-ai` feature flag (off by default), so `bool.ai` only works where the workspace has -been opted in. (Numbered `.11`; `.10` is reserved for the in-flight -`bool.secrets` branch.) +been opted in. Requires the gateway AI plane in the Bool platform repo (`lib/gateway/ai-route.ts`).