@abloatai/ablo 0.5.1 → 0.6.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 (94) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +217 -122
  3. package/dist/BaseSyncedStore.d.ts +2 -2
  4. package/dist/BaseSyncedStore.js +2 -2
  5. package/dist/api/index.d.ts +3 -3
  6. package/dist/api/index.js +1 -1
  7. package/dist/client/Ablo.d.ts +90 -93
  8. package/dist/client/Ablo.js +121 -60
  9. package/dist/client/ApiClient.d.ts +14 -14
  10. package/dist/client/ApiClient.js +81 -55
  11. package/dist/client/createInternalComponents.d.ts +2 -3
  12. package/dist/client/createInternalComponents.js +2 -3
  13. package/dist/client/createModelProxy.d.ts +90 -87
  14. package/dist/client/createModelProxy.js +124 -127
  15. package/dist/client/index.d.ts +6 -7
  16. package/dist/client/index.js +4 -5
  17. package/dist/client/validateAbloOptions.js +3 -3
  18. package/dist/core/index.d.ts +2 -0
  19. package/dist/core/index.js +7 -0
  20. package/dist/errors.d.ts +8 -8
  21. package/dist/errors.js +18 -10
  22. package/dist/index.d.ts +9 -8
  23. package/dist/index.js +7 -11
  24. package/dist/interfaces/index.d.ts +2 -10
  25. package/dist/mutators/Transaction.d.ts +2 -2
  26. package/dist/mutators/Transaction.js +2 -2
  27. package/dist/mutators/mutateActions.d.ts +44 -0
  28. package/dist/{react/useMutate.js → mutators/mutateActions.js} +11 -28
  29. package/dist/mutators/readerActions.d.ts +32 -0
  30. package/dist/{react/useReader.js → mutators/readerActions.js} +2 -18
  31. package/dist/query/types.d.ts +1 -1
  32. package/dist/react/AbloProvider.d.ts +1 -1
  33. package/dist/react/AbloProvider.js +3 -3
  34. package/dist/react/context.d.ts +4 -4
  35. package/dist/react/index.d.ts +4 -5
  36. package/dist/react/index.js +3 -7
  37. package/dist/react/useAblo.d.ts +14 -14
  38. package/dist/react/useAblo.js +26 -26
  39. package/dist/react/useIntent.d.ts +2 -2
  40. package/dist/react/useIntent.js +2 -2
  41. package/dist/react/useMutators.d.ts +1 -1
  42. package/dist/react/usePresence.d.ts +3 -3
  43. package/dist/react/usePresence.js +4 -4
  44. package/dist/react/useUndoScope.d.ts +1 -1
  45. package/dist/schema/diff.d.ts +161 -0
  46. package/dist/schema/diff.js +262 -0
  47. package/dist/schema/generate.d.ts +19 -0
  48. package/dist/schema/generate.js +87 -0
  49. package/dist/schema/index.d.ts +4 -1
  50. package/dist/schema/index.js +7 -1
  51. package/dist/schema/schema.d.ts +83 -32
  52. package/dist/schema/schema.js +58 -12
  53. package/dist/schema/serialize.d.ts +92 -0
  54. package/dist/schema/serialize.js +227 -0
  55. package/dist/sync/SyncWebSocket.d.ts +17 -0
  56. package/dist/sync/SyncWebSocket.js +46 -1
  57. package/dist/sync/awaitIntentGrant.d.ts +26 -0
  58. package/dist/sync/awaitIntentGrant.js +60 -0
  59. package/dist/sync/createIntentStream.js +43 -4
  60. package/dist/sync/createPresenceStream.js +1 -1
  61. package/dist/sync/participants.d.ts +2 -2
  62. package/dist/sync/participants.js +4 -4
  63. package/dist/types/global.d.ts +43 -52
  64. package/dist/types/global.js +16 -18
  65. package/dist/types/streams.d.ts +37 -9
  66. package/docs/api.md +68 -158
  67. package/docs/audit.md +5 -5
  68. package/docs/client-behavior.md +41 -42
  69. package/docs/coordination.md +294 -0
  70. package/docs/data-sources.md +14 -14
  71. package/docs/examples/agent-human.md +30 -32
  72. package/docs/examples/ai-sdk-tool.md +32 -33
  73. package/docs/examples/existing-python-backend.md +35 -33
  74. package/docs/examples/nextjs.md +24 -25
  75. package/docs/examples/server-agent.md +20 -61
  76. package/docs/guarantees.md +30 -55
  77. package/docs/identity.md +458 -0
  78. package/docs/index.md +12 -24
  79. package/docs/integration-guide.md +106 -116
  80. package/docs/interaction-model.md +29 -95
  81. package/docs/mcp/claude-code.md +3 -3
  82. package/docs/mcp/cursor.md +1 -1
  83. package/docs/mcp/windsurf.md +1 -1
  84. package/docs/mcp.md +11 -26
  85. package/docs/quickstart.md +43 -49
  86. package/docs/react.md +73 -23
  87. package/docs/roadmap.md +5 -7
  88. package/llms.txt +34 -39
  89. package/package.json +1 -1
  90. package/dist/react/useMutate.d.ts +0 -83
  91. package/dist/react/useQuery.d.ts +0 -123
  92. package/dist/react/useQuery.js +0 -145
  93. package/dist/react/useReader.d.ts +0 -69
  94. package/docs/capabilities.md +0 -163
@@ -0,0 +1,294 @@
1
+ # Coordination Reference
2
+
3
+ Coordinate long-running work on a row so humans and agents don't clobber each
4
+ other. Most writes need none of this — `ablo.<model>.update(id, …)` is optimistic
5
+ and the server rejects it if the row moved. Reach for `claim` only when you'll
6
+ **hold a row across a slow gap** (read → LLM call → write).
7
+
8
+ Claims are **fair**: on contention a second claimer joins a **server-side FIFO
9
+ queue** and blocks until promoted to the head of the line — it does not fail and
10
+ does not poll. Reads are open by default; reading a claimed row is allowed unless
11
+ the caller explicitly asks for claimed gating. A claim carries a TTL so a crashed
12
+ holder is auto-released and the queue advances.
13
+
14
+ This reference has three sections: the [claim state object](#the-claim-state-object),
15
+ the SDK [methods](#methods) (`claim` · `claimState` · `queue` · `release` ·
16
+ [writing under a claim](#writing-under-a-claim)), and the [errors](#errors) you
17
+ can catch.
18
+
19
+ ---
20
+
21
+ ## The claim state object
22
+
23
+ The claim state object is the live record that a participant is coordinating work on
24
+ a model row. It's what `claimState()` returns and what observers render.
25
+
26
+ | field | type | description |
27
+ |---|---|---|
28
+ | `id` | `string` | The claim id (distinct from the target row id). |
29
+ | `status` | `ClaimStatus` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'`. `active` = the holder; `queued` = waiting in line behind it. |
30
+ | `target` | `EntityRef` | What is being coordinated (`{ model, id, field? }`). |
31
+ | `action` | `string` | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
32
+ | `heldBy` | `string` | Participant holding (or waiting on) it (e.g. `'agent:forecaster'`). |
33
+ | `participantKind` | `'human' \| 'agent'` | Who's behind it. |
34
+ | `position` | `number?` | 0-based place in the FIFO line — present only when `status: 'queued'` (`0` = next behind the holder). |
35
+ | `createdAt` | `string?` | Ms-epoch the holder opened it. Optional — derived shapes may omit it. |
36
+ | `expiresAt` | `string` | Ms-epoch the server reclaims it if the holder goes **silent**. Renewed automatically while the holder's connection stays alive — a crash-cleanup floor, not a duration you size. |
37
+
38
+ ```jsonc
39
+ // A live claim state, as returned by claimState():
40
+ {
41
+ "id": "claim_8fJ2",
42
+ "status": "active",
43
+ "target": { "model": "weatherReports", "id": "report_stockholm" },
44
+ "action": "editing",
45
+ "heldBy": "agent:forecaster",
46
+ "participantKind": "agent",
47
+ "createdAt": "1748160000000",
48
+ "expiresAt": "1748160030000"
49
+ }
50
+ ```
51
+
52
+ ---
53
+
54
+ ## Methods
55
+
56
+ Each method below follows one fixed shape: **signature · what it does ·
57
+ parameters · returns · example**.
58
+
59
+ ### `claim`
60
+
61
+ ```ts
62
+ ablo.<model>.claim(id, work, options?): Promise<R> // callback form
63
+ ablo.<model>.claim(id, options?): Promise<ClaimedRow<T>>
64
+ ```
65
+
66
+ Claim a row so other writers serialize behind you until you're done; reads stay
67
+ open by default. The claim acquires through the server's fair FIFO queue: if the
68
+ target is free the lease is yours immediately, and if another participant holds
69
+ it your claim **waits in line** and resolves only once it reaches the head —
70
+ then re-reads so the claimed snapshot reflects what the previous holder
71
+ committed. There's no client-side poll and no TOCTOU gap: the server orders
72
+ contenders.
73
+
74
+ **Parameters**
75
+
76
+ | name | type | required | description |
77
+ |---|---|---|---|
78
+ | `id` | `string` | yes | The row id — same id as `retrieve` / `update`. |
79
+ | `options.action` | `string` | no | Phase shown to observers (default `'editing'`). |
80
+ | `options.field` | `string` | no | Field-level target, for fine-grained claimed-state badges. |
81
+ | `options.wait` | `boolean` | no | `true` (default) queues and waits for the lease. `false` is fail-fast — if another participant holds the row, reject immediately with `AbloClaimedError('entity_claimed')` instead of queuing (claim-or-skip, for work dedup where waiting would double-process). |
82
+ | `options.maxQueueDepth` | `number` | no | Backpressure: reject with `AbloClaimedError('queue_too_deep')` instead of joining a line already `>= maxQueueDepth` deep. Omit to wait however deep the queue is. |
83
+ | `options.ttl` | `Duration` | no | Crash-cleanup floor. Rarely set — the lease renews while your connection is alive, so it only matters once you go silent. |
84
+ | `work` | `(row) => …` | no | Callback form: hold the claim for the callback, release when it returns. |
85
+
86
+ The high-level `claim` queues by default, so on contention you either get the row
87
+ when your turn arrives or one of the [queue errors](#errors) (`claim_lost`,
88
+ `grant_timeout`).
89
+
90
+ **Returns** — with the callback form, returns whatever `work` returns and
91
+ releases after the callback returns or throws. The manual form returns the
92
+ claimed row (`ClaimedRow<T> = T & AsyncDisposable`): the row data plus a
93
+ release hook for manual scopes.
94
+
95
+ **Example**
96
+
97
+ ```ts
98
+ // Callback form — works on any toolchain:
99
+ const forecast = await ablo.weatherReports.claim('report_stockholm', async (report) => {
100
+ const weather = await weatherAgent.getWeather(report.location);
101
+ await ablo.weatherReports.update(report.id, { forecast: weather });
102
+ return weather;
103
+ });
104
+ ```
105
+
106
+ The manual scoped form is still available for wider TS 5.2+ scopes, but ordinary
107
+ held work should use the callback form above.
108
+
109
+ ### Claim-gated reads
110
+
111
+ `claimState(id)` always returns immediately. Model reads such as
112
+ `ablo.<model>.retrieve(id)` are local reads and stay available while a claim is
113
+ held. Server/model reads can choose a claimed policy:
114
+
115
+ ```ts
116
+ await ablo.model('weatherReports').retrieve('report_stockholm', {
117
+ ifClaimed: 'wait', // wait until the active claim clears
118
+ claimedTimeout: 30_000, // maximum wait
119
+ });
120
+ ```
121
+
122
+ - `ifClaimed: 'return'` reads now and includes active work metadata.
123
+ - `ifClaimed: 'wait'` waits for the active claim to clear before reading.
124
+ - `ifClaimed: 'fail'` throws `AbloClaimedError` if the row is claimed.
125
+
126
+ ### `claimState`
127
+
128
+ ```ts
129
+ ablo.<model>.claimState(id)
130
+ ```
131
+
132
+ Read who's currently working on a row, for observers and UI. Synchronous and
133
+ reactive (it reads the local coordination snapshot). Never blocks.
134
+
135
+ **Parameters**
136
+
137
+ | name | type | required | description |
138
+ |---|---|---|---|
139
+ | `id` | `string` | yes | The row id. |
140
+
141
+ **Returns** — the active [claim state object](#the-claim-state-object), or `null` when the row
142
+ is free.
143
+
144
+ **Example**
145
+
146
+ ```ts
147
+ const who = ablo.weatherReports.claimState('report_stockholm');
148
+ if (who) console.log(`${who.heldBy} is ${who.action}`); // 'agent:forecaster is editing'
149
+ ```
150
+
151
+ ```jsonc
152
+ // Resolved value when the row is held:
153
+ {
154
+ "id": "claim_8fJ2",
155
+ "status": "active",
156
+ "target": { "model": "weatherReports", "id": "report_stockholm" },
157
+ "action": "editing",
158
+ "heldBy": "agent:forecaster",
159
+ "participantKind": "agent",
160
+ "expiresAt": "1748160030000"
161
+ }
162
+ // → null when free.
163
+ ```
164
+
165
+ ### `queue`
166
+
167
+ ```ts
168
+ ablo.<model>.queue(id)
169
+ ```
170
+
171
+ Read the **wait line** behind a row — the FIFO of claims queued behind the
172
+ current holder, in promotion order. Like `claimState`, it's synchronous and
173
+ reactive (it reads the local coordination snapshot, kept current by the server's
174
+ queue-mutation frames), and reading never blocks. Where `claimState` answers "who
175
+ holds it," `queue` answers "who's lined up next" — render "3rd in line", or
176
+ decide the wait isn't worth it.
177
+
178
+ **Parameters**
179
+
180
+ | name | type | required | description |
181
+ |---|---|---|---|
182
+ | `id` | `string` | yes | The row id. |
183
+
184
+ **Returns** — a list envelope. `data` contains the queued
185
+ [claim state objects](#the-claim-state-object) in promotion order (head first), excluding
186
+ the active holder; `[]` when no one is waiting.
187
+
188
+ **Example**
189
+
190
+ ```ts
191
+ const { data: waiting } = ablo.weatherReports.queue('report_stockholm');
192
+ console.log(`${waiting.length} ahead of you`);
193
+ console.log(waiting.map((i) => i.heldBy)); // ['agent:b', 'agent:c']
194
+ ```
195
+
196
+ ### `release`
197
+
198
+ ```ts
199
+ ablo.<model>.release(id): Promise<void>
200
+ ```
201
+
202
+ Release a claim you hold. Usually **implicit** — the callback returning releases
203
+ for you, and TTL cleans up a crashed holder.
204
+ Call this only to give a manually held claim back early (claimed, then decided
205
+ not to write).
206
+ Releasing **promotes the head of the queue**: the next waiter receives the claim.
207
+
208
+ **Parameters**
209
+
210
+ | name | type | required | description |
211
+ |---|---|---|---|
212
+ | `id` | `string` | yes | The row id you hold a claim on. No-op if you don't hold it. |
213
+
214
+ **Returns** — resolves once the claim is released.
215
+
216
+ **Example**
217
+
218
+ ```ts
219
+ const report = await ablo.weatherReports.claim('report_stockholm', { action: 'reviewing' });
220
+ try {
221
+ const ok = await reviewExternally(report);
222
+ if (!ok) return; // abandon, no write
223
+ await ablo.weatherReports.update(report.id, { status: 'ready' });
224
+ } finally {
225
+ await ablo.weatherReports.release(report.id);
226
+ }
227
+ ```
228
+
229
+ ### Writing under a claim
230
+
231
+ There is no separate "write" method on a claim — use the normal flat
232
+ `ablo.<model>.update(id, data)`. While you hold a claim on `id`, that `update` is
233
+ automatically stale-guarded against the snapshot the claim took (`readAt` =
234
+ snapshot watermark, `onStale: 'reject'`) and attributed to the claim's lease, so
235
+ it rejects with [`AbloStaleContextError`](#errors) if the row changed under you.
236
+
237
+ ```ts
238
+ await ablo.weatherReports.claim(id, async (report) => {
239
+ await ablo.weatherReports.update(report.id, { status: 'ready' }); // guarded by the claim
240
+ });
241
+ ```
242
+
243
+ Claims are **enforced server-side**: if you `update`/`delete` a row that *another*
244
+ participant holds, the commit is rejected with [`AbloClaimedError`](#errors) (`code:
245
+ 'entity_claimed'`). To proceed, `claim` the row yourself — the claim queues
246
+ behind the current holder and re-reads once it's yours, so your `update` lands
247
+ on fresh data. You never conflict with your own claim, and reads are never gated.
248
+
249
+ ```ts
250
+ try {
251
+ await ablo.weatherReports.update(id, { status: 'ready' });
252
+ } catch (err) {
253
+ if (err instanceof AbloClaimedError) {
254
+ // someone else holds it — claim the row and retry from fresh state
255
+ }
256
+ }
257
+ ```
258
+
259
+ ---
260
+
261
+ ## Errors
262
+
263
+ All extend `AbloError` (`packages/sync-engine/src/errors.ts`). Catch by `type` or
264
+ inspect the `code`.
265
+
266
+ | error | `code` | thrown when | carries |
267
+ |---|---|---|---|
268
+ | `AbloClaimedError` | `claim_lost` | A held/queued claim was taken away (holder TTL lapse on disconnect, or revoke) while you were holding or waiting. | `claims?` |
269
+ | `AbloClaimedError` | `grant_timeout` | The optional `timeoutMs` elapsed while you were still queued for a grant. | `claims?` |
270
+ | `AbloClaimedError` | `queue_too_deep` | `claim` was passed `maxQueueDepth` and the wait line was already that deep when you tried to join — fail-fast instead of waiting. | `claims?` |
271
+ | `AbloClaimedError` | `claim_conflict` | An `update`/`delete` targets a row another participant holds — the server's pre-commit check rejected it. | — |
272
+ | `AbloClaimedError` | `entity_claimed` | Same conflict, from the commit guard backstop. | — |
273
+ | `AbloStaleContextError` | — | A guarded `update` (under a claim, or any write carrying `readAt`) targets a row that received deltas since the snapshot — your reasoning is stale. | `readAt`, `conflicts[]` |
274
+ | `AbloValidationError` | `model_claim_not_configured` | `claim` called on a model without collaboration wiring. | — |
275
+ | `AbloValidationError` | `entity_not_found` | The row id doesn't exist locally or on load. | — |
276
+
277
+ `AbloStaleContextError.conflicts` lists the `(model, id, observedSyncId)` rows
278
+ that moved during your generation window — use it for selective regeneration
279
+ (re-think only the slides that changed, not the whole deck) and for metrics.
280
+
281
+ ```ts
282
+ try {
283
+ await ablo.weatherReports.claim('report_stockholm', async (report) => {
284
+ const weather = await weatherAgent.getWeather(report.location); // slow gap
285
+ await ablo.weatherReports.update(report.id, { forecast: weather });
286
+ });
287
+ } catch (err) {
288
+ if (err instanceof AbloClaimedError && err.code === 'claim_lost') {
289
+ // Our lease lapsed mid-flight (we stalled past the TTL). Re-claim and retry.
290
+ } else if (err instanceof AbloStaleContextError) {
291
+ // The row moved under us — re-read and regenerate from the fresh snapshot.
292
+ } else throw err;
293
+ }
294
+ ```
@@ -8,7 +8,7 @@ the SDK, agents, realtime subscriptions, and the Data Source endpoint. Use
8
8
  `schema.prisma`.
9
9
 
10
10
  By default, Ablo stores the rows for the models you declare. That makes Ablo the
11
- managed state store for those resources, the same way Stripe stores `Customer`
11
+ managed state store for those models, the same way Stripe stores `Customer`
12
12
  and `PaymentIntent` objects that you create through Stripe's API.
13
13
 
14
14
  If you already have application tables and want those tables to remain
@@ -54,9 +54,9 @@ ABLO_API_KEY=sk_live_...
54
54
  The SDK call is the same in both modes:
55
55
 
56
56
  ```ts
57
- await ablo.tasks.create({ title: 'Draft launch plan', status: 'todo' });
58
- await ablo.tasks.update('task_123', { status: 'done' });
59
- const task = ablo.tasks.retrieve('task_123');
57
+ await ablo.weatherReports.create({ location: 'Stockholm', status: 'pending' });
58
+ await ablo.weatherReports.update('report_stockholm', { status: 'ready' });
59
+ const report = ablo.weatherReports.retrieve('report_stockholm');
60
60
  ```
61
61
 
62
62
  Only the backing store changes.
@@ -120,13 +120,13 @@ export const POST = dataSource({
120
120
  return { rows };
121
121
  },
122
122
 
123
- tasks: {
123
+ reports: {
124
124
  async load({ id, context }) {
125
- return context.auth.db.task.findUnique({ where: { id } });
125
+ return context.auth.db.report.findUnique({ where: { id } });
126
126
  },
127
127
 
128
128
  async list({ query, context }) {
129
- return context.auth.db.task.findMany({
129
+ return context.auth.db.report.findMany({
130
130
  take: query.limit ?? 100,
131
131
  });
132
132
  },
@@ -137,9 +137,9 @@ export const POST = dataSource({
137
137
  Your app code still writes through the normal model API:
138
138
 
139
139
  ```ts
140
- await ablo.tasks.update(
141
- 'task_123',
142
- { status: 'done' },
140
+ await ablo.weatherReports.update(
141
+ 'report_stockholm',
142
+ { status: 'ready' },
143
143
  { wait: 'confirmed', readAt: snap.stamp, onStale: 'reject' },
144
144
  );
145
145
  ```
@@ -155,9 +155,9 @@ When Ablo calls your Data Source, it sends a signed JSON request:
155
155
  operations: [
156
156
  {
157
157
  type: 'UPDATE',
158
- model: 'tasks',
159
- id: 'task_123',
160
- input: { status: 'done' },
158
+ model: 'weatherReports',
159
+ id: 'report_stockholm',
160
+ input: { status: 'ready' },
161
161
  readAt: 1042,
162
162
  onStale: 'reject',
163
163
  },
@@ -177,7 +177,7 @@ Return canonical rows:
177
177
  ```ts
178
178
  {
179
179
  rows: [
180
- { id: 'task_123', title: 'Fix docs', status: 'done' },
180
+ { id: 'report_stockholm', location: 'Stockholm', status: 'ready' },
181
181
  ],
182
182
  }
183
183
  ```
@@ -1,57 +1,57 @@
1
1
  # Agent + Human
2
2
 
3
- A task-writing agent that yields when a human is editing the same task.
3
+ A report-writing agent that yields when a human is editing the same report.
4
4
 
5
5
  ## Scenario
6
6
 
7
- A product queue has tasks that humans and agents both update. They must not
7
+ A product queue has reports that humans and agents both update. They must not
8
8
  collide:
9
9
 
10
10
  - If the user is editing, the agent waits or yields.
11
11
  - If the agent is updating, the UI can show who is active.
12
- - If the task changes mid-run, the commit rejects instead of overwriting newer
12
+ - If the report changes mid-run, the commit rejects instead of overwriting newer
13
13
  state.
14
14
 
15
15
  ## Schema-Backed Worker
16
16
 
17
- Use the same schema client the app uses. The worker loads the task, checks active
18
- intents, and writes through `ablo.tasks.update(...)`.
17
+ Use the same schema client the app uses. The worker loads the report, claims the
18
+ row, and writes through `ablo.weatherReports.update(...)`.
19
19
 
20
20
  ```ts
21
21
  import Ablo from '@abloatai/ablo';
22
22
  import { defineSchema, model, z } from '@abloatai/ablo/schema';
23
23
 
24
24
  const schema = defineSchema({
25
- tasks: model({
26
- title: z.string(),
27
- status: z.enum(['todo', 'doing', 'done']),
25
+ weatherReports: model({
26
+ location: z.string(),
27
+ status: z.enum(['pending', 'ready']),
28
28
  }),
29
29
  });
30
30
 
31
31
  const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
32
32
 
33
- export async function markDone(taskId: string) {
33
+ export async function markReady(reportId: string) {
34
34
  await ablo.ready();
35
35
 
36
- const [task] = await ablo.tasks.load({ where: { id: taskId } });
37
- if (!task) return { status: 'not_found' };
38
-
39
- const busy = ablo.intents.list({ resource: 'tasks', id: taskId });
40
- if (busy.length > 0) return { status: 'busy', intents: busy };
41
-
42
- const snap = ablo.snapshot({ tasks: taskId });
43
- const updated = await ablo.tasks.update(
44
- taskId,
45
- { status: 'done' },
46
- { readAt: snap.stamp, onStale: 'reject', wait: 'confirmed' },
36
+ const [report] = await ablo.weatherReports.load({ where: { id: reportId } });
37
+ if (!report) return { status: 'not_found' };
38
+
39
+ const updated = await ablo.weatherReports.claim(
40
+ reportId,
41
+ async (claimed) =>
42
+ ablo.weatherReports.update(
43
+ claimed.id,
44
+ { status: 'ready' },
45
+ { wait: 'confirmed' },
46
+ ),
47
+ { wait: false, action: 'marking_ready' },
47
48
  );
48
49
 
49
- return { status: 'done', task: updated };
50
+ return { status: 'ready', report: updated };
50
51
  }
51
52
  ```
52
53
 
53
- Advanced schema-less workers can use `Ablo({ apiKey }).agent(...)`, but that is
54
- not the first integration path.
54
+ Keep workers on the same schema-backed client as the app.
55
55
 
56
56
  ## UI
57
57
 
@@ -60,16 +60,14 @@ not the first integration path.
60
60
 
61
61
  import { useAblo } from '@abloatai/ablo/react';
62
62
 
63
- export function TaskRow({ task: serverTask }: Props) {
64
- const data = useAblo((ablo) => ablo.tasks.retrieve(serverTask.id)) ?? serverTask;
65
- const intents = useAblo((ablo) =>
66
- ablo.intents.list({ resource: 'tasks', id: serverTask.id }),
67
- ) ?? [];
68
- const agentActive = intents.some((i) => i.participantKind === 'agent');
63
+ export function ReportRow({ report: serverReport }: Props) {
64
+ const data = useAblo((ablo) => ablo.weatherReports.retrieve(serverReport.id)) ?? serverReport;
65
+ const active = useAblo((ablo) => ablo.weatherReports.claimState(serverReport.id));
66
+ const agentActive = active?.participantKind === 'agent';
69
67
 
70
68
  return (
71
69
  <div>
72
- <span>{data.title}</span>
70
+ <span>{data.location}</span>
73
71
  {agentActive ? <span>Agent is updating...</span> : null}
74
72
  </div>
75
73
  );
@@ -78,7 +76,7 @@ export function TaskRow({ task: serverTask }: Props) {
78
76
 
79
77
  ## Why It Works
80
78
 
81
- - Intents are visible on read and over the live stream.
82
- - `ifBusy: 'wait'` lets agents wait for active work instead of racing.
79
+ - Claims are visible through `claimState(id)` and over the live stream.
80
+ - `claim(id, work)` lets agents wait for active work instead of racing.
83
81
  - `readAt` plus `onStale: 'reject'` turns mid-flight changes into typed errors.
84
82
  - Audit rows tie each accepted write back to the run that caused it.
@@ -9,10 +9,10 @@ import { streamText, tool } from 'ai';
9
9
  import { z } from 'zod';
10
10
 
11
11
  const schema = defineSchema({
12
- tasks: model({
13
- title: schemaZ.string(),
14
- status: schemaZ.enum(['todo', 'doing', 'done']),
15
- summary: schemaZ.string().optional(),
12
+ weatherReports: model({
13
+ location: schemaZ.string(),
14
+ status: schemaZ.enum(['pending', 'ready']),
15
+ forecast: schemaZ.string().optional(),
16
16
  }),
17
17
  });
18
18
 
@@ -21,35 +21,35 @@ const ablo = Ablo({
21
21
  apiKey: process.env.ABLO_API_KEY,
22
22
  });
23
23
 
24
- const updateTask = tool({
25
- description: 'Update a task in the product database.',
24
+ const updateReport = tool({
25
+ description: 'Update a weather report in the product database.',
26
26
  inputSchema: z.object({
27
- taskId: z.string(),
28
- status: z.enum(['todo', 'doing', 'done']).optional(),
29
- summary: z.string().optional(),
27
+ reportId: z.string(),
28
+ status: z.enum(['pending', 'ready']).optional(),
29
+ forecast: z.string().optional(),
30
30
  }),
31
- execute: async ({ taskId, status, summary }) => {
31
+ execute: async ({ reportId, status, forecast }) => {
32
32
  await ablo.ready();
33
33
 
34
- const [task] = await ablo.tasks.load({ where: { id: taskId } });
35
- if (!task) return { ok: false, reason: 'not_found' };
34
+ const [report] = await ablo.weatherReports.load({ where: { id: reportId } });
35
+ if (!report) return { ok: false, reason: 'not_found' };
36
36
 
37
- const claim = ablo.tasks.intent(taskId);
38
- if (claim.current) await claim.whenFree({ timeout: 30_000 });
37
+ // claim is advisory: if another participant holds the row, it waits for
38
+ // them to finish and re-reads before entering the callback. Released when
39
+ // the callback returns or throws.
40
+ return ablo.weatherReports.claim(
41
+ reportId,
42
+ async (claimed) => {
43
+ // update is stale-guarded under the held claim
44
+ const updated = await ablo.weatherReports.update(claimed.id, {
45
+ status: status ?? claimed.status,
46
+ forecast: forecast ?? claimed.forecast,
47
+ });
39
48
 
40
- await claim.claim({ action: 'editing', field: 'status', ttl: '2m' });
41
- try {
42
- // update commits with the held claim and auto-releases on success
43
- const updated = await claim.update({
44
- status: status ?? task.status,
45
- summary: summary ?? task.summary,
46
- });
47
-
48
- return { ok: true, task: updated };
49
- } catch (err) {
50
- await claim.finish();
51
- throw err;
52
- }
49
+ return { ok: true, report: updated };
50
+ },
51
+ { action: 'editing', ttl: '2m' },
52
+ );
53
53
  },
54
54
  });
55
55
 
@@ -59,7 +59,7 @@ export async function POST(req: Request) {
59
59
  return streamText({
60
60
  model,
61
61
  messages,
62
- tools: { updateTask },
62
+ tools: { updateReport },
63
63
  }).toUIMessageStreamResponse();
64
64
  }
65
65
  ```
@@ -67,9 +67,8 @@ export async function POST(req: Request) {
67
67
  The important part is not the model provider. The important part is that the
68
68
  tool:
69
69
 
70
- - loads the latest task,
71
- - waits if another participant already holds the row,
72
- - acquires a claim before writing,
73
- - writes and auto-releases through the same handle,
70
+ - loads the latest weather report,
71
+ - claims the row (advisory serializes behind any current holder, then re-reads),
72
+ - writes through the normal stale-guarded `update`,
73
+ - releases the claim automatically when the callback returns or throws,
74
74
  - waits for server confirmation.
75
-