@abloatai/ablo 0.9.0 → 0.9.2

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 (63) hide show
  1. package/AGENTS.md +84 -0
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +15 -7
  4. package/dist/BaseSyncedStore.d.ts +10 -0
  5. package/dist/BaseSyncedStore.js +26 -0
  6. package/dist/SyncClient.d.ts +12 -0
  7. package/dist/SyncClient.js +15 -0
  8. package/dist/agent/index.js +1 -1
  9. package/dist/api/index.d.ts +1 -1
  10. package/dist/client/Ablo.d.ts +9 -51
  11. package/dist/client/Ablo.js +2 -104
  12. package/dist/client/ApiClient.d.ts +3 -115
  13. package/dist/client/ApiClient.js +0 -232
  14. package/dist/client/auth.js +32 -2
  15. package/dist/client/httpClient.d.ts +5 -6
  16. package/dist/client/httpClient.js +2 -3
  17. package/dist/client/index.d.ts +1 -1
  18. package/dist/errorCodes.js +3 -3
  19. package/dist/index.js +1 -1
  20. package/dist/interfaces/index.d.ts +4 -4
  21. package/dist/mutators/UndoManager.d.ts +100 -11
  22. package/dist/mutators/UndoManager.js +282 -13
  23. package/dist/react/AbloProvider.d.ts +18 -8
  24. package/dist/react/context.d.ts +31 -0
  25. package/dist/react/index.d.ts +1 -1
  26. package/dist/react/index.js +1 -1
  27. package/dist/react/useUndoScope.js +7 -0
  28. package/dist/schema/ddl.d.ts +8 -0
  29. package/dist/schema/ddl.js +10 -0
  30. package/dist/schema/index.d.ts +1 -1
  31. package/dist/schema/index.js +1 -1
  32. package/dist/server/commit.d.ts +4 -5
  33. package/dist/source/adapter.d.ts +18 -12
  34. package/dist/source/adapter.js +8 -7
  35. package/dist/source/adapters/drizzle.d.ts +15 -6
  36. package/dist/source/adapters/drizzle.js +87 -49
  37. package/dist/source/adapters/memory.d.ts +1 -1
  38. package/dist/source/adapters/memory.js +2 -2
  39. package/dist/source/adapters/prisma.d.ts +3 -3
  40. package/dist/source/adapters/prisma.js +6 -29
  41. package/dist/source/conformance.d.ts +1 -1
  42. package/dist/source/conformance.js +2 -2
  43. package/dist/source/contract.d.ts +3 -2
  44. package/dist/source/contract.js +3 -2
  45. package/dist/source/index.d.ts +1 -0
  46. package/dist/source/index.js +3 -2
  47. package/dist/source/migrations.d.ts +14 -0
  48. package/dist/source/migrations.js +39 -0
  49. package/dist/types/streams.d.ts +2 -1
  50. package/dist/wire/frames.d.ts +6 -8
  51. package/docs/api.md +1 -1
  52. package/docs/cli.md +18 -5
  53. package/docs/data-sources.md +68 -83
  54. package/docs/examples/ai-sdk-tool.md +11 -5
  55. package/docs/examples/existing-python-backend.md +26 -4
  56. package/docs/examples/nextjs.md +3 -2
  57. package/docs/examples/scoped-agent.md +38 -11
  58. package/docs/identity.md +86 -59
  59. package/docs/index.md +1 -1
  60. package/docs/integration-guide.md +85 -54
  61. package/docs/react.md +39 -28
  62. package/llms.txt +18 -11
  63. package/package.json +2 -2
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The table-creation SQL every ORM adapter ships for its OWN infrastructure tables —
3
+ * `ablo_idempotency` (dedupe by clientTxId) and `ablo_outbox` (transactional
4
+ * outbox the `events()` feed reads). Defined ONCE here so the Prisma adapter, the
5
+ * Drizzle adapter, and `ablo migrate` can never disagree on the shape (they used
6
+ * to inline their own copies, which had already drifted in whitespace).
7
+ *
8
+ * These are NOT model tables and are NOT emitted by the hosted provisioner
9
+ * (`generateProvisionPlan`) — the hosted path uses `sync_deltas` directly. They
10
+ * exist only on a customer's own database in Data Source mode.
11
+ */
12
+ /** Canonical adapter-owned table-creation SQL. Idempotent (`IF NOT EXISTS`). */
13
+ export function adapterTableMigrations() {
14
+ return [
15
+ {
16
+ name: 'ablo_idempotency',
17
+ up: `CREATE TABLE IF NOT EXISTS ablo_idempotency (
18
+ client_tx_id TEXT PRIMARY KEY,
19
+ response JSONB NOT NULL,
20
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
21
+ );`,
22
+ },
23
+ {
24
+ name: 'ablo_outbox',
25
+ up: `CREATE TABLE IF NOT EXISTS ablo_outbox (
26
+ cursor BIGSERIAL PRIMARY KEY,
27
+ id TEXT NOT NULL UNIQUE,
28
+ model TEXT NOT NULL,
29
+ entity_id TEXT NOT NULL,
30
+ type TEXT NOT NULL,
31
+ data JSONB,
32
+ organization_id TEXT,
33
+ client_tx_id TEXT,
34
+ occurred_at BIGINT,
35
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
36
+ );`,
37
+ },
38
+ ];
39
+ }
@@ -52,7 +52,8 @@ export interface AgentDelta {
52
52
  capabilityId?: string | null;
53
53
  /** Whether the human explicitly approved the change. */
54
54
  confirmationState?: ConfirmationState | null;
55
- /** Turn handle that caused this commit. */
55
+ /** Agent-task id that caused this commit, if any. Dormant on new client
56
+ * writes (turns/tasks removed); may hold historical values. */
56
57
  causedByTaskId?: string | null;
57
58
  createdAt: string;
58
59
  }
@@ -76,14 +76,12 @@ export interface CommitMessage {
76
76
  operations: CommitOperation[];
77
77
  clientTxId: string;
78
78
  /**
79
- * Optional turn handle. When the SDK opens a turn via
80
- * `SyncAgent.beginTurn(...)`, subsequent commits within the handle's
81
- * scope auto-attach the `turnId` here. The Hub validates the turn
82
- * belongs to the same agent and is open, then threads it onto every
83
- * delta's `caused_by_task_id` column. Absent for human-direct commits
84
- * and for SDKs that predate the turn protocol those produce deltas
85
- * with `caused_by_task_id = NULL`, which the audit pane treats as "no
86
- * prompt-side context recorded."
79
+ * Dormant agent-task lineage field. The SDK no longer populates it —
80
+ * turns/tasks were removed and write attribution now rides on the
81
+ * claim (`intent`) id plus the server-stamped actor/capability. Kept
82
+ * optional for wire-compat; when present the Hub still validates and
83
+ * threads it onto `caused_by_task_id`, but client writes leave it
84
+ * `null` (the audit pane treats null as "no prompt-side context").
87
85
  */
88
86
  causedByTaskId?: string | null;
89
87
  };
package/docs/api.md CHANGED
@@ -8,7 +8,7 @@ row, you can optionally `claim` it so they serialize instead of clobbering
8
8
  each other.
9
9
 
10
10
  Two things to know before the method list. **Reads come in two flavors:**
11
- `retrieve(id)` / `list({ where })` are async and hit the server (use them when
11
+ `retrieve({ id })` / `list({ where })` are async and hit the server (use them when
12
12
  the row may not be local yet); `get(id)` / `getAll({ where })` / `getCount({ where })`
13
13
  are synchronous reads off the local graph (use them in render, after data has
14
14
  synced). **Claims don't lock.** If another writer holds the row, `claim` waits
package/docs/cli.md CHANGED
@@ -57,9 +57,9 @@ either mode) defines the same models test and live see; only the rows differ.
57
57
  | `ablo dev` | **Hosted** — push the schema to your test sandbox, then watch `ablo/schema.ts` and re-push on save. | `--no-watch`, `--schema <path>`, `--export <name>`, `--url <url>` |
58
58
  | `ablo logs` | Tail your scope's commit activity (`stripe logs tail`). Follows by default. | `-n, --tail <N>`, `--since <dur\|ts>`, `--model`, `--op`, `--json`, `--no-follow`, `--mode test\|live` |
59
59
  | `ablo push` | **Hosted** — upload the schema to Ablo; the server diffs, migrates, and activates it. | `--force`, `--rename old:new`, `--backfill model.field=value`, `--schema`, `--export`, `--url` |
60
- | `ablo migrate` | **Direct Postgres** — apply the schema to your own `DATABASE_URL` (you run the DDL). | `--dry-run`, `--output <file>`, `--schema`, `--export` |
60
+ | `ablo migrate` | **Direct Postgres** — provision just the synced models (plus the adapter's `ablo_outbox` / `ablo_idempotency`) in your own `DATABASE_URL`. Leaves your other tables alone. | `--dry-run`, `--output <file>`, `--schema`, `--export` |
61
61
  | `ablo pull` | **Direct Postgres** — generate `defineSchema(...)` from your existing tables (read-only, like `prisma db pull`). | `--out <path>`, `--app-schema <name>`, `--import <pkg>`, `--force` |
62
- | `ablo check` | **Direct Postgres** — verify your _existing_ tables fit the schema (read-only, no DDL). | `--schema <path>`, `--export <name>`, `--app-schema <name>` |
62
+ | `ablo check` | **Direct Postgres** — verify your _existing_ tables fit the schema (read-only, no schema changes). | `--schema <path>`, `--export <name>`, `--app-schema <name>` |
63
63
  | `ablo generate` | Emit TypeScript types from the schema. | `--out <path>`, `--schema`, `--export` |
64
64
 
65
65
  ## `ablo dev`
@@ -144,9 +144,9 @@ reshaping it. `ablo check` is read-only; it never proposes a migration.
144
144
  ## `migrate` (Direct Postgres) vs `push` (Hosted)
145
145
 
146
146
  Same engine, two setups. If you use the **Direct Postgres connector**, use
147
- `ablo migrate` — it applies the schema to your own `DATABASE_URL`, and you run
148
- the DDL. If Ablo manages the sandbox/hosted store, use `ablo push` and
149
- `ablo dev` — the server applies the change and version-gates connecting clients.
147
+ `ablo migrate` — it provisions the synced models in your own `DATABASE_URL`. If
148
+ Ablo manages the sandbox/hosted store, use `ablo push` and `ablo dev` — the
149
+ server applies the change and version-gates connecting clients.
150
150
 
151
151
  ```bash
152
152
  ablo migrate --dry-run # preview the exact SQL
@@ -154,6 +154,19 @@ ablo migrate # apply to DATABASE_URL
154
154
  ablo migrate --output schema.sql # write SQL to a file
155
155
  ```
156
156
 
157
+ ### One database, two schemas
158
+
159
+ `ablo migrate` does **not** own your whole database. It creates exactly the
160
+ models in your `defineSchema(...)` — the synced, collaborative tables — plus the
161
+ adapter's bookkeeping tables (`ablo_outbox`, `ablo_idempotency`). Nothing else.
162
+
163
+ Your auth, billing, and any other non-synced tables stay in **your own ORM
164
+ schema** (Drizzle's `schema.ts`, Prisma's `schema.prisma`) and are provisioned by
165
+ **your own migrations** (`drizzle-kit push` / `prisma migrate`). The Ablo schema
166
+ is not a replacement for `schema.prisma`, and `ablo migrate` won't touch, drop,
167
+ or adopt the tables it doesn't manage. One database, two schemas, side by side —
168
+ each owned by its own migration tool.
169
+
157
170
  ## Zod → Postgres type mapping
158
171
 
159
172
  The one type map, shared by both paths (there is no second mapping):
@@ -10,10 +10,16 @@ That default makes Ablo the managed state store for your models, the same way
10
10
  Stripe stores `Customer` and `PaymentIntent` objects that you create through
11
11
  Stripe's API.
12
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.
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 (plus the small `ablo_outbox` / `ablo_idempotency` bookkeeping
20
+ tables its adapter needs); you keep owning everything else. `ablo check` reflects
21
+ this — it reports your other tables as "ignored / owned by you," which is exactly
22
+ right.
17
23
 
18
24
  Your app can keep using its own `DATABASE_URL`. Store that value in your app or
19
25
  backend environment, not in Ablo. The integration boundary is the HTTPS
@@ -66,8 +72,8 @@ Multiplayer behavior is the same in both modes. Writes made through
66
72
  fan out to subscribers. If something writes to your database without going
67
73
  through Ablo (a cron job, an admin tool), Ablo can't know about it
68
74
  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).
75
+ outside changes back through the outbox feed — shown below in
76
+ [Outbox Events](#outbox-events).
71
77
 
72
78
  ## When To Use A Data Source
73
79
 
@@ -100,59 +106,53 @@ The shape is the same as a production webhook integration:
100
106
 
101
107
  ## Route
102
108
 
109
+ You don't hand-write the commit transaction, the idempotency upsert, or the
110
+ outbox writes. You pass an ORM **adapter** and it does all of that for you —
111
+ transaction, exactly-once idempotency, and outbox — driven by the same Ablo
112
+ schema. The whole route is three fields:
113
+
103
114
  ```ts
104
115
  // app/api/ablo/source/route.ts
105
- import { dataSource, sourceEventForOperation } from '@abloatai/ablo';
116
+ import { dataSourceNext } from '@abloatai/ablo/source/next';
117
+ import { prismaDataSource } from '@abloatai/ablo/source';
106
118
  import { schema } from '@/ablo/schema';
107
- import { db } from '@/db';
119
+ import { prisma } from '@/lib/prisma';
108
120
 
109
- export const POST = dataSource({
121
+ // Data Source routes touch the database, so they run on the Node runtime.
122
+ export const runtime = 'nodejs';
123
+
124
+ export const { POST } = dataSourceNext({
110
125
  schema,
111
- apiKey: process.env.ABLO_API_KEY,
126
+ apiKey: process.env.ABLO_API_KEY!,
127
+ adapter: prismaDataSource(prisma, schema),
128
+ });
129
+ ```
112
130
 
113
- authorize() {
114
- return { db };
115
- },
131
+ Using Drizzle instead of Prisma is the same shape — swap the adapter for
132
+ `drizzleDataSource(db, schema)`:
116
133
 
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
- },
134
+ ```ts
135
+ // app/api/ablo/source/route.ts
136
+ import { dataSourceNext } from '@abloatai/ablo/source/next';
137
+ import { drizzleDataSource } from '@abloatai/ablo/source/drizzle';
138
+ import { schema } from '@/ablo/schema';
139
+ import { db } from '@/db';
141
140
 
142
- reports: {
143
- async load({ id, context }) {
144
- return context.auth.db.report.findUnique({ where: { id } });
145
- },
141
+ export const runtime = 'nodejs';
146
142
 
147
- async list({ query, context }) {
148
- return context.auth.db.report.findMany({
149
- take: query.limit ?? 100,
150
- });
151
- },
152
- },
143
+ export const { POST } = dataSourceNext({
144
+ schema,
145
+ apiKey: process.env.ABLO_API_KEY!,
146
+ adapter: drizzleDataSource(db, schema),
153
147
  });
154
148
  ```
155
149
 
150
+ The adapter is constructed from your ORM client and the Ablo `schema` —
151
+ `prismaDataSource(prisma, schema)` or `drizzleDataSource(db, schema)`. It maps
152
+ each synced model to your table, wraps every commit in one transaction, dedupes
153
+ on `clientTxId` via `ablo_idempotency`, and appends `ablo_outbox` rows for the
154
+ external-write feed — the bookkeeping you used to write by hand.
155
+
156
156
  Your app code still writes through the normal model API:
157
157
 
158
158
  ```ts
@@ -208,37 +208,18 @@ events.
208
208
 
209
209
  ## Outbox Events
210
210
 
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.
211
+ The adapter serves the outbox feed for you. Every `commit` it runs appends one
212
+ `ablo_outbox` row per operation in the same transaction, and the adapter's
213
+ built-in events handler streams those rows back to Ablo by cursor — so connected
214
+ humans and agents stay current with no extra code. If Ablo already appended the
215
+ commit directly, `clientTxId` lets Ablo filter the echo; if the direct append
216
+ failed, the same outbox row repairs it on the next poll or push.
215
217
 
216
- ```ts
217
- export const POST = dataSource({
218
- schema,
219
- apiKey: process.env.ABLO_API_KEY,
220
-
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.
218
+ Events without `clientTxId` are treated as external writes. The only thing you
219
+ add by hand is recording *outside* writes — changes made to your tables by a
220
+ cron job or admin tool that never went through Ablo. Append an `ablo_outbox` row
221
+ (with no `clientTxId`) for those in the same transaction as the change, and the
222
+ adapter's feed carries them to every connected screen.
242
223
 
243
224
  ## Production Checklist
244
225
 
@@ -246,14 +227,18 @@ Before using a customer-owned database in production:
246
227
 
247
228
  - Keep `DATABASE_URL` in the customer app or backend environment.
248
229
  - 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`.
230
+ - Run the adapter migrations so `ablo_outbox` and `ablo_idempotency` exist
231
+ alongside your synced tables (`ablo migrate`).
232
+ - Set `export const runtime = 'nodejs'` on the route so it can reach the database.
233
+ - For writes that bypass Ablo (cron, admin tools), append an `ablo_outbox` row
234
+ (no `clientTxId`) in the same transaction as the change.
255
235
  - Monitor last success, last error, retry count, event lag, and cursor.
256
236
 
237
+ The adapter already handles the rest — signature verification, the commit
238
+ transaction, `clientTxId` idempotency, returning canonical rows, the outbox
239
+ append per operation, and deduping the feed by event `id`. You don't write any of
240
+ that by hand.
241
+
257
242
  Don't give Ablo your database URL for this integration — Ablo never connects to
258
243
  your database directly. (Direct database access would be a separate product with
259
244
  its own security model.)
@@ -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