@abloatai/ablo 0.51.0 → 0.53.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 (42) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +220 -26
  3. package/NOTICE +1 -1
  4. package/docs/agents.md +28 -28
  5. package/docs/api-keys.md +30 -3
  6. package/docs/api.md +32 -3
  7. package/docs/cli.md +3 -3
  8. package/docs/client-behavior.md +1 -1
  9. package/docs/concurrency-convention.md +7 -7
  10. package/docs/context.md +11 -11
  11. package/docs/coordination.md +19 -19
  12. package/docs/customer-organizations.md +215 -0
  13. package/docs/data-sources.md +3 -3
  14. package/docs/debugging.md +8 -8
  15. package/docs/examples/agent-human.md +17 -17
  16. package/docs/examples/ai-sdk-tool.md +6 -6
  17. package/docs/examples/existing-python-backend.md +1 -1
  18. package/docs/examples/nextjs.md +15 -15
  19. package/docs/examples/scoped-agent.md +10 -10
  20. package/docs/examples/server-agent.md +14 -14
  21. package/docs/groups.md +12 -12
  22. package/docs/how-it-works.md +7 -7
  23. package/docs/idempotency.md +4 -4
  24. package/docs/identity.md +45 -38
  25. package/docs/index.md +2 -1
  26. package/docs/integration-guide.md +1 -1
  27. package/docs/integrations/inngest.md +2 -2
  28. package/docs/integrations/temporal.md +3 -3
  29. package/docs/integrations.md +3 -3
  30. package/docs/react.md +4 -4
  31. package/docs/sessions.md +21 -16
  32. package/docs/webhooks.md +2 -2
  33. package/examples/README.md +6 -6
  34. package/examples/agent-turn.ts +13 -13
  35. package/examples/data-source/README.md +3 -3
  36. package/examples/data-source/customer-server.ts +23 -23
  37. package/examples/data-source/run.ts +8 -8
  38. package/examples/data-source/schema.ts +2 -2
  39. package/examples/lease-outlives-the-machine.ts +66 -0
  40. package/examples/tsconfig.json +4 -10
  41. package/llms.txt +11 -0
  42. package/package.json +4 -4
package/docs/context.md CHANGED
@@ -21,13 +21,13 @@ import { generateText } from 'ai';
21
21
  const ctx = await context({
22
22
  ablo,
23
23
  data: {
24
- task: ablo.tasks.get({ id: taskId }),
25
- documents: ablo.documents.list({ where: { taskId } }),
26
- memory: loadMemories(taskId),
24
+ record: ablo.records.get({ id: recordId }),
25
+ records: ablo.records.list({ where: { recordId } }),
26
+ memory: loadMemories(recordId),
27
27
  },
28
28
  });
29
29
 
30
- if (!ctx.data.task) throw new Error('Task not found');
30
+ if (!ctx.data.record) throw new Error('Record not found');
31
31
 
32
32
  const result = await generateText({
33
33
  model,
@@ -35,8 +35,8 @@ const result = await generateText({
35
35
  tools,
36
36
  });
37
37
 
38
- await ablo.tasks.update({
39
- id: ctx.data.task.id,
38
+ await ablo.records.update({
39
+ id: ctx.data.record.id,
40
40
  data: parseTaskUpdate(result.text),
41
41
  reads: ctx.reads,
42
42
  });
@@ -80,7 +80,7 @@ This distinction is visible in `sources`:
80
80
  ```ts
81
81
  ctx.sources;
82
82
  // [
83
- // { key: 'task', kind: 'ablo', guarantee: 'guardable', cursor: 42 },
83
+ // { key: 'record', kind: 'ablo', guarantee: 'guardable', cursor: 42 },
84
84
  // { key: 'memory', kind: 'value', guarantee: 'informational', cursor: null },
85
85
  // ]
86
86
  ```
@@ -89,7 +89,7 @@ A top-level value may contain both kinds. It is then marked `mixed` and only
89
89
  its exact Ablo rows appear in `ctx.reads`:
90
90
 
91
91
  ```ts
92
- // data: { briefing: { task, memory } }
92
+ // data: { briefing: { record, memory } }
93
93
  // sources: [
94
94
  // { key: 'briefing', kind: 'mixed', guarantee: 'partial', cursor: 42 },
95
95
  // ]
@@ -108,10 +108,10 @@ Reducto, or another system behind their own interfaces.
108
108
  const ctx = await context({
109
109
  ablo,
110
110
  data: {
111
- task: ablo.tasks.get({ id: taskId }),
111
+ record: ablo.records.get({ id: recordId }),
112
112
  memory: loadMemories({ query, userId }),
113
113
  related: findRelatedChunks({ projectId, query }),
114
- evidence: extractEvidence({ documentId }),
114
+ evidence: extractEvidence({ recordId }),
115
115
  },
116
116
  });
117
117
  ```
@@ -141,7 +141,7 @@ await generateText({
141
141
  model,
142
142
  messages: [
143
143
  ...history,
144
- contextMessage(ctx, { include: ['task', 'documents', 'memory'] }),
144
+ contextMessage(ctx, { include: ['record', 'documents', 'memory'] }),
145
145
  ],
146
146
  tools,
147
147
  });
@@ -9,7 +9,7 @@ meaning. Choose the narrowest one that matches the operation.
9
9
  |---|---|---|
10
10
  | Set an independent value | `update({ id, data })` | Last-write-wins when no claim applies. |
11
11
  | Compute a value from the current row | `update(id, current => next)` | Re-reads and retries if the row changes concurrently. |
12
- | Write only if earlier rows are still current | `reads: [task, policy]` | Rejects when an explicitly named dependency changed. |
12
+ | Write only if earlier rows are still current | `reads: [record, policy]` | Rejects when an explicitly named dependency changed. |
13
13
  | Read, call a model, then write | `claim({ id })` | Other participants cannot write the claimed target by default until your claim ends. |
14
14
 
15
15
  **If a model call sits between the read and the write, take a claim.** A stale
@@ -25,16 +25,16 @@ does not carry a stale premise. It is intentionally last-write-wins.
25
25
  Pass the exact rows that produced a decision on the write:
26
26
 
27
27
  ```ts
28
- const task = await ablo.tasks.get({ id: taskId });
28
+ const record = await ablo.records.get({ id: recordId });
29
29
  const policy = await ablo.policies.get({ id: policyId });
30
- if (!task || !policy) throw new Error('required input is missing');
30
+ if (!record || !policy) throw new Error('required input is missing');
31
31
 
32
- const result = await model({ task, policy });
32
+ const result = await model({ record, policy });
33
33
 
34
- await ablo.tasks.update({
35
- id: task.id,
34
+ await ablo.records.update({
35
+ id: record.id,
36
36
  data: result,
37
- reads: [task, policy],
37
+ reads: [record, policy],
38
38
  });
39
39
  ```
40
40
 
@@ -57,7 +57,7 @@ When the next value is a function of the current one, pass an updater rather
57
57
  than fixed data:
58
58
 
59
59
  ```ts
60
- const document = await ablo.documents.update(documentId, (current) => ({
60
+ const document = await ablo.records.update(recordId, (current) => ({
61
61
  revision: current.revision + 1,
62
62
  content: revise(current.content),
63
63
  }));
@@ -75,8 +75,8 @@ effect inside it.
75
75
  You can bound or cancel reconciliation:
76
76
 
77
77
  ```ts
78
- await ablo.documents.update(
79
- documentId,
78
+ await ablo.records.update(
79
+ recordId,
80
80
  (current) => ({ revision: current.revision + 1 }),
81
81
  { retries: 8, signal: request.signal },
82
82
  );
@@ -184,8 +184,8 @@ protect the row version rather than a participant-held claim.
184
184
  For deduplicated jobs, skip work when another participant already owns it:
185
185
 
186
186
  ```ts
187
- const claim = await ablo.tasks.claim({
188
- id: taskId,
187
+ const claim = await ablo.records.claim({
188
+ id: recordId,
189
189
  contention: { mode: 'skip' },
190
190
  });
191
191
 
@@ -201,8 +201,8 @@ try {
201
201
  To wait with limits, keep the policy together:
202
202
 
203
203
  ```ts
204
- const claim = await ablo.tasks.claim({
205
- id: taskId,
204
+ const claim = await ablo.records.claim({
205
+ id: recordId,
206
206
  contention: {
207
207
  mode: 'wait',
208
208
  maxDepth: 3,
@@ -217,9 +217,9 @@ const claim = await ablo.tasks.claim({
217
217
  Narrow a claim when independent fields may be edited concurrently:
218
218
 
219
219
  ```ts
220
- await using claim = await ablo.tasks.claim({
221
- id: taskId,
222
- fields: (task) => task.status,
220
+ await using claim = await ablo.records.claim({
221
+ id: recordId,
222
+ fields: (record) => record.status,
223
223
  });
224
224
  ```
225
225
 
@@ -239,8 +239,8 @@ The target options are:
239
239
  Read current claim state without blocking:
240
240
 
241
241
  ```ts
242
- const holder = ablo.tasks.claim.state({ id: taskId });
243
- const queue = ablo.tasks.claim.queue({ id: taskId });
242
+ const holder = ablo.records.claim.state({ id: recordId });
243
+ const queue = ablo.records.claim.queue({ id: recordId });
244
244
  ```
245
245
 
246
246
  Use this state for presence and progress UI. Do not use an observed `null` as a
@@ -0,0 +1,215 @@
1
+ # Customer Organizations
2
+
3
+ > Serve many customer organizations from one backend and one shared schema without weakening tenant isolation.
4
+
5
+ An application serving many customers has two independent choices: where
6
+ customer data is isolated and where the schema is authored. Ablo lets every
7
+ customer keep a hard organization boundary while all of them use the schema
8
+ pushed once by the owning project.
9
+
10
+ ## Choose the customer boundary first
11
+
12
+ | Customer model | Isolation guarantee | Choose it when |
13
+ |---|---|---|
14
+ | One Ablo organization with customer scope roots | Whatever read boundary every model declares in `policy` | Cross-customer reads are intentional, or every model is explicitly and continuously policy-partitioned |
15
+ | One Ablo organization per customer | Structural organization filtering and RLS on every row | Customers must stay isolated even when a model has no customer-specific policy |
16
+
17
+ Sync-groups decide which changes are delivered. They do not, by themselves,
18
+ authorize HTTP reads. If you cannot audit matching policies across every model,
19
+ use one Ablo organization per customer.
20
+
21
+ The rest of this guide uses organization-per-customer: one trusted backend
22
+ holds one dedicated mint key, every customer has an `organizationId`, and each
23
+ user session names that customer organization.
24
+
25
+ ## What you need
26
+
27
+ - An owning project containing the schema every customer uses.
28
+ - The schema pushed to that project's production root.
29
+ - A server-side `sk_` carrying only `ephemeral:mint-any-org`.
30
+ - A customer `organizationId` resolved from your authenticated application
31
+ membership, never accepted unchecked from the browser.
32
+ - A model-by-model `can` grant for the UI being opened.
33
+
34
+ The cross-organization key is a minting credential, not a tenant-data
35
+ credential. Keep it in a secret manager and expose only your own authenticated
36
+ session endpoint.
37
+
38
+ ## Mint on your backend
39
+
40
+ Create the Ablo client once in server-only code:
41
+
42
+ ```ts
43
+ import Ablo from '@abloatai/ablo';
44
+ import { schema } from '@/ablo/schema';
45
+
46
+ export const customerSessions = Ablo({
47
+ schema,
48
+ apiKey: process.env.ABLO_API_KEY,
49
+ });
50
+ ```
51
+
52
+ After your application authenticates the user, resolve the customer from that
53
+ trusted membership and mint the session:
54
+
55
+ ```ts
56
+ import { credentialEndpointSuccessSchema } from '@abloatai/ablo/auth';
57
+ import { customerSessions } from '@/ablo/customer-sessions';
58
+
59
+ export async function POST() {
60
+ const member = await requireSignedInCustomerMember();
61
+
62
+ const session = await customerSessions.sessions.create({
63
+ user: { id: member.userId },
64
+ organizationId: member.abloOrganizationId,
65
+ can: {
66
+ projects: ['read'],
67
+ records: ['read', 'create', 'update'],
68
+ },
69
+ });
70
+
71
+ return Response.json(
72
+ credentialEndpointSuccessSchema.parse({
73
+ token: session.token,
74
+ expiresAt: session.expiresAt,
75
+ credentialKind: 'ephemeral',
76
+ }),
77
+ { headers: { 'Cache-Control': 'no-store' } },
78
+ );
79
+ }
80
+ ```
81
+
82
+ Do not take `organizationId` directly from request JSON. A signed-in user could
83
+ replace it with another customer's id. Derive it from the server-side membership
84
+ you just authenticated.
85
+
86
+ ## Connect the browser
87
+
88
+ The browser knows only your session endpoint. It never receives the
89
+ cross-organization key:
90
+
91
+ ```tsx
92
+ 'use client';
93
+
94
+ import Ablo from '@abloatai/ablo';
95
+ import { AbloProvider } from '@abloatai/ablo/react';
96
+ import { schema } from '@/ablo/schema';
97
+
98
+ const ablo = Ablo({
99
+ schema,
100
+ authEndpoint: '/api/ablo-session',
101
+ });
102
+
103
+ export function Providers({ children }: { children: React.ReactNode }) {
104
+ return <AbloProvider client={ablo}>{children}</AbloProvider>;
105
+ }
106
+ ```
107
+
108
+ The client re-mints before expiry. Your endpoint should return
109
+ `session_expired` only when the application's own login is gone; network and
110
+ server failures are transient and must not sign the user out.
111
+
112
+ ## Schema and data stay on different axes
113
+
114
+ For a cross-organization mint, Ablo derives the schema binding from the owning
115
+ key automatically:
116
+
117
+ ```text
118
+ owning key organization/project -> schema shape
119
+ customer organization -> rows, RLS, database, sync groups
120
+ session can -> allowed model operations
121
+ model policy -> allowed reads inside that organization
122
+ ```
123
+
124
+ Most applications should not pass a schema option. An explicit override exists for
125
+ migrations or advanced routing:
126
+
127
+ ```ts
128
+ await customerSessions.sessions.create({
129
+ user: { id: member.userId },
130
+ organizationId: member.abloOrganizationId,
131
+ schemaProject: {
132
+ organizationId: schemaOwnerOrganizationId,
133
+ projectId: migratingSchemaProjectId,
134
+ },
135
+ can: { records: ['read', 'update'] },
136
+ });
137
+ ```
138
+
139
+ Both schema coordinates move together; customer data does not move with them.
140
+
141
+ ## Customer lifecycle checklist
142
+
143
+ When onboarding a customer:
144
+
145
+ 1. Provision or resolve its Ablo organization and store that immutable id on
146
+ your customer record.
147
+ 2. Connect that organization's production data source if it owns a separate
148
+ database.
149
+ 3. Keep the shared schema in the owning project; do not copy it into every
150
+ customer organization.
151
+ 4. Mint a test user session through the same backend route production uses.
152
+ 5. Verify one known read and write in the customer organization before enabling
153
+ the integration.
154
+
155
+ When offboarding, stop minting immediately, revoke active sessions when an
156
+ immediate cutoff is required, and retire the customer's data-source access
157
+ through the control-plane process you use for provisioning.
158
+
159
+ ## Security checklist
160
+
161
+ - Store the cross-organization key only in the backend secret manager.
162
+ - Give it only `ephemeral:mint-any-org`; do not combine minting with schema or
163
+ data authority.
164
+ - Resolve `organizationId` from authenticated membership server-side.
165
+ - Keep `can` to the smallest model/verb set the UI needs.
166
+ - Use organization-per-customer when a missing model policy must not expose
167
+ another customer's rows.
168
+ - Rotate the cross-organization key on a schedule and after personnel or
169
+ infrastructure changes.
170
+ - Record target organization, user, session id, and request id without logging
171
+ plaintext credentials.
172
+
173
+ ## Troubleshooting
174
+
175
+ ### The mint is forbidden
176
+
177
+ The presenting credential must be a secret `sk_` with
178
+ `ephemeral:mint-any-org`. A normal project key can mint users into its own
179
+ organization but cannot name another one. Run `npx ablo whoami --json` in the
180
+ backend environment to confirm which project and branch the configured key
181
+ actually belongs to; the command never prints the full secret.
182
+
183
+ ### The session mints but bootstrap is empty
184
+
185
+ Confirm that the cross-organization key belongs to the project where the schema
186
+ was pushed, and that the customer's organization has its data source/root
187
+ branch ready. Do not copy the schema into the customer organization. If you
188
+ supplied `schemaProject`, remove it unless you intentionally override the
189
+ owning-key default.
190
+
191
+ ### The mint reports an unknown model
192
+
193
+ Every model named by `can` must exist in the active shared schema. Check the
194
+ model key spelling against the schema used to construct `customerSessions`, then
195
+ push that schema to the key's branch deliberately.
196
+
197
+ ### Data appears under the wrong customer
198
+
199
+ Inspect the membership-to-`organizationId` lookup in your backend route first.
200
+ The schema project never selects the data tenant. The `organizationId` passed to
201
+ `sessions.create` does, and it must come from trusted server-side membership.
202
+
203
+ ### Realtime looks isolated but an HTTP read is too broad
204
+
205
+ Sync-groups route changes; they are not a read policy. Add or correct the
206
+ model's `policy`, or move customers to separate Ablo organizations when the
207
+ boundary must hold structurally across every model.
208
+
209
+ ## Related guides
210
+
211
+ - [Sessions](./sessions.md) — session lifecycle, refresh, revocation, and grants.
212
+ - [API Keys](./api-keys.md) — credential classes, scopes, inspection, and rotation.
213
+ - [Identity & Sync Groups](./identity.md) — participant delivery groups versus model read policy.
214
+ - [Connect Your Database](./data-sources.md) — customer-owned database setup.
215
+ - [Deployment](./deployment.md) — production schema and database rollout order.
@@ -149,8 +149,8 @@ own schema:
149
149
  ABLO_API_KEY="$MAIL_KEY" DATABASE_URL="$PRODUCTION_URL" \
150
150
  npx ablo connect apply --schema mail --yes
151
151
 
152
- ABLO_API_KEY="$SLIDES_KEY" DATABASE_URL="$PRODUCTION_URL" \
153
- npx ablo connect apply --schema slides --yes
152
+ ABLO_API_KEY="$ENTRIES_KEY" DATABASE_URL="$PRODUCTION_URL" \
153
+ npx ablo connect apply --schema entries --yes
154
154
  ```
155
155
 
156
156
  For a Neon or Supabase preview branch, use that branch's direct URL and keep the
@@ -380,7 +380,7 @@ export const ablo = Ablo({
380
380
  The API key still selects the project and branch; during `ready()` Ablo asks the
381
381
  server what the key actually targets and refuses startup when either coordinate
382
382
  differs. `ablo dev` writes all three values together, so accidentally exporting
383
- a slides key into the mail app—or a mail development key into production—fails
383
+ a entries key into the mail app—or a mail development key into production—fails
384
384
  before any read, write, or subscription begins.
385
385
 
386
386
  The Ablo schema describes **only your synced, collaborative models** — the rows
package/docs/debugging.md CHANGED
@@ -72,12 +72,12 @@ Precedence: an explicit `logLevel` wins, then `debug: true` (⇒ `debug`), then
72
72
  These lines (all at `info`) let you watch the handover you built:
73
73
 
74
74
  ```
75
- [Ablo] claim: requesting documents:doc_42 for "editing" (will queue if contended)
76
- [Ablo] claim: queued for documents:doc_42 — position 2 of 3, waiting
75
+ [Ablo] claim: requesting records:doc_42 for "editing" (will queue if contended)
76
+ [Ablo] claim: queued for records:doc_42 — position 2 of 3, waiting
77
77
  [Ablo] claim: granted 7f3c… — your turn (waited in queue)
78
- [Ablo] claim: rejected documents:doc_42 — held by agent_writer
79
- [Ablo] claim: lost documents:doc_42 (preempted or expired)
80
- [Ablo] claim: released documents:doc_42
78
+ [Ablo] claim: rejected records:doc_42 — held by agent_writer
79
+ [Ablo] claim: lost records:doc_42 (preempted or expired)
80
+ [Ablo] claim: released records:doc_42
81
81
  ```
82
82
 
83
83
  Read it as the lifecycle of one claim:
@@ -189,7 +189,7 @@ function ActivityFeed({ log }: { log: ClaimLog }) {
189
189
  For **"who holds *this* row right now"** (a badge, not a feed), don't use `ClaimLog` — read the reactive claim state directly. It re-renders on change with no extra wiring:
190
190
 
191
191
  ```tsx
192
- const holder = useAblo((ablo) => ablo.documents.claim.state({ id })); // Claim | null
192
+ const holder = useAblo((ablo) => ablo.records.claim.state({ id })); // Claim | null
193
193
  ```
194
194
 
195
195
  See [React](./react.md) and [Coordination](./coordination.md) for the claim-read APIs.
@@ -265,7 +265,7 @@ Every rejected live commit carries `requestId` on the thrown error and
265
265
  import { AbloError } from '@abloatai/ablo';
266
266
 
267
267
  try {
268
- await ablo.documents.create({
268
+ await ablo.records.create({
269
269
  data,
270
270
  });
271
271
  } catch (error) {
@@ -289,7 +289,7 @@ evidence that the replication source has no history.
289
289
  Use:
290
290
 
291
291
  ```ts
292
- await ablo.documents.list({ type: 'complete' });
292
+ await ablo.records.list({ type: 'complete' });
293
293
  ```
294
294
 
295
295
  `type: 'complete'` waits for a server round trip and returns the confirmed
@@ -2,16 +2,16 @@
2
2
 
3
3
  > An agent that yields the row when a person is already holding it.
4
4
 
5
- A task-writing agent that yields when a person is editing the same task.
5
+ A record-writing agent that yields when a person is editing the same record.
6
6
 
7
7
  ## Scenario
8
8
 
9
- The same tasks are edited by agents and by the people watching them. They must
9
+ The same records are edited by agents and by the people watching them. They must
10
10
  not collide:
11
11
 
12
12
  - If a person already holds the row, the agent yields instead of fighting for it.
13
13
  - While the agent is updating, the UI can show who is active.
14
- - If the task changes mid-run, the commit is rejected instead of overwriting the
14
+ - If the record changes mid-run, the commit is rejected instead of overwriting the
15
15
  newer edit.
16
16
 
17
17
  A **claim** does both jobs. Claims don't lock — if another writer holds the row,
@@ -24,9 +24,9 @@ a typed error if the row moved underneath you while the agent was busy.
24
24
 
25
25
  ## Schema-Backed Worker
26
26
 
27
- The worker uses the same schema client the app uses. It reads the task from the
27
+ The worker uses the same schema client the app uses. It reads the record from the
28
28
  server with `get({ id })`, claims the row, and writes through
29
- `ablo.tasks.update(...)` with a stale-check so a concurrent edit can't be
29
+ `ablo.records.update(...)` with a stale-check so a concurrent edit can't be
30
30
  overwritten.
31
31
 
32
32
  ```ts
@@ -34,7 +34,7 @@ import Ablo, { AbloClaimedError, AbloStaleContextError } from '@abloatai/ablo';
34
34
  import { defineSchema, model, z } from '@abloatai/ablo/schema';
35
35
 
36
36
  const schema = defineSchema({
37
- tasks: model({
37
+ records: model({
38
38
  title: z.string(),
39
39
  status: z.enum(['todo', 'doing', 'done']),
40
40
  }),
@@ -46,19 +46,19 @@ const ablo = Ablo({
46
46
  transport: 'http',
47
47
  });
48
48
 
49
- export async function markDone(taskId: string) {
49
+ export async function markDone(recordId: string) {
50
50
  await ablo.ready();
51
51
 
52
52
  // get({ id }) is an async server read — await it.
53
- const task = await ablo.tasks.get({ id: taskId });
54
- if (!task) return { status: 'not_found' };
53
+ const record = await ablo.records.get({ id: recordId });
54
+ if (!record) return { status: 'not_found' };
55
55
 
56
56
  try {
57
57
  // queue: false → don't queue behind a current holder. If another
58
58
  // participant holds the row, claim resolves null, so the agent yields
59
59
  // instead of waiting. Omit it, or pass queue: true, to queue behind them.
60
- const acquired = await ablo.tasks.claim({
61
- id: taskId,
60
+ const acquired = await ablo.records.claim({
61
+ id: recordId,
62
62
  queue: false,
63
63
  description: 'marking_done',
64
64
  });
@@ -72,7 +72,7 @@ export async function markDone(taskId: string) {
72
72
  // `onStale: 'reject'`. The write below is therefore equivalent to passing
73
73
  // those options yourself:
74
74
  //
75
- // ablo.tasks.update({
75
+ // ablo.records.update({
76
76
  // id: claim.data.id,
77
77
  // data: { status: 'done' },
78
78
  // readAt: <claim snapshot version>,
@@ -82,12 +82,12 @@ export async function markDone(taskId: string) {
82
82
  // If a newer version landed mid-run, the row no longer matches `readAt`, so
83
83
  // the server rejects this commit with AbloStaleContextError (caught below)
84
84
  // instead of clobbering that edit.
85
- const updated = await ablo.tasks.update({
85
+ const updated = await ablo.records.update({
86
86
  id: claim.data.id,
87
87
  data: { status: 'done' },
88
88
  });
89
89
 
90
- return { status: 'done', task: updated };
90
+ return { status: 'done', record: updated };
91
91
  } catch (err) {
92
92
  // The lease was lost or a foreign holder rejected the write.
93
93
  if (err instanceof AbloClaimedError) return { status: 'yielded' };
@@ -108,9 +108,9 @@ Keep workers on the same schema-backed client as the app.
108
108
 
109
109
  import { useAblo } from '@abloatai/ablo/react';
110
110
 
111
- export function TaskRow({ task: serverTask }: Props) {
112
- const data = useAblo((ablo) => ablo.tasks.local.get(serverTask.id)) ?? serverTask;
113
- const holder = useAblo((ablo) => ablo.tasks.claim.state({ id: serverTask.id }));
111
+ export function RecordRow({ record: serverTask }: Props) {
112
+ const data = useAblo((ablo) => ablo.records.local.get(serverTask.id)) ?? serverTask;
113
+ const holder = useAblo((ablo) => ablo.records.claim.state({ id: serverTask.id }));
114
114
  const agentActive = holder?.participantKind === 'agent';
115
115
 
116
116
  return (
@@ -27,7 +27,7 @@ import { z } from 'zod';
27
27
  export const runtime = 'nodejs';
28
28
 
29
29
  const schema = defineSchema({
30
- tasks: model({
30
+ records: model({
31
31
  title: schemaZ.string(),
32
32
  status: schemaZ.enum(['todo', 'doing', 'done']),
33
33
  summary: schemaZ.string().optional(),
@@ -40,15 +40,15 @@ const ablo = Ablo({
40
40
  transport: 'http',
41
41
  });
42
42
 
43
- const updateTask = updateTool(ablo.tasks, {
44
- title: 'Update task',
45
- description: 'Update a task without overwriting concurrent work.',
43
+ const updateTask = updateTool(ablo.records, {
44
+ title: 'Update record',
45
+ description: 'Update a record without overwriting concurrent work.',
46
46
  inputSchema: z.object({
47
- taskId: z.string(),
47
+ recordId: z.string(),
48
48
  status: z.enum(['todo', 'doing', 'done']).optional(),
49
49
  summary: z.string().optional(),
50
50
  }),
51
- id: ({ taskId }) => taskId,
51
+ id: ({ recordId }) => recordId,
52
52
  apply: (current, { status, summary }) => ({
53
53
  status: status ?? current.status,
54
54
  summary: summary ?? current.summary,
@@ -95,7 +95,7 @@ export async function POST() {
95
95
  const userId = await currentUserId(); // your auth
96
96
  const { token, expiresAt } = await ablo.sessions.create({
97
97
  user: { id: userId },
98
- can: { tasks: ['read', 'update'] },
98
+ can: { records: ['read', 'update'] },
99
99
  });
100
100
  return Response.json(
101
101
  credentialEndpointSuccessSchema.parse({
@@ -21,11 +21,11 @@ app/
21
21
  api/
22
22
  ablo-session/
23
23
  route.ts # mints a per-user ek_ token for the browser
24
- tasks/
24
+ records/
25
25
  [id]/
26
26
  page.tsx # RSC: get + render
27
27
  actions.ts # Server Action: claim, then write
28
- TaskEditor.tsx # Client: live updates
28
+ RecordEditor.tsx # Client: live updates
29
29
  lib/
30
30
  ablo.ts # Server Ablo client (holds ABLO_API_KEY)
31
31
  ablo.schema.ts # shared schema
@@ -82,7 +82,7 @@ export async function POST() {
82
82
 
83
83
  const { token, expiresAt } = await ablo.sessions.create({
84
84
  user: { id: user.id },
85
- can: { tasks: ['read', 'create', 'update'] },
85
+ can: { records: ['read', 'create', 'update'] },
86
86
  });
87
87
  return Response.json(
88
88
  credentialEndpointSuccessSchema.parse({
@@ -137,40 +137,40 @@ export default function RootLayout({ children }: { children: React.ReactNode })
137
137
  ## RSC Initial Render
138
138
 
139
139
  ```tsx
140
- // app/tasks/[id]/page.tsx
140
+ // app/records/[id]/page.tsx
141
141
  import { ablo } from '@/lib/ablo';
142
142
 
143
- export default async function TaskPage({
143
+ export default async function RecordPage({
144
144
  params,
145
145
  }: { params: Promise<{ id: string }> }) {
146
146
  const { id } = await params;
147
147
  await ablo.ready();
148
- const task = await ablo.tasks.get({ id });
149
- if (!task) return null;
148
+ const record = await ablo.records.get({ id });
149
+ if (!record) return null;
150
150
 
151
- return <TaskEditor task={task} />;
151
+ return <RecordEditor record={record} />;
152
152
  }
153
153
  ```
154
154
 
155
155
  ## Server Action Commit
156
156
 
157
157
  ```ts
158
- // app/tasks/[id]/actions.ts
158
+ // app/records/[id]/actions.ts
159
159
  'use server';
160
160
 
161
161
  import { ablo } from '@/lib/ablo';
162
162
 
163
163
  export async function markDone(id: string) {
164
164
  // Claim grants exclusive, ordered access and hands back the fresh row.
165
- await using claim = await ablo.tasks.claim({ id });
165
+ await using claim = await ablo.records.claim({ id });
166
166
 
167
- const task = await ablo.tasks.update({
167
+ const record = await ablo.records.update({
168
168
  id,
169
169
  data: { status: 'done' },
170
170
  claim,
171
171
  });
172
172
 
173
- return { status: 'done', task };
173
+ return { status: 'done', record };
174
174
  // claim auto-releases as the action returns
175
175
  }
176
176
  ```
@@ -186,9 +186,9 @@ you — re-fetch and retry.
186
186
 
187
187
  import { useAblo } from '@abloatai/ablo/react';
188
188
 
189
- export function TaskEditor({ task: serverTask }: Props) {
190
- const data = useAblo((ablo) => ablo.tasks.local.get(serverTask.id)) ?? serverTask;
191
- const holder = useAblo((ablo) => ablo.tasks.claim.state({ id: serverTask.id }));
189
+ export function RecordEditor({ record: serverTask }: Props) {
190
+ const data = useAblo((ablo) => ablo.records.local.get(serverTask.id)) ?? serverTask;
191
+ const holder = useAblo((ablo) => ablo.records.claim.state({ id: serverTask.id }));
192
192
  const busy = Boolean(holder);
193
193
 
194
194
  return (