@abloatai/ablo 0.11.1 → 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 (74) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +10 -2
  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 +39 -6
  8. package/dist/client/Ablo.d.ts +39 -88
  9. package/dist/client/Ablo.js +38 -98
  10. package/dist/client/ApiClient.d.ts +10 -1
  11. package/dist/client/ApiClient.js +19 -11
  12. package/dist/client/auth.d.ts +12 -5
  13. package/dist/client/auth.js +2 -1
  14. package/dist/client/createModelProxy.d.ts +49 -10
  15. package/dist/client/createModelProxy.js +6 -0
  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 +2 -0
  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 +13 -2
  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.d.ts +0 -11
  45. package/dist/transactions/TransactionQueue.js +12 -56
  46. package/dist/types/global.d.ts +3 -0
  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 +3 -2
  51. package/docs/client-behavior.md +1 -0
  52. package/docs/coordination.md +75 -21
  53. package/docs/examples/existing-python-backend.md +9 -5
  54. package/docs/examples/scoped-agent.md +1 -1
  55. package/docs/guarantees.md +4 -3
  56. package/docs/identity.md +89 -82
  57. package/docs/integration-guide.md +19 -10
  58. package/docs/migration.md +9 -2
  59. package/docs/quickstart.md +6 -2
  60. package/docs/react.md +3 -3
  61. package/docs/schema-contract.md +23 -5
  62. package/llms-full.txt +18 -16
  63. package/llms.txt +6 -6
  64. package/package.json +1 -1
  65. package/dist/api/index.d.ts +0 -10
  66. package/dist/api/index.js +0 -9
  67. package/dist/principal.d.ts +0 -44
  68. package/dist/principal.js +0 -49
  69. package/dist/react/SyncGroupProvider.d.ts +0 -19
  70. package/dist/react/SyncGroupProvider.js +0 -44
  71. package/dist/react/useClaim.d.ts +0 -29
  72. package/dist/react/useClaim.js +0 -42
  73. package/dist/react/usePresence.d.ts +0 -32
  74. package/dist/react/usePresence.js +0 -41
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
@@ -113,6 +113,11 @@ const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, transport: 'http'
113
113
  await ablo.tasks.update({ id, data: { status: 'done' } });
114
114
  ```
115
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
+
116
121
  ---
117
122
 
118
123
  ## 0.9.2 — `turn` / agent-`tasks` removed; `intents` deprecated
@@ -135,8 +140,10 @@ helper, and the agent/task type family (`Agent`, `AgentOptions`,
135
140
  ```diff
136
141
  - const turn = await engine.beginTurn();
137
142
  - await Ablo({ apiKey }).agent(agentId, opts).run(prompt, handler);
138
- + // Mint a scoped credential, then claim + write under it.
139
- + 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 } });
140
147
  + const agent = Ablo({ schema, apiKey: token });
141
148
  + await using claim = await agent.tasks.claim({ id });
142
149
  + await agent.tasks.update({ id, data: { status: 'done' }, wait: 'confirmed' });
@@ -57,6 +57,9 @@ export const schema = defineSchema({
57
57
  });
58
58
  ```
59
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.
60
63
 
61
64
  The schema is registered once (init scaffolds `ablo/register.ts` for you), and
62
65
  every type is one parameter away — no `typeof schema` re-stating, anywhere:
@@ -210,8 +213,9 @@ and you write the usual way with `ablo.<model>.update({ id, data })`.
210
213
  Claims don't lock. If another writer holds the row, `claim` waits for them,
211
214
  re-reads the fresh row, then hands it to you — so two writers serialize instead
212
215
  of clobbering. Normal reads still work while the claim is held. If a server read
213
- should not return a row while someone else is mid-edit, pass `ifClaimed: 'wait'`
214
- 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).
215
219
  Call `handle.release()` when your work is done.
216
220
 
217
221
  ```ts
package/docs/react.md CHANGED
@@ -31,7 +31,7 @@ import { schema } from '@/ablo/schema';
31
31
  // from your own server route (see Identity below).
32
32
  export const ablo = Ablo({
33
33
  schema,
34
- authEndpoint: '/api/ablo-session',
34
+ apiKey: () => fetch('/api/ablo-session').then((r) => r.text()),
35
35
  });
36
36
  ```
37
37
 
@@ -65,13 +65,13 @@ export function Providers({
65
65
 
66
66
  | Prop | Default | Purpose |
67
67
  | ----------- | ---------------- | --------------------------------------------------------------------------------------------------------- |
68
- | `client` | — | **Required.** The `Ablo({ schema, authEndpoint })` instance. It carries the schema and connection config. |
68
+ | `client` | — | **Required.** The `Ablo({ schema, apiKey })` instance. It carries the schema and connection config. |
69
69
  | `userId` | resolved from auth | App participant id for app-owned fields and your `identityRoles`. Not the security boundary. |
70
70
  | `fallback` | neutral spinner | Rendered during the *first* bootstrap only. Pass a branded skeleton, `null`, or `'passthrough'`. |
71
71
  | `onError` | — | Engine / WebSocket / bootstrap errors. Wire to Sentry / Datadog. |
72
72
 
73
73
  Everything that used to be a provider prop — `schema`, `url`, `apiKey`,
74
- `teamIds`, `syncGroups`/`scope`, `persistence`, `bootstrapMode` — now lives on
74
+ `teamIds`, `syncGroups`, `persistence`, `bootstrapMode` — now lives on
75
75
  the `Ablo({ ... })` client you build before mounting the provider. Where the
76
76
  identity comes from, and why the API key never reaches the browser, is the whole
77
77
  of [Identity & Sync Groups](./identity.md) — read that if it isn't obvious how
@@ -49,6 +49,20 @@ The model key (`weatherReports`) becomes the client namespace
49
49
  contract. You should not create a parallel string-keyed write path for the same
50
50
  data.
51
51
 
52
+ ### Reserved fields
53
+
54
+ The SDK provides these on every row automatically — do **not** declare them in
55
+ your `model(...)` fields:
56
+
57
+ - `id`
58
+ - `createdAt`
59
+ - `updatedAt`
60
+ - `organizationId`
61
+ - `createdBy`
62
+
63
+ Declare only your own fields; the reserved ones are still present on the row and
64
+ readable, you just don't author them.
65
+
52
66
  ## Reads and writes
53
67
 
54
68
  Use async reads when the row may not be local:
@@ -88,12 +102,16 @@ the fresh row. Reads stay open; only acting on the row serializes.
88
102
 
89
103
  ## Storage boundary
90
104
 
91
- Every schema model is backed by your own database through a Data Source — Ablo
92
- coordinates each write and your app commits it to your Postgres.
105
+ Every schema model is backed by your own database. There are three start states,
106
+ all covered in [Connect Your Database](./data-sources.md) (the single source of
107
+ truth): the sandbox (`apiKey` only, no database), a direct connection string
108
+ (`databaseUrl` passed to `Ablo(...)`, a live, server-only option), or a signed
109
+ Data Source endpoint where your app keeps the database credential and commits
110
+ each write itself.
93
111
 
94
- Do not pass a database URL to `Ablo(...)`. Trusted runtimes use `ABLO_API_KEY`.
95
- Browser code goes through `<AbloProvider>` or a scoped session route, never a raw
96
- API key.
112
+ If your database stays canonical behind a Data Source endpoint, do not pass
113
+ `databaseUrl` to `Ablo(...)` trusted runtimes use `ABLO_API_KEY`. Browser code
114
+ goes through `<AbloProvider>` or a scoped session route, never a raw API key.
97
115
 
98
116
  ## Rules of thumb
99
117
 
package/llms-full.txt CHANGED
@@ -39,9 +39,9 @@ Use `snapshot(...)` and `readAt` when an agent write depends on state it already
39
39
  read. `onStale: 'reject'` prevents lost updates by rejecting if the target
40
40
  changed after the snapshot.
41
41
 
42
- Claims are live coordination signals, not database locks. Schema clients wait
43
- from the realtime claim stream. Schema-less HTTP clients must pass an explicit
44
- `claimedPollInterval` for `ifClaimed: 'wait'`; no hidden hard-coded polling.
42
+ Claims are live coordination signals, not database locks. Reads never block on a
43
+ claim to wait for a row to free up, `claim({ id })` it and the claim queues
44
+ fairly behind the current holder.
45
45
 
46
46
  Agent run bookkeeping is internal. Most users should not create run ledger
47
47
  records or scoped access credentials manually.
@@ -307,7 +307,10 @@ common agent path.
307
307
 
308
308
  ## Model
309
309
 
310
- Every model read returns state plus coordination metadata:
310
+ The read shape depends on whether the client has a local graph:
311
+
312
+ - The stateful `ablo.<model>.retrieve({ id })` returns the BARE row (`T | undefined`), and `list({ where })` returns `T[]`. Read coordination metadata (state watermark, active claims) separately via `ablo.snapshot(...)` and `ablo.<model>.claim.state({ id })`.
313
+ - The stateless HTTP / `.model(name)` `retrieve({ id })` returns a `ModelRead<T>` envelope because it has no local graph to carry the watermark; `list({ where })` still returns `T[]`.
311
314
 
312
315
  ```ts
313
316
  type ModelRead<T> = {
@@ -317,32 +320,31 @@ type ModelRead<T> = {
317
320
  };
318
321
  ```
319
322
 
320
- `stamp` is the state watermark. Pass it to writes as `readAt`.
323
+ `stamp` is the state watermark. Pass it to writes as `readAt` (from `ablo.snapshot(...)` on the stateful client, or from the envelope on the HTTP client).
321
324
 
322
325
  `claims` lists active work on the target. Reads are allowed while another participant is working. The caller decides whether to return, fail, or wait.
323
326
 
324
327
  ## Claimed Behavior
325
328
 
326
- Claimed behavior is explicit:
329
+ Claimed behavior is explicit, and there are only two policies:
327
330
 
328
- - `ifClaimed: 'return'` returns the current claims with the read.
331
+ - `ifClaimed: 'return'` (the default) returns the row plus the current claims with the read.
329
332
  - `ifClaimed: 'fail'` throws `AbloClaimedError`.
330
- - `ifClaimed: 'wait'` waits for matching claims to clear.
331
-
332
- Schema clients use the realtime claim stream for waits.
333
333
 
334
- Schema-less HTTP clients cannot know when a claim clears unless the caller opts into polling. When using `ifClaimed: 'wait'` over HTTP, provide `claimedPollInterval` and usually `claimedTimeout`.
334
+ Reads never block on a claim. To wait for a row to free up, `claim({ id })` it
335
+ the claim queues fairly behind the current holder and is granted when the row
336
+ frees. Use `ifClaimed: 'fail'` when you'd rather refuse to read a claimed row.
335
337
 
336
338
  ```ts
339
+ // Read a claimed row without blocking, surfacing who holds it:
337
340
  await api.model('reports').retrieve({
338
341
  id: 'report_stockholm',
339
- ifClaimed: 'wait',
340
- claimedPollInterval: 1_000,
341
- claimedTimeout: 30_000,
342
+ ifClaimed: 'return',
342
343
  });
343
- ```
344
344
 
345
- No hidden hard-coded claimed polling. `claimedTimeout` is a maximum wait, not the coordination mechanism.
345
+ // Or wait for it to free up by queueing your own claim:
346
+ await api.model('reports').claim({ id: 'report_stockholm' });
347
+ ```
346
348
 
347
349
  ## Write
348
350
 
package/llms.txt CHANGED
@@ -99,13 +99,13 @@ coordination until the app reports it through Data Source events.
99
99
  ## Claimed Behavior
100
100
 
101
101
  Reads never silently block. Schema reads stay open while a row is claimed.
102
- Server reads through `ablo.model(name)` can pass `ifClaimed: 'return'` to
103
- receive active claims, `ifClaimed: 'fail'` to throw `AbloClaimedError`, or
104
- `ifClaimed: 'wait'` to wait until the active claim clears.
102
+ Server reads through `ablo.model(name)` can pass `ifClaimed: 'return'` (the
103
+ default — returns the row plus active claim metadata) or `ifClaimed: 'fail'`
104
+ to throw `AbloClaimedError`.
105
105
 
106
- Schema clients learn when a claim clears by listening to the live claim stream, so they don't need to poll. Schema-less HTTP callers must provide an explicit `claimedPollInterval` when using `ifClaimed: 'wait'`; Ablo does not hide a hard-coded polling loop.
107
-
108
- Use `claimedTimeout` only as a maximum wait, not as the coordination mechanism.
106
+ Reads never block on a claim. To wait for a row to free up, `claim({ id })` it
107
+ the claim queues fairly behind the current holder and is granted when the row
108
+ frees. Use `ifClaimed: 'fail'` when you'd rather refuse to read a claimed row.
109
109
 
110
110
  ## Guarantees
111
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
4
4
  "description": "The Collaboration Layer For AI Agents",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,10 +0,0 @@
1
- /**
2
- * Internal compatibility entrypoint for the stateless hosted protocol client.
3
- *
4
- * Use this build for serverless functions, scripts, and backends that want
5
- * model reads/writes and commits over HTTP without the realtime sync runtime.
6
- */
7
- export { createProtocolClient, createProtocolClient as Ablo, type AbloApi, type AbloApiClientOptions, type AbloApiClaims, type Capability, type CapabilityCreateOptions, type CapabilityParticipantKind, type CapabilityRecord, type CapabilityResource, type CapabilityRevocation, type CapabilityScope, } from '../client/ApiClient.js';
8
- export type { CommitCreateOptions, CommitOperationInput, CommitReceipt, CommitWait, ClaimCreateOptions, ClaimHandle, ClaimWaitOptions, ClaimedOptions, IfClaimedPolicy, ModelClient, ModelClaim, ModelMutationOptions, ModelReadOptions, ModelRead, ModelTarget, } from '../client/Ablo.js';
9
- import { createProtocolClient } from '../client/ApiClient.js';
10
- export default createProtocolClient;
package/dist/api/index.js DELETED
@@ -1,9 +0,0 @@
1
- /**
2
- * Internal compatibility entrypoint for the stateless hosted protocol client.
3
- *
4
- * Use this build for serverless functions, scripts, and backends that want
5
- * model reads/writes and commits over HTTP without the realtime sync runtime.
6
- */
7
- export { createProtocolClient, createProtocolClient as Ablo, } from '../client/ApiClient.js';
8
- import { createProtocolClient } from '../client/ApiClient.js';
9
- export default createProtocolClient;
@@ -1,44 +0,0 @@
1
- /**
2
- * Principal constructors — a thin typed façade over the raw
3
- * `SessionRef` / `AgentRef` shapes so call sites don't have to memorize
4
- * the discriminated-union tags.
5
- *
6
- * ```ts
7
- * import Ablo, { session } from '@abloatai/ablo';
8
- *
9
- * const ablo = Ablo({ schema, apiKey });
10
- * const participant = await ablo.participants.join({
11
- * type: 'Matter',
12
- * id: 'deal-1',
13
- * });
14
- * ```
15
- *
16
- * Browser-human flows use `session(...)`. Agent-spawn-agent flows use
17
- * `agent(...)`, but those rarely appear in customer code because the
18
- * participant layer handles attenuation.
19
- *
20
- * These are pure — no I/O, no hidden state. If the shape ever grows a
21
- * required field (say, a scope hint for the restricted key), the helper
22
- * is the one place to flag migrations.
23
- */
24
- import type { AgentRef, SessionRef } from './types/streams.js';
25
- /**
26
- * Build a `SessionRef` from the identifiers your auth system already
27
- * holds. Typical inputs: the Better Auth session id, the user id, and
28
- * the organization the session is scoped to.
29
- */
30
- export declare function session(params: {
31
- id: string;
32
- userId: string;
33
- organizationId: string;
34
- }): SessionRef;
35
- /**
36
- * Build an `AgentRef` from an agent id + the capability token that
37
- * authenticates it. Rare in application code — the common path is
38
- * `participant.join(child)` where the parent's token is attenuated
39
- * automatically.
40
- */
41
- export declare function agent(params: {
42
- id: string;
43
- capabilityToken: string;
44
- }): AgentRef;