@abloatai/ablo 0.5.1 → 0.7.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 (129) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +248 -124
  3. package/dist/BaseSyncedStore.d.ts +3 -3
  4. package/dist/BaseSyncedStore.js +3 -3
  5. package/dist/api/index.d.ts +3 -3
  6. package/dist/api/index.js +1 -1
  7. package/dist/client/Ablo.d.ts +91 -93
  8. package/dist/client/Ablo.js +122 -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 +116 -90
  14. package/dist/client/createModelProxy.js +128 -128
  15. package/dist/client/index.d.ts +6 -7
  16. package/dist/client/index.js +4 -5
  17. package/dist/client/validateAbloOptions.js +5 -5
  18. package/dist/coordination/index.d.ts +6 -0
  19. package/dist/coordination/index.js +6 -0
  20. package/dist/coordination/schema.d.ts +329 -0
  21. package/dist/coordination/schema.js +209 -0
  22. package/dist/core/QueryView.d.ts +4 -1
  23. package/dist/core/QueryView.js +1 -1
  24. package/dist/core/index.d.ts +2 -0
  25. package/dist/core/index.js +7 -0
  26. package/dist/core/query-utils.d.ts +7 -10
  27. package/dist/core/query-utils.js +2 -3
  28. package/dist/errorCodes.d.ts +264 -0
  29. package/dist/errorCodes.js +251 -0
  30. package/dist/errors.d.ts +59 -14
  31. package/dist/errors.js +73 -12
  32. package/dist/index.d.ts +11 -9
  33. package/dist/index.js +8 -12
  34. package/dist/interfaces/index.d.ts +2 -10
  35. package/dist/mutators/Transaction.d.ts +2 -2
  36. package/dist/mutators/Transaction.js +2 -2
  37. package/dist/mutators/mutateActions.d.ts +44 -0
  38. package/dist/{react/useMutate.js → mutators/mutateActions.js} +11 -28
  39. package/dist/mutators/readerActions.d.ts +32 -0
  40. package/dist/{react/useReader.js → mutators/readerActions.js} +2 -18
  41. package/dist/policy/index.d.ts +1 -1
  42. package/dist/policy/index.js +1 -1
  43. package/dist/policy/types.d.ts +31 -0
  44. package/dist/policy/types.js +15 -0
  45. package/dist/query/types.d.ts +1 -1
  46. package/dist/react/AbloProvider.d.ts +13 -1
  47. package/dist/react/AbloProvider.js +14 -6
  48. package/dist/react/context.d.ts +4 -4
  49. package/dist/react/index.d.ts +4 -5
  50. package/dist/react/index.js +3 -7
  51. package/dist/react/useAblo.d.ts +14 -14
  52. package/dist/react/useAblo.js +26 -26
  53. package/dist/react/useIntent.d.ts +2 -2
  54. package/dist/react/useIntent.js +2 -2
  55. package/dist/react/useMutators.d.ts +1 -1
  56. package/dist/react/usePresence.d.ts +3 -3
  57. package/dist/react/usePresence.js +4 -4
  58. package/dist/react/useUndoScope.d.ts +1 -1
  59. package/dist/schema/ddl.d.ts +62 -0
  60. package/dist/schema/ddl.js +317 -0
  61. package/dist/schema/diff.d.ts +167 -0
  62. package/dist/schema/diff.js +280 -0
  63. package/dist/schema/field.d.ts +16 -19
  64. package/dist/schema/field.js +30 -17
  65. package/dist/schema/generate.d.ts +19 -0
  66. package/dist/schema/generate.js +87 -0
  67. package/dist/schema/index.d.ts +9 -3
  68. package/dist/schema/index.js +14 -2
  69. package/dist/schema/model.d.ts +87 -25
  70. package/dist/schema/model.js +33 -3
  71. package/dist/schema/relation.d.ts +17 -0
  72. package/dist/schema/roles.d.ts +148 -0
  73. package/dist/schema/roles.js +149 -0
  74. package/dist/schema/schema.d.ts +10 -69
  75. package/dist/schema/schema.js +58 -24
  76. package/dist/schema/select.d.ts +25 -0
  77. package/dist/schema/select.js +55 -0
  78. package/dist/schema/serialize.d.ts +96 -0
  79. package/dist/schema/serialize.js +231 -0
  80. package/dist/schema/sugar.d.ts +20 -3
  81. package/dist/schema/sugar.js +5 -1
  82. package/dist/schema/tenancy.d.ts +66 -0
  83. package/dist/schema/tenancy.js +58 -0
  84. package/dist/sync/HydrationCoordinator.d.ts +2 -0
  85. package/dist/sync/HydrationCoordinator.js +23 -17
  86. package/dist/sync/SyncWebSocket.d.ts +17 -0
  87. package/dist/sync/SyncWebSocket.js +46 -1
  88. package/dist/sync/awaitIntentGrant.d.ts +26 -0
  89. package/dist/sync/awaitIntentGrant.js +60 -0
  90. package/dist/sync/createIntentStream.d.ts +2 -1
  91. package/dist/sync/createIntentStream.js +89 -5
  92. package/dist/sync/createPresenceStream.js +1 -1
  93. package/dist/sync/participants.d.ts +2 -2
  94. package/dist/sync/participants.js +9 -18
  95. package/dist/types/global.d.ts +43 -52
  96. package/dist/types/global.js +16 -18
  97. package/dist/types/streams.d.ts +90 -42
  98. package/docs/api-keys.md +44 -0
  99. package/docs/api.md +72 -173
  100. package/docs/audit.md +5 -5
  101. package/docs/cli.md +212 -0
  102. package/docs/client-behavior.md +42 -43
  103. package/docs/coordination.md +343 -0
  104. package/docs/data-sources.md +16 -16
  105. package/docs/examples/agent-human.md +30 -32
  106. package/docs/examples/ai-sdk-tool.md +32 -33
  107. package/docs/examples/existing-python-backend.md +38 -36
  108. package/docs/examples/nextjs.md +24 -25
  109. package/docs/examples/scoped-agent.md +78 -0
  110. package/docs/examples/server-agent.md +20 -61
  111. package/docs/guarantees.md +34 -56
  112. package/docs/identity.md +529 -0
  113. package/docs/index.md +18 -24
  114. package/docs/integration-guide.md +130 -144
  115. package/docs/interaction-model.md +32 -95
  116. package/docs/mcp/claude-code.md +3 -3
  117. package/docs/mcp/cursor.md +1 -1
  118. package/docs/mcp/windsurf.md +1 -1
  119. package/docs/mcp.md +11 -26
  120. package/docs/quickstart.md +43 -49
  121. package/docs/react.md +74 -24
  122. package/docs/roadmap.md +17 -7
  123. package/llms.txt +34 -39
  124. package/package.json +8 -1
  125. package/dist/react/useMutate.d.ts +0 -83
  126. package/dist/react/useQuery.d.ts +0 -123
  127. package/dist/react/useQuery.js +0 -145
  128. package/dist/react/useReader.d.ts +0 -69
  129. package/docs/capabilities.md +0 -163
@@ -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
-
@@ -9,7 +9,7 @@ that need multiplayer now and agent-safe writes later.
9
9
 
10
10
  This also applies to any API-backed app, not only Python. A product like a YC
11
11
  company's existing dashboard can keep its current endpoint/service/database
12
- shape and migrate one coordinated resource at a time.
12
+ shape and migrate one coordinated model at a time.
13
13
 
14
14
  ```txt
15
15
  Browser UI
@@ -26,14 +26,14 @@ Browser UI
26
26
  Create a schema for the records that need realtime coordination.
27
27
 
28
28
  ```ts
29
- // web/ablo.schema.ts
29
+ // web/ablo/schema.ts
30
30
  import { defineSchema, model, z } from '@abloatai/ablo/schema';
31
31
 
32
32
  export const schema = defineSchema({
33
- tasks: model({
33
+ weatherReports: model({
34
34
  id: z.string(),
35
- title: z.string(),
36
- status: z.enum(['todo', 'doing', 'done']),
35
+ location: z.string(),
36
+ status: z.enum(['pending', 'ready']),
37
37
  updatedAt: z.string(),
38
38
  }),
39
39
  });
@@ -42,7 +42,7 @@ export const schema = defineSchema({
42
42
  ```ts
43
43
  // web/ablo.ts
44
44
  import Ablo from '@abloatai/ablo';
45
- import { schema } from './ablo.schema';
45
+ import { schema } from './ablo/schema';
46
46
 
47
47
  export const ablo = Ablo({
48
48
  schema,
@@ -51,14 +51,14 @@ export const ablo = Ablo({
51
51
  ```
52
52
 
53
53
  Mount the React provider near the app root so client components can subscribe to
54
- model resources without importing server credentials.
54
+ model clients without importing server credentials.
55
55
 
56
56
  ```tsx
57
57
  // web/app/providers.tsx
58
58
  'use client';
59
59
 
60
60
  import { AbloProvider } from '@abloatai/ablo/react';
61
- import { schema } from '@/ablo.schema';
61
+ import { schema } from '@/ablo/schema';
62
62
 
63
63
  export function Providers({ children }: { children: React.ReactNode }) {
64
64
  return <AbloProvider schema={schema}>{children}</AbloProvider>;
@@ -68,30 +68,32 @@ export function Providers({ children }: { children: React.ReactNode }) {
68
68
  ## 2. Add Live Reads In The UI
69
69
 
70
70
  Keep the first render backed by the existing Python endpoint. After that,
71
- subscribe to the same model resource Ablo writes through.
71
+ subscribe to the same model client Ablo writes through.
72
72
 
73
73
  ```tsx
74
74
  'use client';
75
75
 
76
76
  import { useAblo } from '@abloatai/ablo/react';
77
77
 
78
- export function TaskRow({ task: serverTask }: { task: Task }) {
79
- const task = useAblo((ablo) => ablo.tasks.retrieve(serverTask.id)) ?? serverTask;
80
- const intents = useAblo((ablo) =>
81
- ablo.intents.list({ resource: 'tasks', id: serverTask.id }),
82
- ) ?? [];
83
- const busy = intents.length > 0;
78
+ export function ReportRow({
79
+ report: serverReport,
80
+ }: {
81
+ report: { id: string; location: string; status: string };
82
+ }) {
83
+ const report = useAblo((ablo) => ablo.weatherReports.retrieve(serverReport.id)) ?? serverReport;
84
+ const active = useAblo((ablo) => ablo.weatherReports.claimState(serverReport.id));
85
+ const claimed = Boolean(active);
84
86
 
85
87
  return (
86
- <button disabled={busy || task.status === 'done'}>
87
- {busy ? 'Someone is editing' : task.title}
88
+ <button disabled={claimed || report.status === 'ready'}>
89
+ {claimed ? 'Someone is editing' : report.location}
88
90
  </button>
89
91
  );
90
92
  }
91
93
  ```
92
94
 
93
95
  No string model key is needed in the first example. The selector reads from
94
- `ablo.tasks`, so React uses the same model resource as writes and agents.
96
+ `ablo.weatherReports`, so React uses the same model client as writes and agents.
95
97
 
96
98
  ## 3. Add One Python Data Source Endpoint
97
99
 
@@ -120,7 +122,7 @@ import os
120
122
  import time
121
123
  from fastapi import APIRouter, HTTPException, Request
122
124
 
123
- from app.services.tasks import get_task, list_tasks, apply_task_operations
125
+ from app.services.reports import get_report, list_reports, apply_report_operations
124
126
 
125
127
  router = APIRouter()
126
128
 
@@ -160,15 +162,15 @@ async def ablo_source(request: Request):
160
162
  body = json.loads(raw_body)
161
163
 
162
164
  if body["type"] == "load":
163
- if body["model"] == "tasks":
164
- return {"row": await get_task(body["id"])}
165
+ if body["model"] == "weatherReports":
166
+ return {"row": await get_report(body["id"])}
165
167
 
166
168
  if body["type"] == "list":
167
- if body["model"] == "tasks":
168
- return {"rows": await list_tasks(body.get("query", {}))}
169
+ if body["model"] == "weatherReports":
170
+ return {"rows": await list_reports(body.get("query", {}))}
169
171
 
170
172
  if body["type"] == "commit":
171
- rows = await apply_task_operations(
173
+ rows = await apply_report_operations(
172
174
  operations=body["operations"],
173
175
  client_tx_id=body.get("clientTxId"),
174
176
  scope=body.get("scope", {}),
@@ -178,7 +180,7 @@ async def ablo_source(request: Request):
178
180
  raise HTTPException(status_code=400, detail="unsupported request")
179
181
  ```
180
182
 
181
- `apply_task_operations` should reuse the same transaction and validation logic
183
+ `apply_report_operations` should reuse the same transaction and validation logic
182
184
  the existing Python endpoints already use. Dedupe by `clientTxId` so retries are
183
185
  safe.
184
186
 
@@ -193,20 +195,20 @@ Button -> Python endpoint -> service -> database
193
195
  Target button path:
194
196
 
195
197
  ```txt
196
- Button -> ablo.tasks.update(...)
198
+ Button -> ablo.weatherReports.update(...)
197
199
  Ablo -> Python Data Source endpoint
198
200
  Python service -> database
199
201
  Ablo -> realtime fanout and receipt
200
202
  ```
201
203
 
202
- The app does not need a flag-day rewrite. Move one resource at a time.
204
+ The app does not need a flag-day rewrite. Move one model at a time.
203
205
 
204
206
  ```ts
205
- const snap = ablo.snapshot({ tasks: taskId });
207
+ const snap = ablo.snapshot({ weatherReports: reportId });
206
208
 
207
- await ablo.tasks.update(
208
- taskId,
209
- { status: 'done' },
209
+ await ablo.weatherReports.update(
210
+ reportId,
211
+ { status: 'ready' },
210
212
  { readAt: snap.stamp, onStale: 'reject', wait: 'confirmed' },
211
213
  );
212
214
  ```
@@ -235,12 +237,12 @@ and timestamp. If the change originated from an Ablo commit, include the same
235
237
  Agents use the same model API as the UI:
236
238
 
237
239
  ```ts
238
- const [task] = await ablo.tasks.load({ where: { id: taskId } });
239
- const snap = ablo.snapshot({ tasks: taskId });
240
+ const [report] = await ablo.weatherReports.load({ where: { id: reportId } });
241
+ const snap = ablo.snapshot({ weatherReports: reportId });
240
242
 
241
- await ablo.tasks.update(
242
- taskId,
243
- { status: 'done' },
243
+ await ablo.weatherReports.update(
244
+ reportId,
245
+ { status: 'ready' },
244
246
  { readAt: snap.stamp, onStale: 'reject', wait: 'confirmed' },
245
247
  );
246
248
  ```
@@ -7,11 +7,11 @@ Server Components, and live client subscriptions.
7
7
 
8
8
  ```txt
9
9
  app/
10
- tasks/
10
+ reports/
11
11
  [id]/
12
12
  page.tsx # RSC: retrieve + render
13
13
  actions.ts # Server Action: schema update with stale-state check
14
- TaskEditor.tsx # Client: live updates
14
+ ReportEditor.tsx # Client: live updates
15
15
  lib/
16
16
  ablo.ts # Schema-backed Ablo client for server actions
17
17
  ```
@@ -19,40 +19,41 @@ app/
19
19
  ## RSC Initial Render
20
20
 
21
21
  ```tsx
22
- // app/tasks/[id]/page.tsx
22
+ // app/reports/[id]/page.tsx
23
23
  import { ablo } from '@/lib/ablo';
24
24
 
25
- export default async function TaskPage({
25
+ export default async function ReportPage({
26
26
  params,
27
27
  }: { params: { id: string } }) {
28
28
  await ablo.ready();
29
- const [task] = await ablo.tasks.load({ where: { id: params.id } });
30
- if (!task) return null;
29
+ const [report] = await ablo.weatherReports.load({ where: { id: params.id } });
30
+ if (!report) return null;
31
31
 
32
- return <TaskEditor task={task} />;
32
+ return <ReportEditor report={report} />;
33
33
  }
34
34
  ```
35
35
 
36
36
  ## Server Action Commit
37
37
 
38
38
  ```ts
39
- // app/tasks/[id]/actions.ts
39
+ // app/reports/[id]/actions.ts
40
40
  'use server';
41
41
 
42
42
  import { ablo } from '@/lib/ablo';
43
43
 
44
- export async function markDone(id: string) {
45
- const busy = ablo.intents.list({ resource: 'tasks', id });
46
- if (busy.length > 0) return { status: 'busy', intents: busy };
47
-
48
- const snap = ablo.snapshot({ tasks: id });
49
- const task = await ablo.tasks.update(
44
+ export async function markReady(id: string) {
45
+ const report = await ablo.weatherReports.claim(
50
46
  id,
51
- { status: 'done' },
52
- { readAt: snap.stamp, onStale: 'reject', wait: 'confirmed' },
47
+ async (claimed) =>
48
+ ablo.weatherReports.update(
49
+ claimed.id,
50
+ { status: 'ready' },
51
+ { wait: 'confirmed' },
52
+ ),
53
+ { wait: false, action: 'marking_ready' },
53
54
  );
54
55
 
55
- return { status: 'done', task };
56
+ return { status: 'ready', report };
56
57
  }
57
58
  ```
58
59
 
@@ -66,16 +67,14 @@ rejects. The action can re-fetch and ask the user to retry.
66
67
 
67
68
  import { useAblo } from '@abloatai/ablo/react';
68
69
 
69
- export function TaskEditor({ task: serverTask }: Props) {
70
- const data = useAblo((ablo) => ablo.tasks.retrieve(serverTask.id)) ?? serverTask;
71
- const intents = useAblo((ablo) =>
72
- ablo.intents.list({ resource: 'tasks', id: serverTask.id }),
73
- ) ?? [];
74
- const busy = intents.length > 0;
70
+ export function ReportEditor({ report: serverReport }: Props) {
71
+ const data = useAblo((ablo) => ablo.weatherReports.retrieve(serverReport.id)) ?? serverReport;
72
+ const active = useAblo((ablo) => ablo.weatherReports.claimState(serverReport.id));
73
+ const claimed = Boolean(active);
75
74
 
76
75
  return (
77
- <button disabled={busy || data.status === 'done'}>
78
- {busy ? 'Someone is editing' : 'Mark done'}
76
+ <button disabled={claimed || data.status === 'ready'}>
77
+ {claimed ? 'Someone is editing' : 'Mark ready'}
79
78
  </button>
80
79
  );
81
80
  }
@@ -0,0 +1,78 @@
1
+ # Agent Scoped to One Deck
2
+
3
+ An agent that edits **one deck** and receives realtime updates for **only that
4
+ deck** — not the whole org. Shows the sync-group model end to end: a scope root,
5
+ a containment (`parent`) edge, identity roles, and the model-form `scope`.
6
+
7
+ See [Identity & Sync Groups](../identity.md) for the full reference.
8
+
9
+ ## 1. Schema — declare the scope, once
10
+
11
+ ```ts
12
+ import { defineSchema, identityRole, model, relation, z } from '@abloatai/ablo/schema';
13
+
14
+ export const schema = defineSchema(
15
+ {
16
+ // A scope root: deck rows form the group `deck:<id>`.
17
+ decks: model(
18
+ { title: z.string() },
19
+ {},
20
+ { orgScoped: true, scope: 'deck' },
21
+ ),
22
+ // A child: no group of its own. It inherits its deck's group via the
23
+ // `parent` edge, so a slide write reaches everyone viewing the deck —
24
+ // even a slide edit that doesn't touch `deckId` (routing is keyed on the
25
+ // row's id, not the changed columns).
26
+ slides: model(
27
+ { deckId: z.string(), body: z.string() },
28
+ { deck: relation.belongsTo('decks', 'deckId', { parent: true }) },
29
+ { orgScoped: true },
30
+ ),
31
+ },
32
+ {
33
+ // Humans get their full org scope automatically from these.
34
+ identityRoles: [
35
+ identityRole({ kind: 'org', source: 'organizationId' }),
36
+ identityRole({ kind: 'user', source: 'userId' }),
37
+ ],
38
+ },
39
+ );
40
+ ```
41
+
42
+ ## 2. Dispatch — narrow the agent to the deck it's working on
43
+
44
+ The agent inherits the triggering user's identity (its ceiling) and is narrowed
45
+ to one deck (the floor). You pass the **model and id** — never a `deck:<id>`
46
+ string; the engine builds the group from the model's `scope`.
47
+
48
+ ```ts
49
+ const ablo = Ablo({
50
+ schema,
51
+ url: process.env.ABLO_URL,
52
+ kind: 'agent',
53
+ agentId: 'agent:slide-writer',
54
+ userId: triggeringUser.id, // ceiling: can't exceed this user's reach
55
+ organizationId: triggeringUser.organizationId,
56
+ scope: { decks: deckId }, // floor: just this deck → deck:<deckId>
57
+ });
58
+ await ablo.ready();
59
+ ```
60
+
61
+ ## 3. Write — it fans out to everyone on that deck
62
+
63
+ ```ts
64
+ // Other participants subscribed to deck:<deckId> — the human in the editor,
65
+ // a reviewer agent — receive this delta in realtime. Participants on other
66
+ // decks never see it.
67
+ await ablo.slides.update(slideId, { body: 'Q4 revenue up 12% YoY' });
68
+ ```
69
+
70
+ The slide's delta is stamped `deck:<deckId>` (derived server-side from the
71
+ slide → deck `parent` edge), so it reaches the deck's audience authoritatively —
72
+ regardless of which groups the agent happened to subscribe to. And `scope` only
73
+ ever *narrows*: the agent can't reach a deck its triggering user couldn't.
74
+
75
+ ## See also
76
+
77
+ - [Identity & Sync Groups](../identity.md) — the full scope / parent / grants model.
78
+ - [Agent + Human](./agent-human.md) — yielding when a human edits the same row.
@@ -8,10 +8,10 @@ import Ablo from '@abloatai/ablo';
8
8
  import { defineSchema, model, z } from '@abloatai/ablo/schema';
9
9
 
10
10
  const schema = defineSchema({
11
- tasks: model({
12
- title: z.string(),
13
- status: z.enum(['todo', 'doing', 'done']),
14
- summary: z.string().optional(),
11
+ weatherReports: model({
12
+ location: z.string(),
13
+ status: z.enum(['pending', 'ready']),
14
+ forecast: z.string().optional(),
15
15
  }),
16
16
  });
17
17
 
@@ -20,67 +20,26 @@ const ablo = Ablo({
20
20
  apiKey: process.env.ABLO_API_KEY,
21
21
  });
22
22
 
23
- export async function completeTask(taskId: string) {
23
+ export async function completeReport(reportId: string) {
24
24
  await ablo.ready();
25
25
 
26
- const [task] = await ablo.tasks.load({ where: { id: taskId } });
27
- if (!task) return { status: 'not_found' };
28
-
29
- const busy = ablo.intents.list({ resource: 'tasks', id: taskId });
30
- if (busy.length > 0) {
31
- return { status: 'busy', intents: busy };
32
- }
33
-
34
- const snap = ablo.snapshot({ tasks: taskId });
35
- const updated = await ablo.tasks.update(
36
- taskId,
37
- { status: 'done' },
38
- { readAt: snap.stamp, onStale: 'reject', wait: 'confirmed' },
26
+ const [report] = await ablo.weatherReports.load({ where: { id: reportId } });
27
+ if (!report) return { status: 'not_found' };
28
+
29
+ const updated = await ablo.weatherReports.claim(
30
+ reportId,
31
+ async (claimed) =>
32
+ ablo.weatherReports.update(
33
+ claimed.id,
34
+ { status: 'ready' },
35
+ { wait: 'confirmed' },
36
+ ),
37
+ { wait: false, action: 'completing' },
39
38
  );
40
39
 
41
- return { status: 'done', task: updated };
40
+ return { status: 'ready', report: updated };
42
41
  }
43
42
  ```
44
43
 
45
- ## Advanced Schema-Less Run
46
-
47
- Use `agent.run(...)` when the worker intentionally cannot import the app schema.
48
- It creates the run envelope and returns `done`, `failed`, or `cancelled`.
49
-
50
- ```ts
51
- const api = Ablo({ apiKey: process.env.ABLO_API_KEY });
52
-
53
- const result = await api.agent('task-writer', {
54
- can: ['tasks.retrieve', 'tasks.update'],
55
- syncGroups: ['workspace:acme'],
56
- }).run(
57
- {
58
- prompt: 'Mark task_123 done.',
59
- surface: 'agent_worker',
60
- },
61
- async ({ resource }) => {
62
- const tasks = resource<{ title: string; status: string }>('tasks');
63
-
64
- const { data, stamp, intents } = await tasks.retrieve('task_123', {
65
- ifBusy: 'return',
66
- });
67
-
68
- if (intents.length > 0) {
69
- return { skipped: true, reason: 'busy' };
70
- }
71
-
72
- return tasks.update(
73
- 'task_123',
74
- { status: 'done' },
75
- { readAt: stamp, onStale: 'reject', wait: 'confirmed' },
76
- );
77
- },
78
- );
79
-
80
- if (result.status === 'failed') throw result.error;
81
- if (result.status === 'cancelled') return;
82
- ```
83
-
84
- Use the schema-backed version first. The schema-less version is for generic
85
- agent infrastructure, MCP routes, and platform code.
86
-
44
+ Use the schema-backed version for server agents so the worker, app, and React UI
45
+ share the same model methods.