@abloatai/ablo 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/AGENTS.md +84 -0
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +53 -27
  4. package/dist/BaseSyncedStore.d.ts +2 -36
  5. package/dist/BaseSyncedStore.js +11 -55
  6. package/dist/NetworkMonitor.js +4 -1
  7. package/dist/SyncClient.d.ts +22 -5
  8. package/dist/SyncClient.js +77 -0
  9. package/dist/SyncEngineContext.js +5 -1
  10. package/dist/agent/index.js +1 -1
  11. package/dist/api/index.d.ts +1 -1
  12. package/dist/auth/index.js +3 -1
  13. package/dist/cli.cjs +302645 -0
  14. package/dist/client/Ablo.d.ts +19 -52
  15. package/dist/client/Ablo.js +30 -106
  16. package/dist/client/ApiClient.d.ts +1 -113
  17. package/dist/client/ApiClient.js +39 -238
  18. package/dist/client/auth.js +32 -2
  19. package/dist/client/createInternalComponents.js +1 -1
  20. package/dist/client/createModelProxy.d.ts +9 -0
  21. package/dist/client/createModelProxy.js +34 -10
  22. package/dist/client/httpClient.d.ts +5 -6
  23. package/dist/client/httpClient.js +2 -3
  24. package/dist/client/index.d.ts +1 -1
  25. package/dist/client/persistence.d.ts +6 -1
  26. package/dist/client/persistence.js +1 -1
  27. package/dist/client/registerDataSource.d.ts +4 -4
  28. package/dist/client/registerDataSource.js +39 -31
  29. package/dist/client/writeOptionsSchema.d.ts +50 -0
  30. package/dist/client/writeOptionsSchema.js +57 -0
  31. package/dist/core/index.d.ts +18 -26
  32. package/dist/core/index.js +22 -46
  33. package/dist/errorCodes.d.ts +13 -0
  34. package/dist/errorCodes.js +19 -4
  35. package/dist/index.d.ts +3 -0
  36. package/dist/index.js +8 -1
  37. package/dist/interfaces/index.d.ts +14 -4
  38. package/dist/mutators/UndoManager.d.ts +48 -5
  39. package/dist/mutators/UndoManager.js +166 -1
  40. package/dist/react/AbloProvider.d.ts +18 -8
  41. package/dist/react/index.d.ts +1 -1
  42. package/dist/react/index.js +1 -1
  43. package/dist/react/useUndoScope.js +7 -0
  44. package/dist/schema/ddl.js +2 -1
  45. package/dist/schema/field.js +2 -1
  46. package/dist/schema/serialize.js +2 -1
  47. package/dist/server/commit.d.ts +4 -5
  48. package/dist/server/storage-mode.d.ts +7 -0
  49. package/dist/server/storage-mode.js +6 -0
  50. package/dist/source/adapters/drizzle.js +3 -2
  51. package/dist/source/adapters/kysely.d.ts +68 -0
  52. package/dist/source/adapters/kysely.js +210 -0
  53. package/dist/source/adapters/memory.js +2 -1
  54. package/dist/source/adapters/prisma.js +3 -2
  55. package/dist/source/index.js +2 -1
  56. package/dist/transactions/TransactionQueue.d.ts +6 -7
  57. package/dist/transactions/TransactionQueue.js +33 -9
  58. package/dist/types/streams.d.ts +2 -1
  59. package/dist/utils/duration.js +3 -2
  60. package/dist/wire/frames.d.ts +6 -8
  61. package/docs/api.md +1 -1
  62. package/docs/cli.md +17 -4
  63. package/docs/client-behavior.md +1 -1
  64. package/docs/data-sources.md +129 -125
  65. package/docs/examples/ai-sdk-tool.md +11 -5
  66. package/docs/examples/existing-python-backend.md +26 -4
  67. package/docs/examples/nextjs.md +3 -2
  68. package/docs/examples/scoped-agent.md +38 -11
  69. package/docs/guarantees.md +2 -2
  70. package/docs/identity.md +86 -59
  71. package/docs/index.md +2 -2
  72. package/docs/integration-guide.md +89 -61
  73. package/docs/mcp.md +1 -1
  74. package/docs/quickstart.md +84 -37
  75. package/docs/react.md +39 -28
  76. package/docs/schema-contract.md +2 -4
  77. package/llms-full.txt +360 -0
  78. package/llms.txt +30 -18
  79. package/package.json +23 -3
@@ -1,26 +1,33 @@
1
1
  # Connect Your Database
2
2
 
3
- By default, Ablo stores the rows for the models you define, so you don't need a
4
- database to get started. But if you already have your own application database
5
- and want it to stay the source of truth, you can attach it as a Data Source —
6
- then Ablo coordinates each write and calls your app to commit it, instead of
7
- storing the data itself.
8
-
9
- That default makes Ablo the managed state store for your models, the same way
10
- Stripe stores `Customer` and `PaymentIntent` objects that you create through
11
- Stripe's API.
12
-
13
- Either way, you define an Ablo schema with `defineSchema`, `model`, and Zod
14
- the same way a Prisma project starts with a `schema.prisma`. Your schema
15
- describes your data once, and everything else (the SDK, agents, and your
16
- database connection) relies on that one definition.
17
-
18
- Your app can keep using its own `DATABASE_URL`. Store that value in your app or
19
- backend environment, not in Ablo. The integration boundary is the HTTPS
20
- endpoint your app exposes. The happy path uses the same server-side
21
- `ABLO_API_KEY` to verify Ablo calls.
22
-
23
- Use the SDK with an API key:
3
+ **Your database is the system of record Ablo never hosts your data.** Every
4
+ synced model is backed by your own Postgres; Ablo is the transaction layer on
5
+ top of it. There are two ways to connect, and they are the same product with the
6
+ same writes the only difference is where your database credential lives:
7
+
8
+ | | How Ablo reaches your Postgres | Use when |
9
+ |---|---|---|
10
+ | **Connection string** (default) | You pass `databaseUrl` to `Ablo(...)`; Ablo registers the connection and commits each write directly, behind row-level security. | You can hand over a scoped connection string. |
11
+ | **Signed endpoint** | Your app exposes one route built from an ORM adapter; Ablo sends signed commit requests and your app writes its own database. | Database credentials must never leave your infrastructure. |
12
+
13
+ Either way, you define an Ablo schema with `defineSchema`, `model`, and Zod. The
14
+ Ablo schema describes **only your synced, collaborative models** the rows Ablo
15
+ coordinates and fans out in realtime. It is *not* your whole-database schema and
16
+ does *not* replace your `schema.prisma` (or your Drizzle schema). Your auth,
17
+ billing, and any other non-synced tables stay in your own ORM schema, owned by
18
+ your own migrations. One database, two schemas, side by side: Ablo owns the
19
+ synced models; you keep owning everything else. `ablo check` reflects this it
20
+ reports your other tables as "ignored / owned by you," which is exactly right.
21
+
22
+ What Ablo stores, in both shapes: your schema *definition* (model names, fields,
23
+ types pushed with `ablo push`), your hashed API keys, a safe projection of the
24
+ connection registration (host, database, schema — the connection string itself
25
+ is sealed and never echoed back), and the commit log that drives sync. Never
26
+ your rows.
27
+
28
+ ## Connection String (default)
29
+
30
+ The canonical client carries all three values:
24
31
 
25
32
  ```ts
26
33
  import Ablo from '@abloatai/ablo';
@@ -29,29 +36,50 @@ import { schema } from './ablo/schema';
29
36
  export const ablo = Ablo({
30
37
  schema,
31
38
  apiKey: process.env.ABLO_API_KEY,
39
+ databaseUrl: process.env.DATABASE_URL, // your Postgres — rows live here, never with Ablo
32
40
  });
33
41
  ```
34
42
 
35
- Do not pass a database URL to `Ablo(...)`.
36
-
37
- For the first production integration, prefer this shape:
38
-
39
43
  ```bash
40
- # Stored only in your app/backend
41
- DATABASE_URL=postgres://...
42
-
43
- # The only Ablo credential in the customer app
44
+ # .env — server runtime only, never the browser
45
+ DATABASE_URL=postgres://ablo_app:...@host:5432/db
44
46
  ABLO_API_KEY=sk_live_...
45
47
  ```
46
48
 
47
- ## Backing Modes
49
+ On first connect the SDK registers the connection — sent once over TLS, stored
50
+ sealed, never returned by any API. From then on Ablo commits every confirmed
51
+ write directly to your database and reads canonical rows from it.
52
+
53
+ Safety requirements, enforced server-side before the first write:
54
+
55
+ - **Non-superuser role.** The connection must not be a superuser or hold
56
+ `BYPASSRLS` — Ablo's tenant isolation is row-level security, and a role that
57
+ can bypass it is rejected outright.
58
+ - **Row-level security on synced tables.** `npx ablo migrate` provisions your
59
+ synced-model tables with `FORCE ROW LEVEL SECURITY` already applied; tables
60
+ you create yourself must do the same.
61
+ - **Public hosts only.** Connection strings resolving to loopback or private
62
+ address ranges are rejected.
48
63
 
49
- | Mode | Where rows live | What `create/update/delete` does | Use when |
50
- |---|---|---|---|
51
- | Ablo-managed | Ablo | Writes directly to Ablo's managed state store, then returns the confirmed row and fans out realtime deltas. | New collaborative/agent state that can live in Ablo. |
52
- | Data Source | Your app database | Sends a signed commit request to your route; your app writes its DB and returns canonical rows. | Existing app tables, regulated data, or teams that need their DB to stay canonical. |
64
+ `databaseUrl` is server-only: the SDK throws if it sees one in a browser-like
65
+ environment, and `dangerouslyAllowBrowser` does not override that.
53
66
 
54
- The SDK call is the same in both modes:
67
+ ## Signed Endpoint
68
+
69
+ When a connection string must not leave your infrastructure, keep
70
+ `DATABASE_URL` in your app and expose one HTTPS endpoint instead. Ablo signs a
71
+ commit request; an ORM adapter in your route runs it in one transaction against
72
+ your Postgres and returns the canonical rows. Omit `databaseUrl` from
73
+ `Ablo(...)` in this setup — the client takes only the schema and the API key:
74
+
75
+ ```ts
76
+ export const ablo = Ablo({
77
+ schema,
78
+ apiKey: process.env.ABLO_API_KEY,
79
+ });
80
+ ```
81
+
82
+ The SDK call is identical in both shapes:
55
83
 
56
84
  ```ts
57
85
  await ablo.weatherReports.create({ data: { location: 'Stockholm', status: 'pending' } });
@@ -59,20 +87,18 @@ await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'read
59
87
  const report = ablo.weatherReports.get('report_stockholm');
60
88
  ```
61
89
 
62
- Only the backing store changes.
63
-
64
- Multiplayer behavior is the same in both modes. Writes made through
90
+ Multiplayer behavior is built in. Writes made through
65
91
  `ablo.<model>.create/update/delete` are coordinated by Ablo, then confirmed rows
66
92
  fan out to subscribers. If something writes to your database without going
67
93
  through Ablo (a cron job, an admin tool), Ablo can't know about it
68
94
  automatically. To keep everyone's screen up to date, your app reports those
69
- outside changes back through an events feed — shown below in
70
- [External Writes](#external-writes).
95
+ outside changes back through the outbox feed — shown below in
96
+ [Outbox Events](#outbox-events).
71
97
 
72
- ## When To Use A Data Source
98
+ ## Your Database Stays Canonical
73
99
 
74
- Use a Data Source only when your existing application database remains the
75
- source of truth and Ablo should coordinate writes against it.
100
+ Your application database remains the source of truth and Ablo coordinates writes
101
+ against it.
76
102
 
77
103
  If you are migrating an app where every button already calls a backend endpoint,
78
104
  read [Integration Guide](./integration-guide.md) first, then
@@ -100,59 +126,53 @@ The shape is the same as a production webhook integration:
100
126
 
101
127
  ## Route
102
128
 
129
+ You don't hand-write the commit transaction, the idempotency upsert, or the
130
+ outbox writes. You pass an ORM **adapter** and it does all of that for you —
131
+ transaction, exactly-once idempotency, and outbox — driven by the same Ablo
132
+ schema. The whole route is three fields:
133
+
103
134
  ```ts
104
135
  // app/api/ablo/source/route.ts
105
- import { dataSource, sourceEventForOperation } from '@abloatai/ablo';
136
+ import { dataSourceNext } from '@abloatai/ablo/source/next';
137
+ import { prismaDataSource } from '@abloatai/ablo/source';
106
138
  import { schema } from '@/ablo/schema';
107
- import { db } from '@/db';
139
+ import { prisma } from '@/lib/prisma';
108
140
 
109
- export const POST = dataSource({
141
+ // Data Source routes touch the database, so they run on the Node runtime.
142
+ export const runtime = 'nodejs';
143
+
144
+ export const { POST } = dataSourceNext({
110
145
  schema,
111
- apiKey: process.env.ABLO_API_KEY,
146
+ apiKey: process.env.ABLO_API_KEY!,
147
+ adapter: prismaDataSource(prisma, schema),
148
+ });
149
+ ```
112
150
 
113
- authorize() {
114
- return { db };
115
- },
151
+ Using Drizzle instead of Prisma is the same shape — swap the adapter for
152
+ `drizzleDataSource(db, schema)`:
116
153
 
117
- async commit({ operations, clientTxId, context }) {
118
- const rows = await context.auth.db.transaction(async (tx) => {
119
- await tx.idempotency.upsert({ key: clientTxId, operations });
120
- const changes = await applyOperations(tx, operations);
121
- await tx.outbox.createMany({
122
- data: changes.map(({ eventId, operation, entityId, data }) =>
123
- sourceEventForOperation({
124
- eventId,
125
- operation,
126
- entityId,
127
- data,
128
- ...(clientTxId ? { clientTxId } : {}),
129
- ...(context.scope?.organizationId
130
- ? { organizationId: context.scope.organizationId }
131
- : {}),
132
- occurredAt: Date.now(),
133
- }),
134
- ),
135
- });
136
- return changes.map(({ row }) => row);
137
- });
138
-
139
- return { rows };
140
- },
154
+ ```ts
155
+ // app/api/ablo/source/route.ts
156
+ import { dataSourceNext } from '@abloatai/ablo/source/next';
157
+ import { drizzleDataSource } from '@abloatai/ablo/source/drizzle';
158
+ import { schema } from '@/ablo/schema';
159
+ import { db } from '@/db';
141
160
 
142
- reports: {
143
- async load({ id, context }) {
144
- return context.auth.db.report.findUnique({ where: { id } });
145
- },
161
+ export const runtime = 'nodejs';
146
162
 
147
- async list({ query, context }) {
148
- return context.auth.db.report.findMany({
149
- take: query.limit ?? 100,
150
- });
151
- },
152
- },
163
+ export const { POST } = dataSourceNext({
164
+ schema,
165
+ apiKey: process.env.ABLO_API_KEY!,
166
+ adapter: drizzleDataSource(db, schema),
153
167
  });
154
168
  ```
155
169
 
170
+ The adapter is constructed from your ORM client and the Ablo `schema` —
171
+ `prismaDataSource(prisma, schema)` or `drizzleDataSource(db, schema)`. It maps
172
+ each synced model to your table, wraps every commit in one transaction, dedupes
173
+ on `clientTxId` via `ablo_idempotency`, and appends `ablo_outbox` rows for the
174
+ external-write feed — the bookkeeping you used to write by hand.
175
+
156
176
  Your app code still writes through the normal model API:
157
177
 
158
178
  ```ts
@@ -208,55 +228,39 @@ events.
208
228
 
209
229
  ## Outbox Events
210
230
 
211
- Return your outbox feed from an `events` handler so connected humans and agents
212
- stay current. Include SDK-origin events too. If Ablo already appended the commit
213
- directly, `clientTxId` lets Ablo filter the echo; if the direct append failed,
214
- the same outbox row repairs it on the next poll or push.
231
+ The adapter serves the outbox feed for you. Every `commit` it runs appends one
232
+ `ablo_outbox` row per operation in the same transaction, and the adapter's
233
+ built-in events handler streams those rows back to Ablo by cursor — so connected
234
+ humans and agents stay current with no extra code. If Ablo already appended the
235
+ commit directly, `clientTxId` lets Ablo filter the echo; if the direct append
236
+ failed, the same outbox row repairs it on the next poll or push.
215
237
 
216
- ```ts
217
- export const POST = dataSource({
218
- schema,
219
- apiKey: process.env.ABLO_API_KEY,
238
+ Events without `clientTxId` are treated as external writes. The only thing you
239
+ add by hand is recording *outside* writes — changes made to your tables by a
240
+ cron job or admin tool that never went through Ablo. Append an `ablo_outbox` row
241
+ (with no `clientTxId`) for those in the same transaction as the change, and the
242
+ adapter's feed carries them to every connected screen.
220
243
 
221
- async events({ cursor, limit, context }) {
222
- const page = await context.auth.db.outbox.after(cursor, { limit });
223
-
224
- return {
225
- events: page.rows.map((row) => ({
226
- id: row.id,
227
- model: row.model,
228
- entityId: row.entityId,
229
- type: row.type,
230
- data: row.data,
231
- organizationId: row.organizationId,
232
- clientTxId: row.clientTxId,
233
- occurredAt: row.createdAt.getTime(),
234
- })),
235
- nextCursor: page.nextCursor,
236
- };
237
- },
238
- });
239
- ```
240
-
241
- Events without `clientTxId` are treated as external writes.
244
+ ## Production Checklist (signed endpoint)
242
245
 
243
- ## Production Checklist
244
-
245
- Before using a customer-owned database in production:
246
+ Before using the signed-endpoint shape in production:
246
247
 
247
248
  - Keep `DATABASE_URL` in the customer app or backend environment.
248
249
  - Use only the Data Source endpoint and `ABLO_API_KEY` as the customer-facing integration boundary.
249
- - Verify signatures before opening a database transaction.
250
- - Store `clientTxId` in an idempotency table before applying writes.
251
- - Return canonical rows after each commit.
252
- - Write outbox events in the same transaction as every app-row write, including
253
- Data Source `commit` writes.
254
- - Dedupe outbox events by event `id`.
250
+ - Run the adapter migrations so `ablo_outbox` and `ablo_idempotency` exist
251
+ alongside your synced tables (`ablo migrate`).
252
+ - Set `export const runtime = 'nodejs'` on the route so it can reach the database.
253
+ - For writes that bypass Ablo (cron, admin tools), append an `ablo_outbox` row
254
+ (no `clientTxId`) in the same transaction as the change.
255
255
  - Monitor last success, last error, retry count, event lag, and cursor.
256
256
 
257
- Don't give Ablo your database URL for this integration Ablo never connects to
258
- your database directly. (Direct database access would be a separate product with
259
- its own security model.)
257
+ The adapter already handles the restsignature verification, the commit
258
+ transaction, `clientTxId` idempotency, returning canonical rows, the outbox
259
+ append per operation, and deduping the feed by event `id`. You don't write any of
260
+ that by hand.
261
+
262
+ In this shape, leave `databaseUrl` out of `Ablo(...)` — the endpoint *is* the
263
+ connection, and registering both would point Ablo at your database twice.
260
264
 
261
265
  ## Security
262
266
 
@@ -7,7 +7,8 @@ Claims don't lock. If another writer holds the row, `claim` waits for them, re-r
7
7
  ```ts
8
8
  import Ablo from '@abloatai/ablo';
9
9
  import { defineSchema, model, z as schemaZ } from '@abloatai/ablo/schema';
10
- import { streamText, tool } from 'ai';
10
+ import { anthropic } from '@ai-sdk/anthropic';
11
+ import { convertToModelMessages, streamText, tool, type UIMessage } from 'ai';
11
12
  import { z } from 'zod';
12
13
 
13
14
  const schema = defineSchema({
@@ -62,17 +63,22 @@ const updateReport = tool({
62
63
  });
63
64
 
64
65
  export async function POST(req: Request) {
65
- const { messages, model } = await req.json();
66
+ // `useChat` posts UIMessage[]; the model is a server-bound provider instance,
67
+ // never read off the request body.
68
+ const { messages }: { messages: UIMessage[] } = await req.json();
66
69
 
67
70
  return streamText({
68
- model,
69
- messages,
71
+ model: anthropic('claude-sonnet-4-6'),
72
+ messages: await convertToModelMessages(messages),
70
73
  tools: { updateReport },
71
74
  }).toUIMessageStreamResponse();
72
75
  }
73
76
  ```
74
77
 
75
- The model provider is interchangeable. What matters is that the tool:
78
+ The model provider is interchangeable swap `anthropic(...)` for any
79
+ server-bound provider instance. What matters is that the route binds the model
80
+ on the server (never trusting one sent in the request body), converts the
81
+ incoming `UIMessage[]` with `convertToModelMessages`, and that the tool:
76
82
 
77
83
  - reads the latest weather report with `retrieve` (a server read),
78
84
  - claims the row — if someone else holds it, the claim waits for them, then re-reads,
@@ -46,7 +46,7 @@ export const schema = defineSchema({
46
46
  ```
47
47
 
48
48
  ```ts
49
- // web/ablo.ts
49
+ // web/ablo.ts — SERVER-ONLY client (holds the sk_ key; never imported in the browser).
50
50
  import Ablo from '@abloatai/ablo';
51
51
  import { schema } from './ablo/schema';
52
52
 
@@ -56,18 +56,40 @@ export const ablo = Ablo({
56
56
  });
57
57
  ```
58
58
 
59
- Mount the React provider near the app root so client components can subscribe to
60
- model clients without importing server credentials.
59
+ Mount the React provider near the app root. Build the browser client first
60
+ with an `authEndpoint` so it mints a short-lived session token instead of
61
+ carrying the secret key — then pass it to the provider via `client`.
61
62
 
62
63
  ```tsx
63
64
  // web/app/providers.tsx
64
65
  'use client';
65
66
 
67
+ import Ablo from '@abloatai/ablo';
66
68
  import { AbloProvider } from '@abloatai/ablo/react';
67
69
  import { schema } from '@/ablo/schema';
68
70
 
71
+ // Browser client: no secret key — `authEndpoint` mints the session token
72
+ // server-side (see the session route below).
73
+ const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session' });
74
+
69
75
  export function Providers({ children }: { children: React.ReactNode }) {
70
- return <AbloProvider schema={schema}>{children}</AbloProvider>;
76
+ return <AbloProvider client={ablo}>{children}</AbloProvider>;
77
+ }
78
+ ```
79
+
80
+ The session route mints with the server client that holds the `sk_` key — the
81
+ browser only ever sees the short-lived token:
82
+
83
+ ```ts
84
+ // web/app/api/ablo-session/route.ts
85
+ import { ablo } from '@/ablo';
86
+
87
+ export const runtime = 'nodejs';
88
+
89
+ export async function POST() {
90
+ const userId = await currentUserId(); // your auth
91
+ const { token } = await ablo.sessions.create({ user: { id: userId } });
92
+ return Response.json({ token });
71
93
  }
72
94
  ```
73
95
 
@@ -36,9 +36,10 @@ import { ablo } from '@/lib/ablo';
36
36
 
37
37
  export default async function ReportPage({
38
38
  params,
39
- }: { params: { id: string } }) {
39
+ }: { params: Promise<{ id: string }> }) {
40
+ const { id } = await params;
40
41
  await ablo.ready();
41
- const report = await ablo.weatherReports.retrieve({ id: params.id });
42
+ const report = await ablo.weatherReports.retrieve({ id });
42
43
  if (!report) return null;
43
44
 
44
45
  return <ReportEditor report={report} />;
@@ -46,26 +46,53 @@ export const schema = defineSchema(
46
46
  ## 2. Dispatch — narrow the agent to the deck it's working on
47
47
 
48
48
  An agent can never reach more than the user who triggered it — that's the upper
49
- limit. From there you narrow it to a single deck with `scope`. You pass the
50
- **model and id** `{ decks: deckId }`, never a `deck:<id>` string; the engine
51
- builds the group from the `decks` model's `scope`.
49
+ limit. From there you narrow it to a single deck by minting the agent's session
50
+ against **just that deck's sync group**. You build the group from the **model
51
+ kind and id** with the typed `syncGroup` helper — `syncGroup('deck', deckId)`,
52
+ never a hand-assembled `deck:<id>` string — where `'deck'` is the kind declared
53
+ by the `decks` model's `scope`.
54
+
55
+ Mint the scoped session on your backend (it holds the `sk_` key; the browser
56
+ never does), then hand the short-lived token to the browser client:
57
+
58
+ ```ts
59
+ // server — mints a scoped agent session for one deck
60
+ import Ablo from '@abloatai/ablo';
61
+ import { syncGroup } from '@abloatai/ablo/schema';
62
+ import { schema } from './schema';
63
+
64
+ const server = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
65
+
66
+ export async function mintDeckAgentSession(deckId: string, agentId: string) {
67
+ const { token } = await server.sessions.create({
68
+ agent: { id: agentId },
69
+ can: { slides: ['read', 'update'] }, // operation allowlist for this run
70
+ syncGroups: [syncGroup('deck', deckId)], // narrowed to just this deck
71
+ });
72
+ return token;
73
+ }
74
+ ```
52
75
 
53
76
  ```tsx
77
+ // client — the browser client carries only the scoped token.
78
+ import Ablo from '@abloatai/ablo';
54
79
  import { AbloProvider } from '@abloatai/ablo/react';
80
+ import { schema } from './schema';
81
+
82
+ const ablo = Ablo({
83
+ schema,
84
+ getToken: async () => mintDeckAgentSession(deckId, agentId),
85
+ });
55
86
 
56
87
  // The agent run is mounted on behalf of its triggering user.
57
- <AbloProvider
58
- schema={schema}
59
- userId={triggeringUser.id} // ceiling: can't exceed this user's reach
60
- scope={{ decks: deckId }} // floor: narrowed to just this deck → deck:<deckId>
61
- >
88
+ <AbloProvider client={ablo} userId={triggeringUser.id}>
62
89
  {children}
63
90
  </AbloProvider>
64
91
  ```
65
92
 
66
- `scope` requests, it never grants: at connect the server intersects the groups
67
- you ask for with the groups the identity is actually allowed, so the agent can
68
- never reach a deck its triggering user couldn't.
93
+ `syncGroups` requests, it never grants: at connect the server intersects the
94
+ groups the session asks for with the groups the identity is actually allowed,
95
+ so the agent can never reach a deck its triggering user couldn't.
69
96
 
70
97
  ## 3. Write — it fans out to everyone on that deck
71
98
 
@@ -109,7 +109,7 @@ authorized it, which run did it, and what state was it based on?"
109
109
 
110
110
  ## Persistence
111
111
 
112
- Ablo defaults to volatile in-memory persistence, so nothing is written to disk
112
+ Ablo defaults to in-memory persistence ('memory'), so nothing is written to disk
113
113
  unless you ask for it.
114
114
 
115
115
  Opt into a durable browser cache that survives reloads when you need it:
@@ -122,7 +122,7 @@ const ablo = Ablo({
122
122
  });
123
123
  ```
124
124
 
125
- Node, SSR, tests, and agents use volatile in-memory persistence automatically.
125
+ Node, SSR, tests, and agents use in-memory persistence ('memory') automatically.
126
126
 
127
127
  ## Storage Boundary
128
128