@abloatai/ablo 0.11.0 → 0.11.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 (75) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +72 -25
  3. package/dist/Model.d.ts +39 -0
  4. package/dist/Model.js +68 -0
  5. package/dist/auth/credentialPolicy.d.ts +145 -0
  6. package/dist/auth/credentialPolicy.js +130 -0
  7. package/dist/cli.cjs +154 -25
  8. package/dist/client/Ablo.d.ts +39 -88
  9. package/dist/client/Ablo.js +54 -99
  10. package/dist/client/ApiClient.d.ts +10 -1
  11. package/dist/client/ApiClient.js +23 -12
  12. package/dist/client/auth.d.ts +21 -9
  13. package/dist/client/auth.js +42 -6
  14. package/dist/client/createModelProxy.d.ts +74 -10
  15. package/dist/client/createModelProxy.js +85 -4
  16. package/dist/client/httpClient.d.ts +17 -3
  17. package/dist/client/httpClient.js +1 -0
  18. package/dist/client/identity.js +134 -122
  19. package/dist/client/index.d.ts +1 -1
  20. package/dist/client/sessionMint.d.ts +15 -0
  21. package/dist/client/sessionMint.js +86 -0
  22. package/dist/errorCodes.d.ts +2 -0
  23. package/dist/errorCodes.js +3 -1
  24. package/dist/errors.d.ts +3 -2
  25. package/dist/errors.js +3 -2
  26. package/dist/index.d.ts +4 -4
  27. package/dist/index.js +4 -7
  28. package/dist/mutators/RecordingTransaction.js +14 -42
  29. package/dist/react/AbloProvider.d.ts +1 -6
  30. package/dist/react/AbloProvider.js +1 -5
  31. package/dist/react/context.d.ts +1 -31
  32. package/dist/react/context.js +2 -2
  33. package/dist/react/index.d.ts +0 -6
  34. package/dist/react/index.js +0 -7
  35. package/dist/react/useSyncStatus.d.ts +1 -1
  36. package/dist/realtime/index.d.ts +1 -1
  37. package/dist/schema/generate.js +1 -2
  38. package/dist/schema/schema.d.ts +16 -5
  39. package/dist/schema/schema.js +26 -0
  40. package/dist/surface.d.ts +29 -0
  41. package/dist/surface.js +60 -0
  42. package/dist/sync/ConnectionManager.d.ts +16 -5
  43. package/dist/sync/ConnectionManager.js +42 -7
  44. package/dist/transactions/TransactionQueue.js +22 -10
  45. package/dist/types/global.d.ts +11 -3
  46. package/dist/types/global.js +8 -3
  47. package/dist/types/streams.d.ts +0 -22
  48. package/dist/utils/mobx-setup.js +1 -0
  49. package/docs/api-keys.md +49 -0
  50. package/docs/api.md +6 -5
  51. package/docs/client-behavior.md +7 -3
  52. package/docs/coordination.md +88 -24
  53. package/docs/data-sources.md +29 -9
  54. package/docs/examples/existing-python-backend.md +9 -5
  55. package/docs/examples/scoped-agent.md +1 -1
  56. package/docs/guarantees.md +4 -3
  57. package/docs/identity.md +89 -82
  58. package/docs/integration-guide.md +19 -10
  59. package/docs/migration.md +49 -2
  60. package/docs/quickstart.md +65 -33
  61. package/docs/react.md +49 -3
  62. package/docs/schema-contract.md +23 -5
  63. package/llms-full.txt +43 -24
  64. package/llms.txt +17 -15
  65. package/package.json +1 -1
  66. package/dist/api/index.d.ts +0 -10
  67. package/dist/api/index.js +0 -9
  68. package/dist/principal.d.ts +0 -44
  69. package/dist/principal.js +0 -49
  70. package/dist/react/SyncGroupProvider.d.ts +0 -19
  71. package/dist/react/SyncGroupProvider.js +0 -44
  72. package/dist/react/useClaim.d.ts +0 -29
  73. package/dist/react/useClaim.js +0 -42
  74. package/dist/react/usePresence.d.ts +0 -32
  75. package/dist/react/usePresence.js +0 -41
@@ -81,7 +81,7 @@ import { schema } from './schema';
81
81
 
82
82
  const ablo = Ablo({
83
83
  schema,
84
- getToken: async () => mintDeckAgentSession(deckId, agentId),
84
+ apiKey: async () => mintDeckAgentSession(deckId, agentId),
85
85
  });
86
86
 
87
87
  // The agent run is mounted on behalf of its triggering user.
@@ -82,9 +82,10 @@ Claims are live coordination signals. They are not database locks.
82
82
  already holds the row, the claim waits for them to finish, then re-reads the row
83
83
  before handing it back, so you proceed from fresh state. Reads stay open while a
84
84
  claim is held — `ablo.<model>.claim.state({ id })` returns the current claim state
85
- (or `null`) without ever blocking. A server read can pass `ifClaimed: 'wait'` to
86
- wait for the claim to clear, or `ifClaimed: 'fail'` to error out, when it should
87
- not return a row while someone else is mid-edit.
85
+ (or `null`) without ever blocking. A server read can pass `ifClaimed: 'fail'` to
86
+ error out, when it should not return a row while someone else is mid-edit. Reads
87
+ never block on a claim — to wait for a row to free up, `claim({ id })` it (the
88
+ claim queues fairly behind the holder).
88
89
 
89
90
  A claim does not reject or block other writers; it announces work so peers
90
91
  serialize behind it rather than racing. While you hold a claim, the matching
package/docs/identity.md CHANGED
@@ -36,8 +36,8 @@ runnable place, so the concepts below have code to attach to.
36
36
  ## Declare it, end to end
37
37
 
38
38
  The entire declaration surface is: `identityRoles` (who may see what), and on
39
- each model `scope` / `parent` / `grants` (which group a row fans out on), plus an
40
- optional `scope` setting on the client (narrowing). Read the three blocks first —
39
+ each model `scope` / `parent` / `grants` (which group a row fans out on), plus
40
+ optional `syncGroups` at session-mint time (narrowing). Read the three blocks first —
41
41
  a human gets their `org` / `team` scope, an agent gets one `deck` — then the
42
42
  sections after explain each.
43
43
 
@@ -84,18 +84,17 @@ export const schema = defineSchema(
84
84
 
85
85
  ```ts
86
86
  // 3. an AGENT run inherits its user, narrowed to the entities in play.
87
- // Pass the MODEL form — { decks: id } not a hand-built `deck:<id>` string;
88
- // the engine derives the group from each model's `scope`. The narrowing lives
89
- // on the client you build, then the provider mounts it.
90
- const ablo = Ablo({
91
- schema,
92
- authEndpoint: '/api/ablo-session',
93
- scope: { decks: deckId }, // floor: just the deck it's working on
87
+ // You narrow at SESSION-MINT time: your backend calls `sessions.create` with the
88
+ // agent's allowed `syncGroups`, built from each model's scope via the
89
+ // `syncGroup(kind, id)` helper never a hand-built `deck:<id>` string. The agent's
90
+ // runtime then connects with the minted token.
91
+ const session = await server.sessions.create({
92
+ agent: { id: agentId },
93
+ can: { Deck: ['read', 'update'] },
94
+ syncGroups: [syncGroup('deck', deckId)], // floor: just the deck it's working on
94
95
  });
95
-
96
- <AbloProvider client={ablo} userId={user.id /* ceiling: the triggering user */}>
97
- {children}
98
- </AbloProvider>;
96
+ // the agent runtime authenticates with the minted token
97
+ const ablo = Ablo({ schema, apiKey: session.token });
99
98
  ```
100
99
 
101
100
  That's the whole surface. The rest of this doc is the *why* behind each line.
@@ -127,11 +126,12 @@ so you pass them in code when you start the run.
127
126
  > **One line:** humans subscribe by who they are; agents subscribe by what
128
127
  > they've been given.
129
128
 
130
- That's why you never write per-user scope code, but you always pass an agent's
131
- scope at the call site. A user's org/team/user don't change per request, so
129
+ That's why you never write per-user scope code, but you always choose an agent's
130
+ groups at the dispatch site. A user's org/team/user don't change per request, so
132
131
  their scope is a **rule the schema derives automatically**. An agent's reach
133
132
  depends on *what it's working on*, which is only knowable at dispatch — so you
134
- set its `scope` on the client **at the dispatch site, in code**. The schema's
133
+ pass its `syncGroups` **when your backend mints the agent session**
134
+ (`sessions.create({ agent, can, syncGroups })`). The schema's
135
135
  only job for entities is to declare *that* a model is
136
136
  entity-scopable and *what its group is named* (`scope: 'deck'` → `deck:{id}`);
137
137
  it never declares *which* entities a given agent gets. (A human can opt into the
@@ -305,8 +305,9 @@ server, never by the browser.**
305
305
 
306
306
  The identity your server resolved is carried by the client you build and the
307
307
  `userId` prop. In a Next.js app, resolve the user in a Server Component and pass
308
- it down. Build the client once (the schema, `teamIds`, and any `scope` narrowing
309
- live here), then hand it to the provider:
308
+ it down. Build the client once (the schema, `teamIds`, and the `apiKey` resolver
309
+ live here; entity narrowing rides the minted session's `syncGroups`), then hand
310
+ it to the provider:
310
311
 
311
312
  ```ts
312
313
  // lib/ablo.ts
@@ -318,7 +319,10 @@ import { schema } from '@/ablo/schema';
318
319
  export function makeAblo(user: { teamIds: string[] }) {
319
320
  return Ablo({
320
321
  schema,
321
- authEndpoint: '/api/ablo-session',
322
+ // The browser holds no secret — the `apiKey` resolver fetches the
323
+ // short-lived session token your `/api/ablo-session` route minted, and the
324
+ // client keeps it fresh before expiry.
325
+ apiKey: () => fetch('/api/ablo-session').then((r) => r.text()),
322
326
  teamIds: user.teamIds,
323
327
  });
324
328
  }
@@ -354,7 +358,7 @@ What carries identity — and just as importantly, what does *not* set the bound
354
358
  | ------------ | ------------------------------------------------------------------------------------------------ |
355
359
  | `userId` prop | App-level participant id, used for app-owned fields and read by your `identityRole` `source`. **Not** the security boundary — the server enforces scope from the authenticated request. |
356
360
  | `teamIds` (on the client) | Team ids expanded into team sync groups via your `identityRoles`. |
357
- | `scope` (on the client) | Optional. **Narrows** the subscription to a subset of what auth already allows — it can never widen it. Use it to scope a page to one entity (e.g. `{ decks: 'abc123' }`). |
361
+ | `syncGroups` (at session mint) | Optional. **Narrows** a minted session's subscription to a subset of what auth already allows — it can never widen it. Passed to `sessions.create({ user \| agent, syncGroups })`; build entries with `syncGroup(kind, id)`. Use it to scope an agent (or a focused page's session) to one entity, e.g. `[syncGroup('deck', 'abc123')]`. |
358
362
 
359
363
  Because the server is the boundary, a client that changes `userId` to another
360
364
  user's id does not gain their data — the server resolves and enforces the real
@@ -398,20 +402,20 @@ subset of what its user could see:
398
402
 
399
403
  ```ts
400
404
  // agent run triggered by `user`, working on one document + one deck.
401
- // The narrowing lives on the client; the provider just mounts it.
402
- const ablo = Ablo({
403
- schema,
404
- authEndpoint: '/api/ablo-session',
405
- // authority narrowed to just the entities in play (the floor).
406
- // Model form — keyed by model, resolved to groups via each model's `scope`.
407
- scope: { documents: documentId, decks: deckId },
405
+ // Your backend mints the agent session narrowed to just the entities in play
406
+ // (the floor). Build each group from the model's scope with `syncGroup(kind, id)`.
407
+ const session = await server.sessions.create({
408
+ agent: { id: agentId },
409
+ can: { Document: ['read', 'update'], Deck: ['read', 'update'] },
410
+ syncGroups: [syncGroup('document', documentId), syncGroup('deck', deckId)],
408
411
  });
409
-
410
- // identity inherited from the triggering user (the ceiling)
411
- <AbloProvider client={ablo} userId={user.id}>
412
+ // identity (the ceiling) is inherited from the triggering user via your
413
+ // session-mint logic; the agent runtime connects with the minted token.
414
+ const ablo = Ablo({ schema, apiKey: session.token });
412
415
  ```
413
416
 
414
- As the run touches more entities its set **accretes** to cover them; it never
417
+ As the run touches more entities, claim or read them and the client auto-enrolls
418
+ in their entity groups — its set **accretes** to cover them; it never
415
419
  widens past the user's ceiling, and it carries no standing access to entities it
416
420
  isn't working on. The `identityRoles` need no agent-specific entry: the agent
417
421
  carries the triggering user's `userId`, so the same `user:{id}` role that scopes
@@ -444,52 +448,55 @@ runs the [Coordinating long agent work](../README.md#coordinating-long-agent-wor
444
448
  `claim` loop is, to the scoping layer, that same participant — scoped to the row
445
449
  it claimed.
446
450
 
447
- ## Narrowing to specific entities — the `scope` setting
448
-
449
- A human gets their full membership automatically (`identityRoles`). To narrow a
450
- session — a page on one deck, or an agent pointed at the entities it's working
451
- on set `scope` on the client you build (`Ablo({ schema, scope })`). You give it
452
- the **model and id(s)**; the engine builds the group string from the model's
453
- `scope` (Half 2), so you never hand-write `deck:<id>`.
454
-
455
- `scope` accepts four shapes, all resolved through the schema:
456
-
457
- | You pass | Resolves to | Use it for |
458
- | --- | --- | --- |
459
- | `{ decks: deckId }` | `deck:<deckId>` | one entity (a page, a focused agent) |
460
- | `{ decks: [id1, id2] }` | `deck:<id1>`, `deck:<id2>` | several of one model |
461
- | `{ decks: deckId, documents: docId }` | `deck:<deckId>`, `document:<docId>` | a mix across models |
462
- | `[{ type: 'Deck', id: deckId }]` | `deck:<deckId>` | entity refs (e.g. from a list) |
463
-
464
- ```tsx
465
- // a page on one deck
466
- const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session', scope: { decks: deckId } });
467
- <AbloProvider client={ablo} userId={user.id} />;
468
-
469
- // an agent working across two decks and a document
470
- const ablo = Ablo({
471
- schema,
472
- authEndpoint: '/api/ablo-session',
473
- scope: { decks: [deckA, deckB], documents: docId },
474
- });
475
- <AbloProvider client={ablo} userId={user.id} />;
476
- ```
477
-
478
- The key is the **model** (`decks`), the value is **which id(s)** the `deck:`
479
- prefix comes from that model's `scope: 'deck'`, never from a string you compose.
480
-
481
- > **`scope` means one thing: sync-group scope.** It appears in two places that
482
- > are the same concept — the model option `scope: 'deck'` (declares a scope root,
483
- > [Half 2](#half-2--per-model-scope-row--group)) and this `scope` client setting
484
- > (subscribe to it). The lifecycle filter on [`list()`](./api.md#model-methods) is a separate
485
- > axis and is named **`state`** (`'live' | 'archived' | 'all'`, GitHub's
486
- > open/closed/all), precisely so it doesn't share the word.
487
-
488
- > **`scope` requests, it never grants.** At connect, the server intersects your
489
- > requested groups with what the identity is actually allowed (`requested ∩
490
- > allowed`). So `scope` only ever *narrows* within a participant's ceiling — an
491
- > agent can't reach a deck its capability doesn't already permit, no matter what
492
- > it passes. Smaller bootstrap, less fan-out, same server-enforced boundary.
451
+ ## Narrowing to specific entities
452
+
453
+ A human gets their full membership automatically (`identityRoles`). There are
454
+ three ways to narrow a participant to specific entities — a page on one deck, or
455
+ an agent pointed at the entities it's working on. You **never hand-write**
456
+ `deck:<id>`; build groups from the model's `scope` (Half 2) with the typed
457
+ `syncGroup(kind, id)` helper from `@abloatai/ablo/schema`.
458
+
459
+ 1. **At session mint — `syncGroups`.** When your backend mints a session, pass the
460
+ exact groups it may subscribe to. This is the floor for a delegated agent (and
461
+ the way to scope a focused page's session):
462
+
463
+ ```ts
464
+ // an agent working across two decks and a document
465
+ const session = await server.sessions.create({
466
+ agent: { id: agentId },
467
+ can: { Deck: ['read', 'update'], Document: ['read'] },
468
+ syncGroups: [
469
+ syncGroup('deck', deckA),
470
+ syncGroup('deck', deckB),
471
+ syncGroup('document', docId),
472
+ ],
473
+ });
474
+ const ablo = Ablo({ schema, apiKey: session.token });
475
+ ```
476
+
477
+ 2. **Automatically, on read or claim.** Reading a row (`retrieve`/`get`/
478
+ `claim.state`) auto-enrolls the client in that row's entity group
479
+ (**read-interest**), and `claim`-ing it pins a **write-intent** subscription.
480
+ So an agent's reachable set **accretes** as it works — no extra subscribe call.
481
+
482
+ 3. **Explicitly, for presence `watch`.** To hold presence on a known set of rows
483
+ and react to peers, use the WebSocket-only `ablo.<model>.watch(ids, { ttl })`
484
+ (it returns a participant handle with `.peers`). See
485
+ [Coordination](./coordination.md).
486
+
487
+ > **`scope` is the schema model option, not a client setting.** `scope: 'deck'`
488
+ > in `model(...)` declares a scope root ([Half 2](#half-2--per-model-scope-row--group))
489
+ > it names the group (`deck:<id>`) that the mechanisms above then subscribe to.
490
+ > There is no `Ablo({ scope })` constructor option. The lifecycle filter on
491
+ > [`list()`](./api.md#model-methods) is a separate axis named **`state`**
492
+ > (`'live' | 'archived' | 'all'`, GitHub's open/closed/all), precisely so it
493
+ > doesn't share the word.
494
+
495
+ > **Requested groups never grant.** At connect, the server intersects the session's
496
+ > `syncGroups` with what the identity is actually allowed (`requested ∩ allowed`).
497
+ > So `syncGroups` only ever *narrows* within a participant's ceiling — an agent
498
+ > can't reach a deck its capability doesn't already permit, no matter what it
499
+ > passes. Smaller bootstrap, less fan-out, same server-enforced boundary.
493
500
 
494
501
  ## How this compares — and the best practices it follows
495
502
 
@@ -512,7 +519,7 @@ how to reason about it.
512
519
  the room/shape and the server signs off, as in
513
520
  [Pusher's channel authorization endpoint](https://pusher.com/docs/channels/server_api/authorizing-users/),
514
521
  [ElectricSQL **gatekeeper auth**](https://github.com/electric-sql/electric/blob/main/examples/gatekeeper-auth/README.md),
515
- and Liveblocks **access tokens**. Ablo's client `scope` setting is the
522
+ and Liveblocks **access tokens**. Ablo's session-mint `syncGroups` is the
516
523
  *narrowing* half of this — but it can only ever shrink the server-derived set,
517
524
  never grow it.
518
525
 
@@ -529,10 +536,10 @@ The best practices Ablo inherits from that lineage:
529
536
  2. **Trusted vs untrusted claims is the whole security argument.** PowerSync draws
530
537
  the line precisely: [token parameters are trusted and usable for access
531
538
  control; client parameters are not](https://docs.powersync.com/usage/sync-rules/advanced-topics/client-parameters).
532
- In Ablo terms, the identity your server vouches for is the *trusted* claim that
533
- sets scope; the `userId` prop and the client's `scope` setting are *untrusted
534
- client input* — convenient for app-owned fields and narrowing, but never the
535
- boundary. This is why changing `userId` in the browser grants nothing.
539
+ In Ablo terms, the identity your server vouches for and the session's
540
+ `syncGroups`, minted server-side are the *trusted* claims that set scope; the
541
+ `userId` prop is *untrusted client input* — convenient for app-owned fields, but
542
+ never the boundary. This is why changing `userId` in the browser grants nothing.
536
543
 
537
544
  3. **Scope by a hierarchical naming convention, declared once.** Ablo's `kind:id`
538
545
  group naming (`org:…` / `team:…` from `identityRoles`, `deck:…` from a model's
@@ -44,13 +44,18 @@ objects by hand.
44
44
 
45
45
  ## Your Database
46
46
 
47
- Every schema model is backed by **your own database**. You expose a signed Data
48
- Source endpoint; Ablo coordinates each write and your app commits it to your
49
- Postgres. The SDK call shape is the same everywhere.
47
+ Every schema model is backed by **your own database**. The SDK call shape is the
48
+ same everywhere.
50
49
 
51
- Do not pass a database URL to `Ablo(...)`. Application and agent code use
52
- `ABLO_API_KEY`. If your database stays canonical, expose a signed Data Source
53
- endpoint from your app and keep the database credentials inside your app.
50
+ In this guide an app that already owns its backend and database keep
51
+ `DATABASE_URL` inside your app and expose a signed Data Source endpoint: Ablo
52
+ coordinates each write and your app commits it to your Postgres. Do not pass
53
+ `databaseUrl` to `Ablo(...)` here; application and agent code use `ABLO_API_KEY`.
54
+
55
+ If instead you want Ablo to connect to your Postgres directly, pass `databaseUrl`
56
+ (a live, server-only option) to `Ablo(...)`. That and the sandbox-only `apiKey`
57
+ shape are the other two start states — [Connect Your Database](./data-sources.md)
58
+ is the single source of truth for all three.
54
59
 
55
60
  ## Test With Sandboxes
56
61
 
@@ -94,12 +99,13 @@ import { defineSchema, model, z } from '@abloatai/ablo/schema';
94
99
  export const schema = defineSchema(
95
100
  {
96
101
  weatherReports: model({
97
- id: z.string(),
102
+ // Reserved fields (id, createdAt, updatedAt, organizationId, createdBy)
103
+ // are SDK-provided automatically — never declare them. Declare only your
104
+ // own fields.
98
105
  projectId: z.string(),
99
106
  location: z.string(),
100
107
  status: z.enum(['pending', 'ready']),
101
108
  assigneeId: z.string().nullable(),
102
- updatedAt: z.string(),
103
109
  }),
104
110
  },
105
111
  {
@@ -169,7 +175,7 @@ export const ablo = Ablo({
169
175
  Browser apps should use the React provider or a scoped session token, not a
170
176
  server API key in the bundle. Build the client first, then hand it to the
171
177
  provider — `AbloProvider` takes `{ client, userId?, onError?, fallback? }`, and
172
- nothing else (`schema`, `teamIds`, `scope`, and `authEndpoint` all live on the
178
+ nothing else (`schema`, `teamIds`, and `apiKey` all live on the
173
179
  client now).
174
180
 
175
181
  ```tsx
@@ -179,7 +185,10 @@ import { schema } from '@/ablo/schema';
179
185
 
180
186
  // The browser never holds the API key. The client mints a short-lived token
181
187
  // from your session route (see below) and refreshes it before expiry.
182
- export const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session' });
188
+ export const ablo = Ablo({
189
+ schema,
190
+ apiKey: () => fetch('/api/ablo-session').then((r) => r.text()),
191
+ });
183
192
  ```
184
193
 
185
194
  ```tsx
package/docs/migration.md CHANGED
@@ -11,6 +11,7 @@ change when you upgrade.
11
11
 
12
12
  | Version | What changed | What to do |
13
13
  |---|---|---|
14
+ | **0.11.0** | `intent` → `claim` rename completed across the React hook, type namespace, and wire frames | `useIntent` → `useClaim`; `Register.Intents` → `Register.Claims`; `Ablo.Intent.*` → `Ablo.Claim.*`. Upgrade client **and** server together (wire frames moved `intent_*` → `claim_*`) |
14
15
  | **0.10.0** | Environment enum renamed `test`/`live` → `sandbox`/`production` | Update code that branches on the environment (e.g. source `mode`): `'test'`→`'sandbox'`, `'live'`→`'production'`. Key prefixes `sk_test_`/`sk_live_` are unchanged |
15
16
  | **0.9.2** | `turn` primitive + agent-work `tasks` resource removed | Coordinate with `claim`; mint a scoped session instead of `agent().run()` |
16
17
  | **0.9.2** | `intents` deprecated in favor of `claim` | Use `ablo.<model>.claim`; `ablo.intents` is now `@internal` |
@@ -24,6 +25,45 @@ change when you upgrade.
24
25
 
25
26
  ---
26
27
 
28
+ ## 0.11.0 — `intent` → `claim` rename completed
29
+
30
+ The coordination primitive has been `claim` since 0.9.2, but a few `intent`-named
31
+ surfaces lingered. 0.11.0 finishes the rename. There are three edits, all
32
+ mechanical:
33
+
34
+ **1. React hook.** `useIntent` is now `useClaim` (same signature):
35
+
36
+ ```diff
37
+ - import { useIntent } from '@abloatai/ablo/react';
38
+ - const claimEditLayer = useIntent('editLayer');
39
+ + import { useClaim } from '@abloatai/ablo/react';
40
+ + const claimEditLayer = useClaim('editLayer');
41
+ ```
42
+
43
+ **2. Type registration.** The `Register` interface key is `Claims`, not `Intents`:
44
+
45
+ ```diff
46
+ declare module '@abloatai/ablo' {
47
+ interface Register {
48
+ - Intents: { editLayer: { slideId: string; layerId: string } };
49
+ + Claims: { editLayer: { slideId: string; layerId: string } };
50
+ }
51
+ }
52
+ ```
53
+
54
+ **3. Type namespace.** The `Ablo.Intent.*` helper types moved to `Ablo.Claim.*`.
55
+ If you referenced them directly, rename the namespace; the shapes are unchanged.
56
+
57
+ > **Coordinated deploy required.** The on-the-wire frames moved from `intent_*`
58
+ > to `claim_*`. A `claim_*`-aware client cannot coordinate with an `intent_*`
59
+ > server (and vice-versa), so ship the client and server together. If you run a
60
+ > self-managed sync server, deploy it first.
61
+
62
+ Two non-breaking improvements ride along: claim-rejection errors now surface the
63
+ contending holders (`AbloClaimedError.claims` and a policy reason folded into the
64
+ message), and `participantKind` is the canonical `'user' | 'agent' | 'system'`
65
+ on presence and claim state.
66
+
27
67
  ## 0.10.0 — environment enum `sandbox` / `production`; stateless HTTP transport
28
68
 
29
69
  ### Environment enum rename (the only breaking change)
@@ -73,6 +113,11 @@ const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, transport: 'http'
73
113
  await ablo.tasks.update({ id, data: { status: 'done' } });
74
114
  ```
75
115
 
116
+ > **Minting still needs the stateful client.** `sessions.create(...)` is not on
117
+ > the `transport: 'http'` surface. Keep a default-transport `server` client for
118
+ > minting short-lived credentials (see the 0.9.2 example below), and use the
119
+ > http client for the per-request reads and writes.
120
+
76
121
  ---
77
122
 
78
123
  ## 0.9.2 — `turn` / agent-`tasks` removed; `intents` deprecated
@@ -95,8 +140,10 @@ helper, and the agent/task type family (`Agent`, `AgentOptions`,
95
140
  ```diff
96
141
  - const turn = await engine.beginTurn();
97
142
  - await Ablo({ apiKey }).agent(agentId, opts).run(prompt, handler);
98
- + // Mint a scoped credential, then claim + write under it.
99
- + const { token } = await ablo.sessions.create({ agent: { id: agentId } });
143
+ + // Mint a scoped credential from a stateful (default-transport) server client —
144
+ + // sessions.create lives on the stateful client, not on transport: 'http'.
145
+ + const server = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
146
+ + const { token } = await server.sessions.create({ agent: { id: agentId } });
100
147
  + const agent = Ablo({ schema, apiKey: token });
101
148
  + await using claim = await agent.tasks.claim({ id });
102
149
  + await agent.tasks.update({ id, data: { status: 'done' }, wait: 'confirmed' });
@@ -1,11 +1,16 @@
1
1
  # Quickstart
2
2
 
3
- Build with Ablo on **your own database**. You declare a small Ablo schema for the
4
- models humans and agents edit together, hand the client your Postgres
5
- `DATABASE_URL`, and coordinate every write through `ablo.<model>`. Your database
6
- is the system of record Ablo never hosts your data. It is the transaction
7
- layer on top: it registers your connection, commits every write there behind
8
- row-level security, and fans the confirmed rows out to every connected client.
3
+ Build with Ablo on **the Postgres you already have**. You declare a small Ablo
4
+ schema for the models humans and agents edit together, hand the client your
5
+ Postgres `DATABASE_URL` (passed explicitly), and coordinate every write through
6
+ `ablo.<model>`. In production, your database is the system of record. Ablo is the
7
+ transaction layer on top: it registers your connection, commits every write there
8
+ behind row-level security, and fans the confirmed rows out to every connected
9
+ client.
10
+
11
+ > No database yet? The hosted **sandbox** can host rows in Ablo's test plane —
12
+ > pass an `apiKey` only and omit `databaseUrl`, like Stripe test mode — so you can
13
+ > try Ablo before pointing it at your Postgres.
9
14
 
10
15
  ## 1. Install and initialize
11
16
 
@@ -25,10 +30,12 @@ instead:
25
30
  export ABLO_API_KEY=sk_test_...
26
31
  ```
27
32
 
28
- Every SDK and CLI call needs a key. Test and live keys work like Stripe's
29
- except both point at databases *you* own: `sk_test_*` for your dev database,
30
- `sk_live_*` for production. There is no keyless mode; the public `/sandbox` page
31
- is a hosted demo, not your app.
33
+ Every SDK and CLI call needs a key. Test and live keys work like Stripe's:
34
+ `sk_test_*` for the sandbox, `sk_live_*` for production. In production a key
35
+ points at the database *you* own; in the sandbox you can skip the database
36
+ entirely and let Ablo's test plane host the rows (apiKey only). There is no
37
+ keyless mode — a key is always required. (The public `/sandbox` page is a
38
+ separate hosted demo, not your app.)
32
39
 
33
40
  ## 2. Your Ablo schema (init scaffolded it)
34
41
 
@@ -50,19 +57,28 @@ export const schema = defineSchema({
50
57
  });
51
58
  ```
52
59
 
60
+ **Reserved fields** — `id`, `createdAt`, `updatedAt`, `organizationId`, and
61
+ `createdBy` are provided by the SDK automatically. Don't declare them in your
62
+ `model(...)` fields; declare only your own.
53
63
 
54
- Register the schema once (init scaffolds this `ablo.d.ts`), and every type
55
- is one parameter away — no `typeof schema` re-stating, anywhere:
64
+ The schema is registered once (init scaffolds `ablo/register.ts` for you), and
65
+ every type is one parameter away — no `typeof schema` re-stating, anywhere:
56
66
 
57
67
  ```ts
58
- // ablo.d.ts — once per project
59
- import type { schema } from './ablo/schema';
68
+ // ablo/register.ts — scaffolded by `npx ablo init`, sits beside ablo/schema.ts
69
+ import type { schema } from './schema';
60
70
  declare module '@abloatai/ablo' {
61
71
  interface Register { Schema: typeof schema }
62
72
  }
63
73
  export {};
64
74
  ```
65
75
 
76
+ It's a regular `.ts` module, not a hand-authored `.d.ts`. The top-level
77
+ `import type { schema }` makes the `declare module` block *merge* into (augment)
78
+ the SDK's `Register` interface instead of colliding with it — the same shape
79
+ [TanStack Router uses in `src/router.tsx`](https://tanstack.com/router/latest/docs/framework/react/guide/type-safety). Any `.ts` file in your
80
+ `tsconfig` `include` works; it never needs to be imported.
81
+
66
82
  ```ts
67
83
  import type { Model } from '@abloatai/ablo/schema';
68
84
 
@@ -73,6 +89,12 @@ type WeatherReport = Model<'weatherReports'>; // fully typed from YOUR schema
73
89
  TanStack-Router pattern: declare the source of truth once, everything
74
90
  infers from it.)
75
91
 
92
+ When you need to name the client type — to pass it to a function or store it in
93
+ a context — **infer it from the value**: `type Sync = typeof sync`. That's the
94
+ same idiom as tRPC's `typeof appRouter` and Drizzle's `typeof db`; it resolves
95
+ the typed overload at the call site. Avoid `ReturnType<typeof Ablo>`, which
96
+ collapses to the untyped client.
97
+
76
98
  ## 3. Point Ablo at your database
77
99
 
78
100
  The client takes your schema, your key, and your `DATABASE_URL`. On first
@@ -93,10 +115,14 @@ import { schema } from './schema';
93
115
  export const ablo = Ablo({
94
116
  schema,
95
117
  apiKey: process.env.ABLO_API_KEY,
96
- databaseUrl: process.env.DATABASE_URL, // your Postgres — rows live here, never with Ablo
118
+ databaseUrl: process.env.DATABASE_URL, // your Postgres, passed explicitly — rows live here
97
119
  });
98
120
  ```
99
121
 
122
+ `databaseUrl` is not auto-read from the environment — you pass it explicitly
123
+ (as above). If a `DATABASE_URL` is set for another tool, `Ablo()` ignores it
124
+ unless you wire it in like this.
125
+
100
126
  Use a dedicated **non-superuser role** for the connection — Ablo enforces
101
127
  tenant isolation with row-level security, so the server rejects superuser or
102
128
  `BYPASSRLS` roles outright (`database_role_cannot_enforce_rls`).
@@ -127,29 +153,34 @@ built from an ORM adapter instead — same product, same writes, see
127
153
  [Connect Your Database](./data-sources.md). In that setup, omit `databaseUrl`
128
154
  from `Ablo(...)`.
129
155
 
130
- ## 4. Push Ablo provisions your tables for you
156
+ ## 4. Push the schema, then map it to tables
131
157
 
132
158
  ```bash
133
159
  npx ablo push # checks your DATABASE_URL role, pushes the schema (sandbox),
134
- # provisions your synced-model tables (with row-level
135
- # security) IN YOUR database, and writes ABLO_API_KEY to
136
- # .env.local. Add --watch to re-push on every save.
160
+ # and writes ABLO_API_KEY to .env.local. Add --watch to
161
+ # re-push on every save.
137
162
  ```
138
163
 
139
- Nothing runs locally there is no dev server to start. Your app talks to
140
- Ablo's hosted API with the sandbox key; the rows land in your database.
164
+ `ablo push` uploads the schema *definition* model names, fields, types. That
165
+ metadata is what tells Ablo which models to coordinate. Skipping it makes every
166
+ write to a new or changed model fail with `server_execute_unknown_model` — that
167
+ error literally means "run `npx ablo push`."
168
+
169
+ Now Ablo needs real Postgres tables behind those models. Two ways, depending on
170
+ who owns the tables:
141
171
 
142
- There is no separate migration step: the push provisions your synced-model
143
- tables in the registered database server-side your other tables are left
144
- untouched. (`npx ablo migrate` still exists for the signed Data Source
145
- endpoint mode, where Ablo never touches your database and DDL must run from
146
- your side.)
172
+ - **Adopt existing tables (the common case).** Most teams already have the
173
+ tables created by Prisma, Drizzle, or hand-written migrations. Run
174
+ `npx ablo pull` to import their shape into your schema, or `npx ablo check`
175
+ to verify your schema and the live tables agree. Keep managing the tables
176
+ with your own migration tool; Ablo just syncs the subset of models you
177
+ declared.
178
+ - **Let Ablo provision them.** If Ablo should own the tables, `npx ablo migrate`
179
+ creates your synced-model tables (with row-level security) in the registered
180
+ database. Your other tables are left untouched.
147
181
 
148
- `ablo push` uploads the schema *definition*
149
- model names, fields, types. That metadata is the only thing Ablo keeps; the
150
- rows stay in your database. Skipping the push makes every write to a new or
151
- changed model fail with `server_execute_unknown_model` — that error literally
152
- means "run `npx ablo push`."
182
+ Nothing runs locally there is no dev server to start. Your app talks to Ablo's
183
+ hosted API with the sandbox key; the rows land in your database.
153
184
 
154
185
  ## 5. Write through the model
155
186
 
@@ -182,8 +213,9 @@ and you write the usual way with `ablo.<model>.update({ id, data })`.
182
213
  Claims don't lock. If another writer holds the row, `claim` waits for them,
183
214
  re-reads the fresh row, then hands it to you — so two writers serialize instead
184
215
  of clobbering. Normal reads still work while the claim is held. If a server read
185
- should not return a row while someone else is mid-edit, pass `ifClaimed: 'wait'`
186
- to wait for the claim to clear, or `ifClaimed: 'fail'` to error out instead.
216
+ should not return a row while someone else is mid-edit, pass `ifClaimed: 'fail'`
217
+ to error out instead. Reads never block on a claim to wait for a row to free
218
+ up, `claim({ id })` it (the claim queues fairly behind the holder).
187
219
  Call `handle.release()` when your work is done.
188
220
 
189
221
  ```ts