@abloatai/ablo 0.7.0 → 0.9.0

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 (181) hide show
  1. package/CHANGELOG.md +72 -1
  2. package/README.md +80 -66
  3. package/dist/BaseSyncedStore.d.ts +73 -0
  4. package/dist/BaseSyncedStore.js +179 -5
  5. package/dist/Model.d.ts +42 -0
  6. package/dist/Model.js +103 -44
  7. package/dist/SyncEngineContext.d.ts +2 -1
  8. package/dist/SyncEngineContext.js +5 -3
  9. package/dist/agent/session.js +6 -5
  10. package/dist/ai-sdk/coordination-context.js +4 -0
  11. package/dist/ai-sdk/index.d.ts +56 -47
  12. package/dist/ai-sdk/index.js +56 -47
  13. package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
  14. package/dist/ai-sdk/intent-broadcast.js +11 -4
  15. package/dist/ai-sdk/wrap.d.ts +14 -11
  16. package/dist/ai-sdk/wrap.js +11 -13
  17. package/dist/auth/credentialSource.d.ts +34 -0
  18. package/dist/auth/credentialSource.js +63 -0
  19. package/dist/auth/index.d.ts +2 -22
  20. package/dist/auth/index.js +26 -36
  21. package/dist/auth/schemas.d.ts +35 -0
  22. package/dist/auth/schemas.js +53 -0
  23. package/dist/client/Ablo.d.ts +259 -33
  24. package/dist/client/Ablo.js +276 -73
  25. package/dist/client/ApiClient.d.ts +52 -4
  26. package/dist/client/ApiClient.js +236 -66
  27. package/dist/client/auth.d.ts +21 -2
  28. package/dist/client/auth.js +77 -5
  29. package/dist/client/createInternalComponents.d.ts +2 -0
  30. package/dist/client/createInternalComponents.js +8 -1
  31. package/dist/client/createModelProxy.d.ts +187 -79
  32. package/dist/client/createModelProxy.js +203 -68
  33. package/dist/client/httpClient.d.ts +71 -0
  34. package/dist/client/httpClient.js +69 -0
  35. package/dist/client/identity.d.ts +2 -6
  36. package/dist/client/identity.js +63 -11
  37. package/dist/client/index.d.ts +1 -0
  38. package/dist/client/index.js +1 -0
  39. package/dist/client/registerDataSource.d.ts +19 -0
  40. package/dist/client/registerDataSource.js +59 -0
  41. package/dist/client/validateAbloOptions.d.ts +2 -1
  42. package/dist/client/validateAbloOptions.js +8 -7
  43. package/dist/core/DatabaseManager.js +30 -2
  44. package/dist/core/openIDBWithTimeout.d.ts +36 -0
  45. package/dist/core/openIDBWithTimeout.js +88 -1
  46. package/dist/errorCodes.d.ts +92 -1
  47. package/dist/errorCodes.js +139 -7
  48. package/dist/errors.d.ts +54 -3
  49. package/dist/errors.js +192 -44
  50. package/dist/index.d.ts +23 -10
  51. package/dist/index.js +21 -8
  52. package/dist/keys/index.d.ts +76 -0
  53. package/dist/keys/index.js +171 -0
  54. package/dist/mutators/UndoManager.d.ts +86 -50
  55. package/dist/mutators/UndoManager.js +129 -22
  56. package/dist/mutators/inverseOp.d.ts +129 -0
  57. package/dist/mutators/inverseOp.js +74 -0
  58. package/dist/mutators/readerActions.d.ts +1 -1
  59. package/dist/mutators/undoApply.d.ts +42 -0
  60. package/dist/mutators/undoApply.js +143 -0
  61. package/dist/query/client.d.ts +10 -9
  62. package/dist/query/client.js +22 -14
  63. package/dist/react/AbloProvider.d.ts +23 -101
  64. package/dist/react/AbloProvider.js +61 -103
  65. package/dist/react/ClientSideSuspense.d.ts +1 -1
  66. package/dist/react/DefaultFallback.d.ts +1 -1
  67. package/dist/react/SyncGroupProvider.d.ts +1 -1
  68. package/dist/react/index.d.ts +3 -2
  69. package/dist/react/index.js +3 -2
  70. package/dist/react/useAblo.d.ts +4 -4
  71. package/dist/react/useAblo.js +10 -5
  72. package/dist/react/useCurrentUserId.d.ts +1 -1
  73. package/dist/react/useCurrentUserId.js +1 -1
  74. package/dist/react/useMutators.js +19 -12
  75. package/dist/react/useReactive.js +16 -3
  76. package/dist/schema/ddl.d.ts +26 -3
  77. package/dist/schema/ddl.js +152 -4
  78. package/dist/schema/index.d.ts +4 -0
  79. package/dist/schema/index.js +12 -0
  80. package/dist/schema/model.d.ts +11 -0
  81. package/dist/schema/model.js +2 -0
  82. package/dist/schema/openapi.d.ts +28 -0
  83. package/dist/schema/openapi.js +118 -0
  84. package/dist/schema/plane.d.ts +23 -0
  85. package/dist/schema/plane.js +19 -0
  86. package/dist/schema/relation.d.ts +20 -0
  87. package/dist/schema/serialize.d.ts +7 -3
  88. package/dist/schema/serialize.js +6 -2
  89. package/dist/schema/sync-delta-row.d.ts +157 -0
  90. package/dist/schema/sync-delta-row.js +102 -0
  91. package/dist/schema/sync-delta-wire.d.ts +180 -0
  92. package/dist/schema/sync-delta-wire.js +102 -0
  93. package/dist/server/adapter.d.ts +156 -0
  94. package/dist/server/adapter.js +19 -0
  95. package/dist/server/commit.d.ts +82 -0
  96. package/dist/server/commit.js +1 -0
  97. package/dist/server/index.d.ts +14 -0
  98. package/dist/server/index.js +1 -0
  99. package/dist/server/next.d.ts +51 -0
  100. package/dist/server/next.js +47 -0
  101. package/dist/server/read-config.d.ts +60 -0
  102. package/dist/server/read-config.js +8 -0
  103. package/dist/server/storage-mode.d.ts +17 -0
  104. package/dist/server/storage-mode.js +12 -0
  105. package/dist/source/adapter.d.ts +59 -0
  106. package/dist/source/adapter.js +19 -0
  107. package/dist/source/adapters/drizzle.d.ts +34 -0
  108. package/dist/source/adapters/drizzle.js +147 -0
  109. package/dist/source/adapters/memory.d.ts +12 -0
  110. package/dist/source/adapters/memory.js +114 -0
  111. package/dist/source/adapters/prisma.d.ts +57 -0
  112. package/dist/source/adapters/prisma.js +199 -0
  113. package/dist/source/conformance.d.ts +32 -0
  114. package/dist/source/conformance.js +134 -0
  115. package/dist/source/contract.d.ts +143 -0
  116. package/dist/source/contract.js +98 -0
  117. package/dist/source/index.d.ts +61 -10
  118. package/dist/source/index.js +98 -0
  119. package/dist/source/next.d.ts +33 -0
  120. package/dist/source/next.js +26 -0
  121. package/dist/sync/BootstrapHelper.d.ts +10 -0
  122. package/dist/sync/BootstrapHelper.js +56 -42
  123. package/dist/sync/ConnectionManager.d.ts +57 -1
  124. package/dist/sync/ConnectionManager.js +186 -11
  125. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  126. package/dist/sync/HydrationCoordinator.js +241 -41
  127. package/dist/sync/NetworkProbe.d.ts +60 -18
  128. package/dist/sync/NetworkProbe.js +121 -23
  129. package/dist/sync/SyncWebSocket.d.ts +45 -70
  130. package/dist/sync/SyncWebSocket.js +113 -89
  131. package/dist/sync/createIntentStream.js +10 -1
  132. package/dist/sync/participants.js +5 -2
  133. package/dist/transactions/TransactionQueue.js +13 -1
  134. package/dist/types/streams.d.ts +9 -0
  135. package/dist/utils/mobx-setup.js +1 -0
  136. package/dist/webhooks/events.d.ts +38 -0
  137. package/dist/webhooks/events.js +40 -0
  138. package/dist/webhooks/index.d.ts +10 -0
  139. package/dist/webhooks/index.js +10 -0
  140. package/dist/wire/errorEnvelope.d.ts +34 -0
  141. package/dist/wire/errorEnvelope.js +86 -0
  142. package/dist/wire/frames.d.ts +119 -0
  143. package/dist/wire/frames.js +1 -0
  144. package/dist/wire/index.d.ts +24 -0
  145. package/dist/wire/index.js +21 -0
  146. package/dist/wire/listEnvelope.d.ts +45 -0
  147. package/dist/wire/listEnvelope.js +17 -0
  148. package/docs/api-keys.md +5 -5
  149. package/docs/api.md +125 -65
  150. package/docs/audit.md +16 -9
  151. package/docs/cli.md +57 -47
  152. package/docs/client-behavior.md +54 -40
  153. package/docs/coordination.md +66 -80
  154. package/docs/data-sources.md +56 -34
  155. package/docs/examples/agent-human.md +74 -28
  156. package/docs/examples/ai-sdk-tool.md +29 -22
  157. package/docs/examples/existing-python-backend.md +41 -26
  158. package/docs/examples/nextjs.md +32 -17
  159. package/docs/examples/scoped-agent.md +43 -28
  160. package/docs/examples/server-agent.md +40 -15
  161. package/docs/guarantees.md +38 -27
  162. package/docs/identity.md +65 -59
  163. package/docs/index.md +30 -19
  164. package/docs/integration-guide.md +78 -78
  165. package/docs/interaction-model.md +43 -35
  166. package/docs/mcp/claude-code.md +11 -19
  167. package/docs/mcp/cursor.md +7 -25
  168. package/docs/mcp/windsurf.md +7 -20
  169. package/docs/mcp.md +103 -26
  170. package/docs/quickstart.md +63 -61
  171. package/docs/react.md +24 -16
  172. package/docs/roadmap.md +13 -13
  173. package/docs/schema-contract.md +111 -0
  174. package/docs/the-loop.md +21 -0
  175. package/examples/README.md +8 -4
  176. package/examples/data-source/README.md +10 -7
  177. package/examples/data-source/customer-server.ts +27 -25
  178. package/examples/data-source/run.ts +4 -3
  179. package/examples/quickstart.ts +1 -1
  180. package/llms.txt +55 -21
  181. package/package.json +48 -3
package/docs/identity.md CHANGED
@@ -30,65 +30,16 @@ A **sync group** is a named channel of shared state — a string like
30
30
  There is no built-in `org` / `team` / `user` concept in the engine. Those are
31
31
  *your* domain words. Ablo only knows sync-group strings. The mapping from "this
32
32
  is user U in org O" to "they may subscribe to `org:acme` and `user:U`" is
33
- something **you declare in your schema** covered next.
34
-
35
- ### Two kinds of group — the whole mental model
36
-
37
- Every sync group is named after one of two things, and that's the cleanest way
38
- to hold the model in your head:
39
-
40
- - **Membership groups** — named after *who you are*: `org:{id}`, `team:{id}`,
41
- `user:{id}`. Produced from **identity** (`identityRoles`, Half 1). They're
42
- standing and durable — they don't change as you work.
43
- - **Entity groups** — named after *a thing*: `dataroom:{id}`, `deck:{id}`,
44
- `slide:{id}`. Produced from a **row's id** (a model's entity scope, Half 2).
45
- They're granular — one per record — and any participant can be pointed at a
46
- specific set of them.
47
-
48
- Humans and agents fill that same space differently — and, crucially, the two
49
- are **declared in different places**, because membership is static (a property of
50
- identity) while entity scope is dynamic (a property of the task):
51
-
52
- | | Subscribed by | Declared where | Gets |
53
- | --- | --- | --- | --- |
54
- | **Human** | *who they are* — membership | **the schema** (`identityRoles`) — a rule, written once | every `org` / `team` / `user` group their identity implies — their whole standing world |
55
- | **Agent** | *what it's been given* — entities | **code, at the spawn site** — chosen per run | a handful of entity groups: the dataroom it's in, the slides it has read — never beyond what its user's membership could reach |
56
-
57
- > **One line:** humans subscribe by who they are; agents subscribe by what
58
- > they've been given.
59
-
60
- The asymmetry is the point. A user's org/team/user don't change per request, so
61
- their scope is a **rule the schema derives automatically** — you never write
62
- per-user scope code. An agent's reach depends on *what it's working on*, which is
63
- only knowable at dispatch — so you pass its `syncGroups` **at the call site, in
64
- code**. The schema's only job for entities is to declare *that* a model is
65
- entity-scopable and *what its group is named* (`scope: 'deck'` → `deck:{id}`);
66
- it never declares *which* entities a given agent gets. (A human can opt into the
67
- same runtime narrowing — a page scoped to one deck — but by default a human's
68
- scope is fully schema-derived.)
69
-
70
- So an agent doesn't need a `user:{id}` standing grant. It's a participant pointed
71
- at a few entity groups, bounded above by its triggering user's membership. That
72
- boundary is the whole safety story, and it's covered in
73
- [Agents are participants too](#agents-are-participants-too).
74
-
75
- ```txt
76
- your auth → identity { kind, userId|agentId, organizationId, teamIds }
77
- → identityRoles (schema) → allowed sync groups
78
- → participant receives deltas for rows in those groups
79
- ```
80
-
81
- The identity is a **participant** — and a participant is either a human
82
- (`kind: 'user'`) or an agent (`kind: 'agent'`). Same shape, same path; see
83
- [Agents are participants too](#agents-are-participants-too) below. Everything in
84
- the next two sections applies to both.
33
+ something **you declare in your schema**. Here is that whole declaration in one
34
+ runnable place, so the concepts below have code to attach to.
85
35
 
86
36
  ## Declare it, end to end
87
37
 
88
38
  The entire declaration surface is: `identityRoles` (who may see what), and on
89
39
  each model `scope` / `parent` / `grants` (which group a row fans out on), plus an
90
- optional `syncGroups` prop (narrowing). Here they are in one runnable place — the
91
- sections after this explain each.
40
+ optional `syncGroups` prop (narrowing). Read the three blocks first a human
41
+ gets their `org` / `team` scope, an agent gets one `deck` — then the sections
42
+ after explain each.
92
43
 
93
44
  ```ts
94
45
  // 1. src/ablo/schema.ts — map identity → groups, and anchor each model to a group
@@ -144,6 +95,60 @@ export const schema = defineSchema(
144
95
 
145
96
  That's the whole surface. The rest of this doc is the *why* behind each line.
146
97
 
98
+ ## Two kinds of group — the whole mental model
99
+
100
+ You just saw a human get `org` / `team` groups and an agent get one `deck`
101
+ group. That split is the model. Every sync group is named after one of two
102
+ things:
103
+
104
+ - **Membership groups** — named after *who you are*: `org:{id}`, `team:{id}`,
105
+ `user:{id}`. Produced from **identity** (`identityRoles`, Half 1). They're
106
+ standing and durable — they don't change as you work.
107
+ - **Entity groups** — named after *a thing*: `dataroom:{id}`, `deck:{id}`,
108
+ `slide:{id}`. Produced from a **row's id** (a model's entity scope, Half 2).
109
+ They're granular — one per record — and any participant can be pointed at a
110
+ specific set of them.
111
+
112
+ Humans and agents fill that same space differently, and you declare the two in
113
+ different places. A human's groups come from who they are, so you declare them
114
+ once in the schema. An agent's groups come from what it's working on right now,
115
+ so you pass them in code when you start the run.
116
+
117
+ | | Subscribed by | Declared where | Gets |
118
+ | --- | --- | --- | --- |
119
+ | **Human** | *who they are* — membership | **the schema** (`identityRoles`) — a rule, written once | every `org` / `team` / `user` group their identity implies — their whole standing world |
120
+ | **Agent** | *what it's been given* — entities | **code, at the spawn site** — chosen per run | a handful of entity groups: the dataroom it's in, the slides it has read — never beyond what its user's membership could reach |
121
+
122
+ > **One line:** humans subscribe by who they are; agents subscribe by what
123
+ > they've been given.
124
+
125
+ That's why you never write per-user scope code, but you always pass an agent's
126
+ scope at the call site. A user's org/team/user don't change per request, so
127
+ their scope is a **rule the schema derives automatically**. An agent's reach
128
+ depends on *what it's working on*, which is only knowable at dispatch — so you
129
+ pass its `syncGroups` **at the call site, in code**. The schema's only job for
130
+ entities is to declare *that* a model is
131
+ entity-scopable and *what its group is named* (`scope: 'deck'` → `deck:{id}`);
132
+ it never declares *which* entities a given agent gets. (A human can opt into the
133
+ same runtime narrowing — a page scoped to one deck — but by default a human's
134
+ scope is fully schema-derived.)
135
+
136
+ So an agent doesn't need a `user:{id}` standing grant. It's a participant pointed
137
+ at a few entity groups, bounded above by its triggering user's membership. That
138
+ boundary is the whole safety story, and it's covered in
139
+ [Agents are participants too](#agents-are-participants-too).
140
+
141
+ ```txt
142
+ your auth → identity { kind, userId|agentId, organizationId, teamIds }
143
+ → identityRoles (schema) → allowed sync groups
144
+ → participant receives deltas for rows in those groups
145
+ ```
146
+
147
+ The identity is a **participant** — and a participant is either a human
148
+ (`kind: 'user'`) or an agent (`kind: 'agent'`). Same shape, same path; see
149
+ [Agents are participants too](#agents-are-participants-too) below. Everything in
150
+ the next two sections applies to both.
151
+
147
152
  ## The two halves of scoping
148
153
 
149
154
  Scoping is two declarations that meet in the middle. One describes the
@@ -355,9 +360,10 @@ agent authority = (triggering user's allowed set) ← ceiling, inherited (on-
355
360
  ∩ (the model instances it touches) ← floor, least privilege per run
356
361
  ```
357
362
 
358
- Mechanically, this is the per-model anchor from
359
- [Half 2](#half-2--per-model-scope-row--group) doing the work. Declare an entity
360
- anchor on the models an agent operates on:
363
+ Concretely: each model an agent edits declares a `scope`
364
+ ([Half 2](#half-2--per-model-scope-row--group)), so each row forms its own
365
+ group. The agent subscribes only to the groups for the rows it touches. Declare
366
+ an entity anchor on the models an agent operates on:
361
367
 
362
368
  ```ts
363
369
  // each scope-root model an agent edits forms a per-entity group
@@ -393,8 +399,8 @@ user it ran on behalf of, so audit answers "who did this, and on whose behalf."
393
399
  It never appears in an `identityRole`, because it changes *who's accountable*,
394
400
  not *what's reachable*.
395
401
 
396
- This is deliberately the shape the 2025–2026 agent-identity consensus converged
397
- on, expressed in Ablo's primitives rather than a bolted-on agent ACL:
402
+ Three rules make agent access safe, and they fall out of the model above rather
403
+ than needing a separate agent permission system:
398
404
 
399
405
  - **Inherit the user, and no more** — the OAuth
400
406
  [on-behalf-of](https://workos.com/blog/oauth-on-behalf-of-ai-agents) model: the
package/docs/index.md CHANGED
@@ -1,37 +1,47 @@
1
1
  # Ablo Docs
2
2
 
3
- Ablo is a state control API for **humans and AI agents editing the same
4
- typed state in real time, with attribution, conflict handling, and
5
- fast cutoff**.
3
+ You have a database, and you want an AI agent to edit the same rows your
4
+ users are editing without the two clobbering each other's work. Ablo gives
5
+ the agent a narrow, audited write path: you declare your models, then everyone
6
+ (React components, server actions, and agents) calls the same
7
+ `ablo.deck.update(...)`. Ablo streams confirmed changes to everyone live and
8
+ rejects any write based on stale data.
6
9
 
7
- It gives agents a narrow way to write production state: declare models, load current state, coordinate active work, and write with stale-state checks.
10
+ The flow is four steps: declare your models, read the current rows, claim the
11
+ row you're about to change, then write — and the write is rejected if someone
12
+ changed the row first.
8
13
 
9
- Multiplayer is not a separate product mode. If humans, server actions, and agents
10
- use the same `Ablo({ schema, apiKey })` client and write through
11
- `ablo.<model>`, Ablo fans out confirmed deltas, exposes active claims, and
12
- rejects stale writes for every participant.
14
+ ```ts
15
+ // The same call, whether a person, a server action, or an agent makes it.
16
+ await ablo.deck.update({ id: deckId, data: { title: "Q3 Strategy" } });
17
+ ```
13
18
 
14
- ## What you get, in three commitments
19
+ Claims don't lock. If another writer holds the row, `claim` waits for them,
20
+ re-reads the fresh row, then hands it to you — so two writers serialize
21
+ instead of clobbering.
15
22
 
16
- These commitments drive every design choice in the rest of the docs; if
17
- they don't match what you're building, the trade-offs land elsewhere
18
- (Replicache, ElectricSQL, PowerSync for human-only real-time; Zero for
19
- query-shaped sync).
23
+ ## What you get
24
+
25
+ Three things stay true no matter how you use Ablo:
20
26
 
21
27
  - **One model API for every actor.** `ablo.<model>.update(...)` is the
22
28
  call from React components, server actions, background workers, and
23
29
  AI agents alike. No separate "agent SDK," no parallel mutation path.
24
30
  Attribution comes from the credential, not the call site.
25
- - **Server owns the scope convention.** Tenancy / per-entity scope
31
+ - **You declare tenancy scopes once.** Tenancy / per-entity scope
26
32
  prefixes (`org:`, `deck:`, or your own `region:` / `customer:`) are
27
- declared once on the schema's `identityRoles`. Consumer code never
28
- composes group strings. Same boundary Liveblocks (`prepareSession`),
29
- PowerSync (named streams), and Zero (synced queries) settled on.
33
+ declared once on the schema's `identityRoles`, so application code
34
+ never builds an `org:123` string by hand — which keeps tenant
35
+ boundaries from leaking.
36
+ - **Stale writes are rejected.** If the row changed after you read it,
37
+ your write is turned away instead of silently overwriting the change
38
+ you didn't see.
30
39
 
31
40
  ## Start here
32
41
 
33
42
  - [Quickstart](./quickstart.md) — Make your first schema-backed write.
34
- - [CLI & Migrations](./cli.md) — `init` / `migrate` / `schema push` / `generate`, the shared Zod→Postgres type map, and structured migration errors.
43
+ - [Schema Contract](./schema-contract.md) — One schema becomes typed model clients, React reads, agent writes, Data Source shape, and schema push.
44
+ - [CLI & Migrations](./cli.md) — `init` / `migrate` / `push` / `generate`, the shared Zod→Postgres type map, and structured migration errors.
35
45
  - [Identity & Sync Groups](./identity.md) — Bring your own auth; tell Ablo who's connecting and how org / team / user map to sync-group scope.
36
46
  - [Integration Guide](./integration-guide.md) — Choose Ablo-managed state, Data Source, React, multiplayer, and agent patterns.
37
47
  - [Guarantees](./guarantees.md) — What confirmed writes, stale checks, and claims guarantee.
@@ -60,10 +70,11 @@ query-shaped sync).
60
70
 
61
71
  ## Concepts
62
72
 
73
+ - [Schema Contract](./schema-contract.md) — What the schema drives across SDK, React, agents, Data Source, and migrations.
63
74
  - [Model Methods](./api.md#model-methods) — Load and write typed state.
64
75
  - [Integration Guide](./integration-guide.md) — The normal app path and optional pieces.
65
76
  - [Guarantees](./guarantees.md) — Confirmed writes, optimistic state, stale-write protection, and agent lifecycle.
66
- - [Coordination](./coordination.md) — `claim`, `claimState`, and `queue` for active work.
77
+ - [Coordination](./coordination.md) — `claim`, `claim.state`, and `claim.queue` for active work.
67
78
  - [Connect Your Database](./data-sources.md) — Where data lands when your app database is canonical.
68
79
  - [Receipt](./api.md#receipt) — Confirm what landed.
69
80
  - [Usage](./api.md#usage) — Metering and audit dimensions.
@@ -1,39 +1,26 @@
1
1
  # Integration Guide
2
2
 
3
- Use this guide when you are adding Ablo to a real product, not a demo.
3
+ If humans and AI agents both edit the same records in your app, they overwrite
4
+ each other and there's no good place to coordinate. Ablo gives them one shared,
5
+ typed write path — the same `ablo.<model>.update(...)` call for a React
6
+ component, a server action, a background worker, or an agent — and reconciles the
7
+ edits. This guide adds it to a product that already has a backend and database,
8
+ one model at a time.
4
9
 
5
- ## Why Ablo, before the API
6
-
7
- Ablo is a sync engine designed from the ground up for **humans and AI
8
- agents editing the same state at the same time**. That premise drives
9
- every design choice in this guide; if you only need server-to-server
10
- data syncing without agents in the loop, the trade-offs land elsewhere
11
- (Replicache, ElectricSQL, PowerSync are good answers for human-only
12
- real-time apps; Zero is a good answer for query-shaped sync).
13
-
14
- The shape of the SDK reflects three commitments:
10
+ Three things hold no matter which actor is writing:
15
11
 
16
12
  - **One model API for every actor.** `ablo.<model>.update(...)` is what
17
13
  React components, server actions, background workers, and AI agents
18
14
  all call. No separate "agent SDK," no parallel mutation path. The
19
15
  attribution comes from the credential, not the call site.
20
- - **Server owns the scope convention; client picks a subset by id.** The
21
- `org:` / `user:` / `team:` (or your own `region:` / `customer:`)
22
- prefixes live in the schema's `identityRoles` once, never typed by
23
- consumer code. Same boundary Liveblocks (`prepareSession`), PowerSync
24
- (named streams), and Zero (synced queries) settled on after the same
25
- realization: clients that compose scope strings drift; servers that
26
- derive scope from authed identity don't.
27
- - **Capabilities, not API keys, are how agents authenticate.** Static
28
- API keys protect server-to-server humans. Agents get per-run,
29
- per-scope, leased credentials with per-request signature verification
30
- and instant revocation. The 2025-2026 AI-agent auth consensus
31
- (OAuth 2.1 / MCP, AWS STS, Vault leases, Auth0 Token Vault) converged
32
- on this shape. Capabilities are Ablo's instance.
33
-
34
- If you have already built a sync layer or an agent runtime, you know
35
- what each of those costs. This guide assumes you want them solved once,
36
- together, behind one client.
16
+ - **You never type `org:123` in client code.** The server derives what each
17
+ caller can see from their authenticated identity, using the `identityRoles`
18
+ you declare once in the schema. The client just names which model and id it
19
+ wants. The `org:` / `user:` / `team:` (or your own `region:` / `customer:`)
20
+ prefixes live in the schema, never in consumer code.
21
+ - **Agents don't use your account API key.** Each agent run gets a short-lived
22
+ credential scoped to just what that run can touch, verified per request and
23
+ revocable instantly. (See the Agents section below for the actual calls.)
37
24
 
38
25
  ## The integration in one diagram
39
26
 
@@ -49,7 +36,7 @@ Declare the models Ablo coordinates, then read and write through
49
36
  that same model path.
50
37
 
51
38
  ```txt
52
- schema -> ablo.<model>.load(...) -> ablo.<model>.update(...)
39
+ schema -> ablo.<model>.list(...) -> ablo.<model>.update(...)
53
40
  ```
54
41
 
55
42
  Commits and receipts exist under the hood. Most apps do not create protocol
@@ -226,21 +213,28 @@ refreshes before expiry.
226
213
 
227
214
  ## 3. Read State
228
215
 
229
- Use `load` when the row may not already be local.
216
+ Reads come in two flavors, and you pick based on whether you can wait.
217
+ `retrieve(id)` and `list({ where })` hit the server (and hydrate the local
218
+ store) — they're async, so you `await` them. `get(id)`, `getAll({ where })`,
219
+ and `getCount({ where })` read the already-synced local graph synchronously, so
220
+ they're the ones you call in render.
221
+
222
+ Use `retrieve` when the row may not be local yet — it fetches from the server
223
+ and waits.
230
224
 
231
225
  ```ts
232
226
  await ablo.ready();
233
227
 
234
- const [report] = await ablo.weatherReports.load({ where: { id: 'report_stockholm' } });
228
+ const report = await ablo.weatherReports.retrieve({ id: 'report_stockholm' });
235
229
  if (!report) throw new Error('report not found');
236
230
  ```
237
231
 
238
- Use `retrieve`, `list`, and `count` for synchronous reads after data has
239
- loaded.
232
+ Use `get`, `getAll`, and `getCount` for synchronous local-graph reads after
233
+ data has synced.
240
234
 
241
235
  ```ts
242
- const report = ablo.weatherReports.retrieve('report_stockholm');
243
- const activeReports = ablo.weatherReports.list({
236
+ const report = ablo.weatherReports.get('report_stockholm');
237
+ const activeReports = ablo.weatherReports.getAll({
244
238
  where: { projectId: 'proj_123' },
245
239
  filter: (report) => report.status !== 'ready',
246
240
  orderBy: { updatedAt: 'desc' },
@@ -260,8 +254,8 @@ export function ReportRow({
260
254
  }: {
261
255
  report: { id: string; location: string; status: string };
262
256
  }) {
263
- const report = useAblo((ablo) => ablo.weatherReports.retrieve(serverReport.id)) ?? serverReport;
264
- const active = useAblo((ablo) => ablo.weatherReports.claimState(serverReport.id));
257
+ const report = useAblo((ablo) => ablo.weatherReports.get(serverReport.id)) ?? serverReport;
258
+ const active = useAblo((ablo) => ablo.weatherReports.claim.state({ id: serverReport.id }));
265
259
 
266
260
  return <button disabled={Boolean(active) || report.status === 'ready'}>{report.location}</button>;
267
261
  }
@@ -278,7 +272,7 @@ const ablo = useAblo();
278
272
  For simple writes:
279
273
 
280
274
  ```ts
281
- await ablo.weatherReports.update('report_stockholm', { status: 'ready' }, { wait: 'confirmed' });
275
+ await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' }, wait: 'confirmed' });
282
276
  ```
283
277
 
284
278
  For writes based on state the user or agent already read, snapshot first and
@@ -287,15 +281,13 @@ reject stale updates:
287
281
  ```ts
288
282
  const snap = ablo.snapshot({ weatherReports: 'report_stockholm' });
289
283
 
290
- await ablo.weatherReports.update(
291
- 'report_stockholm',
292
- { status: 'ready' },
293
- {
294
- readAt: snap.stamp,
295
- onStale: 'reject',
296
- wait: 'confirmed',
297
- }
298
- );
284
+ await ablo.weatherReports.update({
285
+ id: 'report_stockholm',
286
+ data: { status: 'ready' },
287
+ readAt: snap.stamp,
288
+ onStale: 'reject',
289
+ wait: 'confirmed',
290
+ });
299
291
  ```
300
292
 
301
293
  `wait: 'confirmed'` resolves after the server accepts the write. Rejections roll
@@ -342,11 +334,11 @@ The migration can be gradual:
342
334
 
343
335
  1. Declare schema for one model, such as `reports`.
344
336
  2. Keep existing server loads for first paint.
345
- 3. Add `useAblo((ablo) => ablo.weatherReports.retrieve(id)) ?? serverReport` for live rows.
337
+ 3. Add `useAblo((ablo) => ablo.weatherReports.get(id)) ?? serverReport` for live rows.
346
338
  4. Add one Data Source endpoint that calls the existing service layer.
347
339
  5. Move one mutation button from `fetch('/api/reports/...')` to `ablo.weatherReports.update(...)`.
348
340
  6. Add an outbox/events path for writes that still happen outside Ablo.
349
- 7. Let agents use the same `ablo.weatherReports.load(...)` and `ablo.weatherReports.update(...)`.
341
+ 7. Let agents use the same `ablo.weatherReports.list(...)` and `ablo.weatherReports.update(...)`.
350
342
 
351
343
  For the full Python shape, see
352
344
  [Existing Python Backend](./examples/existing-python-backend.md).
@@ -407,21 +399,26 @@ The API key verifies Ablo's request. It is not a database credential.
407
399
  Agents should use the same model methods as the app when they can import the
408
400
  schema.
409
401
 
402
+ An agent often reads a row, calls an LLM, then writes back — a slow gap during
403
+ which a human might touch the same row. Wrap that work in a claim. Claims don't
404
+ lock. If another writer holds the row, `claim` waits for them, re-reads the
405
+ fresh row, then hands it to you — so two writers serialize instead of clobbering.
406
+
410
407
  ```ts
411
- const [report] = await ablo.weatherReports.load({ where: { id: reportId } });
408
+ const report = await ablo.weatherReports.retrieve({ id: reportId });
412
409
  if (!report) return;
413
410
 
414
- await ablo.weatherReports.claim(
415
- reportId,
416
- async (claimed) => {
417
- await ablo.weatherReports.update(
418
- claimed.id,
419
- { status: 'ready', forecast: await getForecast(claimed) },
420
- { wait: 'confirmed' }
421
- );
422
- },
423
- { wait: false, action: 'forecasting' }
424
- );
411
+ await using claim = await ablo.weatherReports.claim({
412
+ id: reportId,
413
+ wait: false,
414
+ action: 'forecasting',
415
+ });
416
+ const claimed = claim.data;
417
+ await ablo.weatherReports.update({
418
+ id: claimed.id,
419
+ data: { status: 'ready', forecast: await getForecast(claimed) },
420
+ wait: 'confirmed',
421
+ });
425
422
  ```
426
423
 
427
424
  Use AI SDK for the model loop. Put Ablo inside the tool that persists the final
@@ -436,11 +433,13 @@ const completeReport = tool({
436
433
  }),
437
434
  execute: async ({ reportId, forecast }) => {
438
435
  const snap = ablo.snapshot({ weatherReports: reportId });
439
- return ablo.weatherReports.update(
440
- reportId,
441
- { status: 'ready', forecast },
442
- { readAt: snap.stamp, onStale: 'reject', wait: 'confirmed' }
443
- );
436
+ return ablo.weatherReports.update({
437
+ id: reportId,
438
+ data: { status: 'ready', forecast },
439
+ readAt: snap.stamp,
440
+ onStale: 'reject',
441
+ wait: 'confirmed',
442
+ });
444
443
  },
445
444
  });
446
445
  ```
@@ -455,7 +454,7 @@ Keep agent writes on the same schema client surface as the app.
455
454
  | `/testing` | Test harnesses and deterministic mocks. |
456
455
  | `Data Source` | Keep your app database canonical. |
457
456
  | `persistence: 'indexeddb'` | Durable browser cache that survives reloads, for apps that need it. |
458
- | `claim` / `claimState` / `queue` | Show active work and coordinate before a write. |
457
+ | `claim` / `claim.state` / `claim.queue` | Show active work and coordinate before a write. |
459
458
  | `snapshot` + `readAt` | Reject writes based on stale state. |
460
459
  | `mutable`, `readOnly`, `field`, `indexed` | Advanced schema and read tuning. |
461
460
 
@@ -465,16 +464,17 @@ them.
465
464
 
466
465
  ## Method Cheatsheet
467
466
 
468
- | Method | Use it for |
469
- | ---------------------------- | ---------------------------------------------------------------- |
470
- | `load({ where })` | Async hydration from backing store/server. |
471
- | `retrieve(id)` | Synchronous read of one already-loaded row. |
472
- | `list(options?)` | Synchronous collection read of loaded rows. |
473
- | `count(options?)` | Synchronous count of loaded rows. |
474
- | `create(data, options?)` | Create through the model client. |
475
- | `update(id, data, options?)` | Update through the model client. |
476
- | `delete(id, options?)` | Delete through the model client. |
477
- | `claimState(id)` | See active work on a model row. |
478
- | `claim(id, work, options?)` | Wait for your turn, re-read, and hold the row while `work` runs. |
467
+ | Method | Use it for |
468
+ | ---------------------------- | --------------------------------------------------------------------------- |
469
+ | `retrieve(id)` | Async read of one row from the server (await it). |
470
+ | `list({ where })` | Async read of many rows from the server (await it). |
471
+ | `get(id)` | Synchronous local read of one synced row (use in render). |
472
+ | `getAll({ where })` | Synchronous local read of many synced rows. |
473
+ | `getCount({ where })` | Synchronous local count of synced rows. |
474
+ | `create(data, options?)` | Create through the model client. |
475
+ | `update(id, data, options?)` | Update through the model client. |
476
+ | `delete(id, options?)` | Delete through the model client. |
477
+ | `claim.state({ id })` | See who is currently working on a row (synchronous). |
478
+ | `claim(id, work, options?)` | Wait for your turn, re-read, and hold the row while `work` runs. |
479
479
 
480
480
  Keep first integrations on the model methods above.
@@ -1,28 +1,39 @@
1
1
  # Interaction Model
2
2
 
3
- Ablo's public model is the path every human UI, server action, and agent uses on
4
- every write:
3
+ When a person, a server action, and an AI agent can all write to the same row,
4
+ you need one write path that stops them from clobbering each other. Ablo gives
5
+ you exactly one: load the row, claim it while you work, update it, and wait for
6
+ confirmation. This page walks through that path and the few primitives behind it.
5
7
 
8
+ Here's the whole path in one block — claim a row, update it inside the claim, and
9
+ let the claim release when your callback returns:
10
+
11
+ ```ts
12
+ const report = await ablo.weatherReports.retrieve({ id: 'report_stockholm' });
13
+
14
+ await using claim = await ablo.weatherReports.claim({ id: 'report_stockholm' });
15
+ await ablo.weatherReports.update({ id: claim.data.id, data: { status: 'ready' }, wait: 'confirmed' });
6
16
  ```
7
- Schema -> Model load -> Claim -> Model update -> Confirmation
8
- ```
17
+
18
+ Claims don't lock. If another writer holds the row, `claim` waits for them,
19
+ re-reads the fresh row, then hands it to you — so two writers serialize instead
20
+ of clobbering.
9
21
 
10
22
  ## Primitives
11
23
 
12
24
  | Primitive | Plane | Purpose |
13
25
  |---|---|---|
14
26
  | `Schema` | State | Declares typed models the app and agents can read and write. |
15
- | `Model` | State | The generated `ablo.<model>` model. Use `load`, `retrieve`, `create`, `update`, and `delete`. |
16
- | `Claim` | Coordination | Who is working on a target. Claimed via `ablo.<model>.claim(id, ...)` and read via `ablo.<model>.claimState(id)`. Ephemeral — never persisted. |
27
+ | `Model` | State | The generated `ablo.<model>` model. Use `retrieve`/`list` (async server reads), `get`/`getAll`/`getCount` (synchronous local reads), `create`, `update`, and `delete`. |
28
+ | `Claim` | Coordination | Who is working on a target. Taken via `ablo.<model>.claim({ id })` and read via `ablo.<model>.claim.state({ id })`. Ephemeral — never persisted. |
17
29
  | `Commit` | Protocol | The durable write underneath model updates. Most users do not call it directly. |
18
30
  | `Receipt` | Protocol | The lower-level durable result for custom runtimes. Schema writes use `wait: 'confirmed'`. |
19
31
 
20
32
  ### Why each primitive is separate
21
33
 
22
- The plane separation isn't ceremony collapsing any two of these would
23
- lose a property that's hard to recover later. A reader coming from
24
- Replicache or Yjs would expect just `Commit`; here's what the others buy
25
- you over that minimum:
34
+ Why are `Claim`, `Commit`, and `Receipt` separate things instead of one? Each
35
+ does a job the others can't. If you're coming from Replicache or Yjs you'd
36
+ expect just `Commit`; here's what the other two buy you over that minimum:
26
37
 
27
38
  - **`Claim` is not a read lock.** Reads stay open. Claims serialize
28
39
  acting-on-the-row, so slow work can wait in FIFO order, re-read, and write
@@ -33,45 +44,42 @@ you over that minimum:
33
44
  client. A status code can't be re-read by a sub-agent that wasn't on
34
45
  the original call.
35
46
 
36
- The shape is borrowed from systems that learned the cost of collapse:
37
- coordination from operational-transform CRDTs and Linear's optimistic
38
- multiplayer model, and receipts from durable write protocols.
39
-
40
47
  ## Run Loop
41
48
 
42
49
  A normal schema-backed run is:
43
50
 
44
- ```
45
- const [report] = await ablo.weatherReports.load({ where: { id } });
46
- const active = ablo.weatherReports.claimState(id);
47
- await ablo.weatherReports.claim(id, async (report) => {
48
- await ablo.weatherReports.update(report.id, patch, { wait: 'confirmed' });
49
- });
51
+ ```ts
52
+ const report = await ablo.weatherReports.retrieve({ id });
53
+ const active = ablo.weatherReports.claim.state({ id });
54
+ await using claim = await ablo.weatherReports.claim({ id });
55
+ await ablo.weatherReports.update({ id: claim.data.id, data: patch, wait: 'confirmed' });
50
56
  ```
51
57
 
58
+ `retrieve({ id })` is an async server read (await it). `claim.state({ id })` is a
59
+ synchronous local read of who currently holds the row — it never blocks.
60
+
52
61
  ## Coordination
53
62
 
54
63
  > Loop view only. Full claim reference — methods, the claim-state object, the
55
- > `queue`, errors — is [Coordination](./coordination.md).
64
+ > `claim.queue`, errors — is [Coordination](./coordination.md).
56
65
 
57
- Claims broadcast across the org. Claim a row through the flat model verb, write
58
- through the normal `update`, and the claim releases when the callback returns:
66
+ Claims broadcast across the org. Call `claim({ id })`, do your writes with the
67
+ normal `update` inside the `await using` scope, and the claim releases
68
+ automatically when the scope exits:
59
69
 
60
70
  ```ts
61
- await ablo.weatherReports.claim(
62
- 'report_stockholm',
63
- async (report) => {
64
- await ablo.weatherReports.update(report.id, { status: 'ready' }); // stale-guarded under the claim
65
- },
66
- { action: 'editing' },
67
- );
71
+ await using claim = await ablo.weatherReports.claim({
72
+ id: 'report_stockholm',
73
+ action: 'editing',
74
+ });
75
+ await ablo.weatherReports.update({ id: claim.data.id, data: { status: 'ready' } }); // rejected if the row changed under the claim
68
76
  ```
69
77
 
70
- `ablo.weatherReports.claimState('report_stockholm')` reads the live claim (or `null`) without
71
- blocking. The claim is **advisory**: if another participant holds the row,
72
- `claim` waits for them to finish and re-reads before handing back the row. The
73
- same signal is visible to every schema client through `claimState(id)` and the live
74
- claim stream.
78
+ `ablo.weatherReports.claim.state({ id: 'report_stockholm' })` reads the live claim (or
79
+ `null`) without blocking. Claims don't lock: if another participant holds the
80
+ row, `claim` waits for them to finish, re-reads, and then hands you the fresh
81
+ row. The same signal is visible to every schema client through `claim.state({ id })`
82
+ and the live claim stream.
75
83
 
76
84
  ## Conflict resolution
77
85