@abloatai/ablo 0.57.0 → 0.58.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 (77) hide show
  1. package/AGENTS.md +10 -4
  2. package/CHANGELOG.md +199 -13
  3. package/README.md +2 -1
  4. package/dist/ai-sdk.d.ts +1 -1
  5. package/dist/ai-sdk.d.ts.map +1 -1
  6. package/dist/context/evidence.d.ts +6 -8
  7. package/dist/context/evidence.d.ts.map +1 -1
  8. package/dist/context/evidence.js +6 -20
  9. package/dist/context/evidence.js.map +1 -1
  10. package/dist/context/index.d.ts +23 -0
  11. package/dist/context/index.d.ts.map +1 -0
  12. package/dist/context/index.js +26 -0
  13. package/dist/context/index.js.map +1 -0
  14. package/dist/context/onChange.d.ts +9 -0
  15. package/dist/context/onChange.d.ts.map +1 -0
  16. package/dist/context/onChange.js +37 -0
  17. package/dist/context/onChange.js.map +1 -0
  18. package/dist/source-conformance.d.ts +1 -1
  19. package/dist/source-conformance.d.ts.map +1 -1
  20. package/dist/source-conformance.js +1 -1
  21. package/dist/source-conformance.js.map +1 -1
  22. package/dist/source-drizzle.d.ts +1 -1
  23. package/dist/source-drizzle.d.ts.map +1 -1
  24. package/dist/source-drizzle.js +1 -1
  25. package/dist/source-drizzle.js.map +1 -1
  26. package/dist/source-kysely.d.ts +1 -1
  27. package/dist/source-kysely.d.ts.map +1 -1
  28. package/dist/source-kysely.js +1 -1
  29. package/dist/source-kysely.js.map +1 -1
  30. package/dist/source-next.d.ts +1 -1
  31. package/dist/source-next.d.ts.map +1 -1
  32. package/dist/source-next.js +1 -1
  33. package/dist/source-next.js.map +1 -1
  34. package/docs/agent-integration-decision-guide.md +123 -0
  35. package/docs/agents.md +18 -14
  36. package/docs/api-keys.md +6 -6
  37. package/docs/api.md +52 -29
  38. package/docs/branch-development.md +23 -4
  39. package/docs/cli.md +16 -9
  40. package/docs/client-behavior.md +21 -15
  41. package/docs/concurrency-convention.md +67 -77
  42. package/docs/context.md +56 -31
  43. package/docs/coordination.md +115 -36
  44. package/docs/data-sources.md +12 -6
  45. package/docs/debugging.md +1 -1
  46. package/docs/examples/agent-human.md +6 -18
  47. package/docs/examples/coordination-conformance.md +69 -0
  48. package/docs/examples/existing-document-pipeline.md +488 -0
  49. package/docs/examples/existing-python-backend.md +10 -13
  50. package/docs/examples/nextjs.md +2 -2
  51. package/docs/examples/scoped-agent.md +18 -1
  52. package/docs/examples/server-agent.md +2 -2
  53. package/docs/groups.md +19 -139
  54. package/docs/guarantees.md +5 -6
  55. package/docs/identity.md +2 -1
  56. package/docs/index.md +5 -0
  57. package/docs/integration-guide.md +20 -19
  58. package/docs/integrations/sandbox-runtime.md +148 -0
  59. package/docs/integrations.md +9 -0
  60. package/docs/operating-on-your-database.md +7 -0
  61. package/docs/quickstart.md +19 -13
  62. package/docs/react.md +9 -9
  63. package/docs/schema-contract.md +14 -13
  64. package/docs/sessions.md +1 -1
  65. package/examples/README.md +2 -2
  66. package/examples/agent-turn.ts +1 -1
  67. package/examples/expensive-agent-turn.ts +1 -1
  68. package/llms.txt +22 -11
  69. package/package.json +6 -6
  70. package/dist/context/sources.d.ts +0 -21
  71. package/dist/context/sources.d.ts.map +0 -1
  72. package/dist/context/sources.js +0 -36
  73. package/dist/context/sources.js.map +0 -1
  74. package/dist/context.d.ts +0 -22
  75. package/dist/context.d.ts.map +0 -1
  76. package/dist/context.js +0 -33
  77. package/dist/context.js.map +0 -1
package/docs/groups.md CHANGED
@@ -17,30 +17,28 @@ block `C`. Between those steps someone else edits `A`. The agent is now building
17
17
  `C` on a premise that has moved — and nothing about writing `C` looks wrong in
18
18
  isolation. That is stale context, and it is the thing sync groups let you catch.
19
19
 
20
- The recipe is one field on the commit: declare the group you read as a premise,
21
- and say what should happen if it moved.
20
+ The recipe is one field on the commit: declare the group you read as a premise.
22
21
 
23
22
  ```ts
24
23
  // The agent read everything under workspace:abc to compose this write.
25
24
  await ablo.blocks.update({
26
25
  id: 'block-C',
27
26
  data: { text: revised },
28
- reads: [{ group: 'workspace:abc', readAt: watermark, onStale: 'notify' }],
27
+ reads: [{ group: 'workspace:abc', readAt: watermark }],
29
28
  });
30
29
  ```
31
30
 
32
31
  At commit, inside the write transaction, the engine asks a single question: *did
33
32
  any delta routed to `workspace:abc` land after `watermark`?* If nothing moved, the
34
- write applies. If something moved, `onStale` decides `notify` holds the write
35
- and hands the agent a `StaleNotification` naming the group, so it re-reads
36
- `workspace:abc` and regenerates; `reject` aborts the batch with a `409`. The agent
37
- never persists work built on a premise it can no longer see.
33
+ write applies. If something moved, Ablo rejects the batch with a `409`, so the
34
+ agent can re-read `workspace:abc`, regenerate, and submit a fresh guarded
35
+ write. The agent never persists work built on a premise it can no longer see.
38
36
 
39
37
  ---
40
38
 
41
39
  ## How you hear about it
42
40
 
43
- Four channels carry "something changed", and they answer four different
41
+ Three channels carry "something changed", and they answer three different
44
42
  questions. Pick by the question you have.
45
43
 
46
44
  ```ts
@@ -52,10 +50,9 @@ await using room = await ablo.records.join(documentIds, { ttl: '5m' });
52
50
  room.peers;
53
51
 
54
52
  // Stop this write if the thing I read moved while I composed it.
55
- await ablo.blocks.update({ id, data, reads: [{ group: 'workspace:abc', readAt, onStale: 'notify' }] });
56
-
57
- // Tell me later if this moves, even though I am not writing now.
58
- await ablo.records.track({ id: 's-1' });
53
+ const record = await ablo.records.read({ id: 's-1' });
54
+ if (!record) throw new Error('Record not found');
55
+ await ablo.blocks.update({ id, data, reads: [record] });
59
56
  ```
60
57
 
61
58
  | Question | Channel | Arrives |
@@ -63,29 +60,12 @@ await ablo.records.track({ id: 's-1' });
63
60
  | What do the rows say right now? | `onChange` | As deltas land, on the socket |
64
61
  | Who else is working here? | `join`, then `room.peers` and `room.claims` | As participants come and go, on the socket |
65
62
  | Did the premise for **this** write move? | `reads` on the write | On that write's receipt, before it applies |
66
- | Has anything I read moved since? | `track` | On your next commit's receipt |
67
- | Stop me writing if it has | `track` with `onStale: 'reject'` | Refuses that commit, at the chokepoint |
68
-
69
- Two distinctions do most of the work here.
70
-
71
- **`join` is about people; `track` is about data.** Both open a subscription and
72
- both are scoped by sync group, which is why they look alike. `join` reports
73
- participants: who is present, what they are doing, which rows they hold. `track`
74
- reports the rows themselves: something you said you cared about moved, here is
75
- the watermark to re-read it at. A tool that wants to avoid duplicating a peer's
76
- work needs `join`. A tool whose output goes stale when its inputs change needs
77
- `track`.
78
-
79
- **`reads` guards one write; `track` outlives it.** They speak the same
80
- vocabulary and produce the same `StaleNotification`. A `reads` entry is checked
81
- once, at the commit that carried it, and discarded. A `track` is persisted and
82
- re-checked against every delta after it, so a long-running actor hears about a
83
- change that landed while it was thinking, on the next commit it makes.
84
63
 
85
64
  `onChange` and `join` need a live socket, so they are available on the default
86
- WebSocket client. `reads` and `track` ride the commit, so they reach a socketless
87
- actor over HTTP too, which is what makes them the notification path for agents
88
- and workers.
65
+ WebSocket client. `reads` rides the commit, so it reaches a socketless actor over
66
+ HTTP too. The row returned by `read` privately carries its model, id, and
67
+ watermark; passing that row in `reads` is enough to protect a later write. Ablo
68
+ does not retain the row contents as read evidence.
89
69
 
90
70
  ---
91
71
 
@@ -158,13 +138,12 @@ Two consequences worth designing around:
158
138
  ## Declaring the batch premise
159
139
 
160
140
  `reads[]` declares what the commit was based on. Each entry is a premise, and
161
- each governs the *whole* commit: if one goes stale, its disposition applies to
162
- every write in the batch, not just one operation. You choose the granularity per
163
- entry.
141
+ each governs the *whole* commit: if one goes stale, every write in the batch
142
+ rejects. You choose the granularity per entry.
164
143
 
165
144
  ```ts
166
145
  reads: [
167
- { group: 'workspace:abc', readAt: N, onStale: 'notify' }, // did anything in the workspace move?
146
+ { group: 'workspace:abc', readAt: N }, // did anything in the workspace move?
168
147
  { model: 'Document', id: 's-1', readAt: N, fields: ['title'] }, // did this row (this field) move?
169
148
  ]
170
149
  ```
@@ -175,108 +154,9 @@ literal: this object, optionally these fields. A row premise with `fields`
175
154
  conflicts only on real field overlap, so two actors editing disjoint fields of
176
155
  the same row don't collide.
177
156
 
178
- `onStale` has three settings, defaulting to `reject`:
179
-
180
- - **`notify`** holds every write in the batch and returns a `StaleNotification`.
181
- For a group premise it carries the group name and the new watermark
182
- (`observedSyncId`); re-read the group at that point and regenerate. This is the
183
- setting a chain wants — the actor gets the truth and resolves it.
184
- - **`reject`** aborts the batch with a `stale_context` error (`409`). The right
185
- default when there is nothing to reconcile and the write should simply not
186
- land.
187
- - **`overwrite`** skips the check and lets the write land — last-write-wins, the
188
- explicit escape hatch.
189
-
190
- ---
191
-
192
- ## Staying subscribed across commits: `track`
193
-
194
- A batch premise guards a single commit: you state what you read, the engine
195
- checks it, the premise is gone. That fits an actor that reads and writes in one
196
- breath. It does not fit a long-running one — an agent that reads a row now,
197
- works for a few minutes, and writes much later. By the time it commits, the
198
- premise it would have declared is stale, and there was no commit in between on
199
- which to hear that the ground had shifted.
200
-
201
- `track` is the durable half of the same idea. Register what you are watching and
202
- it persists on the server; the next time you commit anything, a change that
203
- landed on the tracked target since you registered rides back on your receipt —
204
- the same `StaleNotification` an `onStale: 'notify'` premise would have handed
205
- you, arriving on the write you were going to make anyway.
206
-
207
- ```ts
208
- // Register interest and walk away — no write required.
209
- await ablo.records.track({ id: 's-1' });
210
-
211
- // …minutes of other work later, on your next commit…
212
- const res = await ablo.blocks.update({ id: 'block-C', data: { text: revised } });
213
- res.notifications; // populated if s-1 moved under you in the meantime
214
- ```
215
-
216
- The target is a row (`{ id }` on the model verb) or a sync group (as a write
217
- option, below). A track is an idempotent registration: calling it again refreshes
218
- the same subscription rather than stacking duplicates, and once a change fires the
219
- track re-baselines, so the same change notifies once. Your own writes to a target
220
- you track never notify you — the signal is about what *others* did.
221
-
222
- You can also register a track as part of a write you are already making, the
223
- persisted companion to `reads`:
224
-
225
- ```ts
226
- await ablo.records.update({
227
- id: 's-1',
228
- data: { title: revised },
229
- reads: [{ group: 'workspace:abc', readAt: N, onStale: 'notify' }], // guards THIS commit
230
- track: [{ group: 'workspace:abc' }], // and keeps watching after it
231
- });
232
- ```
233
-
234
- So `reads` is the premise for the commit in hand; `track` is a standing
235
- subscription that outlives it. Both speak the same notification vocabulary.
236
- Delivery is on your next commit's receipt; a track does not yet push out of band
237
- between commits.
238
-
239
- ### Reporting, or gating: `onStale`
240
-
241
- A track says what a moved belief should do to your **next write**. Same enum the
242
- `reads` premise carries, minus the one mode that cannot apply:
243
-
244
- ```ts
245
- await ablo.records.track({ id: 's-1' }); // notify (default)
246
- await ablo.records.track({ id: 's-1', onStale: 'reject' }); // gate
247
- ```
248
-
249
- - **`notify`** — the change rides your next receipt and the commit proceeds. You
250
- decide what it means.
251
- - **`reject`** — your next commit is **refused** while that belief is stale, even
252
- if it writes an unrelated row. The gate is on what you *know*, not on what you
253
- are touching.
254
-
255
- `reject` is for an actor that must not act on a stale picture — an agent that
256
- read a row, reasoned for minutes, and is about to write based on what it
257
- believed. Ablo enforces it at the commit chokepoint, so it is a guarantee rather
258
- than a convention: the write cannot land.
259
-
260
- The gate takes a key. It does **not** reopen on its own, because an agent that
261
- retried blindly would land exactly the write `reject` was asked to prevent. You
262
- re-read, then re-register the track to say so:
263
-
264
- ```ts
265
- try {
266
- await ablo.records.update({ id, data });
267
- } catch (err) {
268
- if (err.code === 'stale_context') {
269
- const fresh = await ablo.records.get({ id: 's-1' }); // read
270
- await ablo.records.track({ id: 's-1', onStale: 'reject' }); // acknowledge
271
- await ablo.records.update({ id, data: reconsider(fresh) }); // now it lands
272
- }
273
- }
274
- ```
275
-
276
- A track-only call is never gated, so acknowledging is always reachable.
277
-
278
- `overwrite` is not offered: it means "apply my write anyway", and a track guards
279
- no write of its own to apply.
157
+ Any stale premise aborts the batch with `stale_context` (`409`). Re-read and
158
+ regenerate if the work is still relevant. Omit `reads` only when the write is
159
+ intentionally unconditional.
280
160
 
281
161
  ---
282
162
 
@@ -44,11 +44,11 @@ The server remains the source of truth.
44
44
 
45
45
  ## Stale-Write Protection
46
46
 
47
- Use `snapshot(...)` and `readAt` when a write depends on state the agent already
48
- read:
47
+ Use `read(...)` and carry the returned row in `reads` when a write depends on
48
+ state the agent already read:
49
49
 
50
50
  ```ts
51
- const report = await ablo.weatherReports.get({ id: 'report_stockholm' });
51
+ const report = await ablo.weatherReports.read({ id: 'report_stockholm' });
52
52
  if (!report) throw new Error('report missing');
53
53
 
54
54
  await ablo.weatherReports.update({
@@ -67,7 +67,7 @@ not acquire this guarantee.
67
67
 
68
68
  Two other dispositions exist. `overwrite` applies the write with no stale check
69
69
  at all. `notify` **holds** the write, so the row is left as it stands, and hands
70
- back a `StaleNotification` carrying the current value for the actor to reconcile
70
+ back an `AbloStaleContextError` so the actor can re-read and reconcile
71
71
  and re-issue; the rest of the batch still commits.
72
72
 
73
73
  See [Concurrency Convention](./concurrency-convention.md) for the full taxonomy,
@@ -96,8 +96,7 @@ claim queues fairly behind the holder).
96
96
 
97
97
  By default, a held claim rejects writes from other participants to the claimed
98
98
  target. Contenders that call `claim` wait their turn; ordinary reads remain
99
- open. An explicit model conflict policy can choose another disposition for a
100
- participant kind. While you hold a claim, the matching
99
+ open. While you hold a claim, the matching
101
100
  `ablo.<model>.update({ id, ... })` is rejected with `AbloStaleContextError` if
102
101
  the row changed underneath you after your claim point.
103
102
 
package/docs/identity.md CHANGED
@@ -410,7 +410,7 @@ it to the provider:
410
410
 
411
411
  ```ts
412
412
  // lib/ablo.ts
413
- import Ablo from '@abloatai/ablo';
413
+ import { Ablo } from '@abloatai/ablo/react';
414
414
  import { schema } from '@/ablo/schema';
415
415
 
416
416
  // Build the client from the identity your server already resolved.
@@ -505,6 +505,7 @@ subset of what its user could see:
505
505
  // (the floor). Build each group from the model's scope with `syncGroup(kind, id)`.
506
506
  const session = await server.sessions.create({
507
507
  agent: { id: agentId },
508
+ onBehalfOf: { user: { id: triggeringUser.id } },
508
509
  can: { Document: ['read', 'update'], Workspace: ['read', 'update'] },
509
510
  syncGroups: [syncGroup('document', recordId), syncGroup('workspace', workspaceId)],
510
511
  });
package/docs/index.md CHANGED
@@ -162,6 +162,11 @@ default caller, not a special one.
162
162
 
163
163
  ## Build
164
164
 
165
+ - [Agent Integration Decision Guide](./agent-integration-decision-guide.md) — choose identifier or row claims, captured reads, atomic boundaries, persistence ownership, transport, and the smallest proof before opening an example.
166
+ - [TypeScript Library](./libraries/typescript.md) — construct the server client,
167
+ keep schema ownership beneath one boundary, and select an integration approach.
168
+ - [GraphQL.js over an Existing Backend](./approaches/graphql/graphql-js.md) —
169
+ keep resolvers thin and call one named Ablo-backed domain operation.
165
170
  - [Quickstart](./quickstart.md) — make your first coordinated write.
166
171
  - [Integration Guide](./integration-guide.md) — the canonical end-to-end integration.
167
172
  - [Integrations](./integrations.md) — long-running records, ingestion, and other application-edge runtimes.
@@ -77,7 +77,7 @@ When handing this to a coding agent, give it a concrete target:
77
77
  ```txt
78
78
  Add Ablo to this app for one model your agents edit.
79
79
  Run npx ablo dev and use its branch-bound key. Declare schema, add the Ablo client, replace
80
- one write with ablo.<model>.update(..., { readAt, onStale: 'reject' }), and add a smoke test for two concurrent writers.
80
+ one decision read with ablo.<model>.read(...), pass that row in the write's reads array, and add a smoke test for two concurrent writers.
81
81
  ```
82
82
 
83
83
  ## 1. Declare A Schema
@@ -93,9 +93,9 @@ import { defineSchema, model, z } from '@abloatai/ablo/schema';
93
93
  export const schema = defineSchema(
94
94
  {
95
95
  weatherReports: model({
96
- // Reserved fields (id, createdAt, updatedAt, organizationId, createdBy)
97
- // are SDK-provided automatically — never declare them. Declare only your
98
- // own fields.
96
+ // `id` is the only field the SDK supplies; never declare it. Audit
97
+ // fields are ordinary fields: declare `createdAt` / `updatedAt` /
98
+ // `createdBy` here if you want columns for them.
99
99
  projectId: z.string(),
100
100
  location: z.string(),
101
101
  status: z.enum(['pending', 'ready']),
@@ -158,7 +158,7 @@ falsy flag. See `packages/transaction/src/schema/model.ts` for the full option s
158
158
  Trusted runtimes can use `ABLO_API_KEY`.
159
159
 
160
160
  ```ts
161
- // src/ablo.ts
161
+ // src/client/ablo.ts
162
162
  import Ablo from '@abloatai/ablo';
163
163
  import { schema } from './ablo/schema';
164
164
 
@@ -176,7 +176,7 @@ client now).
176
176
 
177
177
  ```tsx
178
178
  // src/ablo-client.ts
179
- import Ablo from '@abloatai/ablo';
179
+ import { Ablo } from '@abloatai/ablo/react';
180
180
  import { schema } from '@/ablo/schema';
181
181
 
182
182
  // The browser never holds the API key. The client mints a short-lived token
@@ -256,7 +256,7 @@ refreshes before expiry.
256
256
  ## 3. Read State
257
257
 
258
258
  Reads come in two flavors, and you pick based on whether you can wait.
259
- `get({ id })` and `list({ where })` hit the server (and hydrate the local
259
+ `get({ id })`, `read({ id })`, and `list({ where })` hit the server (and hydrate the local
260
260
  store) — they're async, so you `await` them. `local.get(id)`,
261
261
  `local.list({ where })`, and `local.count({ where })` read the already-synced local
262
262
  graph synchronously, so they're the ones you call in render — and the ones you
@@ -268,7 +268,7 @@ and waits.
268
268
  ```ts
269
269
  await ablo.ready();
270
270
 
271
- const report = await ablo.weatherReports.get({ id: 'report_stockholm' });
271
+ const report = await ablo.weatherReports.read({ id: 'report_stockholm' });
272
272
  if (!report) throw new Error('report not found');
273
273
  ```
274
274
 
@@ -318,17 +318,17 @@ For simple writes:
318
318
  await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' } });
319
319
  ```
320
320
 
321
- For writes based on state the user or agent already read, snapshot first and
322
- reject stale updates:
321
+ For writes based on current state, use a decision read and pass the exact row
322
+ to the mutation:
323
323
 
324
324
  ```ts
325
- const snap = ablo.snapshot({ weatherReports: 'report_stockholm' });
325
+ const report = await ablo.weatherReports.read({ id: 'report_stockholm' });
326
+ if (!report) throw new Error('report not found');
326
327
 
327
328
  await ablo.weatherReports.update({
328
- id: 'report_stockholm',
329
+ id: report.id,
329
330
  data: { status: 'ready' },
330
- readAt: snap.stamp,
331
- onStale: 'reject',
331
+ reads: [report],
332
332
  });
333
333
  ```
334
334
 
@@ -481,12 +481,12 @@ const completeReport = tool({
481
481
  forecast: z.string(),
482
482
  }),
483
483
  execute: async ({ reportId, forecast }) => {
484
- const snap = ablo.snapshot({ weatherReports: reportId });
484
+ const report = await ablo.weatherReports.read({ id: reportId });
485
+ if (!report) throw new Error('report not found');
485
486
  return ablo.weatherReports.update({
486
487
  id: reportId,
487
488
  data: { status: 'ready', forecast },
488
- readAt: snap.stamp,
489
- onStale: 'reject',
489
+ reads: [report],
490
490
  });
491
491
  },
492
492
  });
@@ -529,7 +529,7 @@ that guarantee as eventual completion with repair—not atomicity.
529
529
  | `persistence: 'indexeddb'` | Durable browser cache that survives reloads, for apps that need it. |
530
530
  | `durableWrites: { store, namespace? }` | Recover unacknowledged worker writes after a process restart. |
531
531
  | `claim` / `claim.state` / `claim.queue` | Show active work and coordinate before a write. |
532
- | `snapshot` + `readAt` | Reject writes based on stale state. |
532
+ | `read` + `reads` | Reject writes based on stale state. |
533
533
  | `mutable`, `readOnly`, `field`, `indexed` | Advanced schema and read tuning. |
534
534
 
535
535
  The first integration should not need most of these. Start with schema and
@@ -540,7 +540,8 @@ them.
540
540
 
541
541
  | Method | Use it for |
542
542
  | -------------------------------------- | -------------------------------------------------------------------------------- |
543
- | `get({ id })` | Async read of one row from the server (await it). |
543
+ | `get({ id })` | Observe one row from the server (await it). |
544
+ | `read({ id })` | Read one decision input to carry in a mutation's `reads` array. |
544
545
  | `list({ where })` | Async read of many rows from the server (await it). |
545
546
  | `listAll({ where, maxPages?, signal? })` | Explicit bounded traversal of every matching page; filter before collecting. |
546
547
  | `local.get(id)` | Synchronous local read of one synced row (use in render). |
@@ -0,0 +1,148 @@
1
+ # Anthropic Sandbox Runtime
2
+
3
+ > Run an Ablo agent with OS-enforced filesystem and network boundaries while its coordinated work remains durable outside the sandbox.
4
+
5
+ Anthropic Sandbox Runtime and Ablo own different boundaries:
6
+
7
+ | Concern | Owner |
8
+ |---|---|
9
+ | Filesystem, network, Unix sockets, process-tree restrictions | Sandbox Runtime |
10
+ | Typed reads and writes, claims, fencing, idempotency, confirmation | Ablo |
11
+ | Prompts, tools, model calls, and business behavior | Your application |
12
+ | Authentication, branch creation, schema push, and database connection | A trusted host workflow |
13
+
14
+ An **execution sandbox** is the disposable process boundary. An **Ablo branch**
15
+ is an isolated data and schema plane. Keep the names and lifecycles separate.
16
+
17
+ ## Use two profiles
18
+
19
+ Do not give a repository-editing agent the same authority as a runtime agent.
20
+
21
+ The **integration profile** adapts source code. It receives no Ablo credential,
22
+ has no Ablo network access, cannot read real environment files, and writes only
23
+ inside the selected application root. It can still read the installed-version
24
+ documentation without a network connection:
25
+
26
+ ```bash
27
+ npm exec --offline -- ablo docs integration-guide
28
+ npm exec --offline -- ablo docs api
29
+ npm exec --offline -- ablo setup --plan --json
30
+ ```
31
+
32
+ The **runtime profile** executes application work. It receives one short-lived,
33
+ branch-bound runtime credential, permits the Ablo API, and normally has no
34
+ repository write access. Give it only the model operations and sync groups one
35
+ run needs.
36
+
37
+ Never inject control-plane authority, a database URL, cloud credentials, or an
38
+ ambient host environment into either profile.
39
+
40
+ ## Prepare authority outside the sandbox
41
+
42
+ Run control-plane and database operations in a trusted host workflow. That
43
+ workflow prepares the branch, pushes the reviewed schema, and delegates a
44
+ per-run `rk_` credential. Management and broad branch credentials are
45
+ infrastructure details and never enter the agent-facing launcher:
46
+
47
+ ```bash
48
+ # Supplied by trusted CI or a credential broker after branch preparation.
49
+ export ABLO_API_KEY=rk_...
50
+ npm run sandbox -- agent job_123
51
+ ```
52
+
53
+ The sandbox launcher should build an explicit child environment rather than
54
+ inherit `process.env`. A runtime credential is not a substitute for process
55
+ isolation, and process isolation is not a substitute for a narrowly scoped
56
+ credential.
57
+
58
+ ## Keep the repository tree downward
59
+
60
+ Give the Ablo boundary and agent behavior separate entry points:
61
+
62
+ ```text
63
+ src/
64
+ ablo/
65
+ index.ts schema-backed client boundary
66
+ client.ts
67
+ schema.ts
68
+ agent/
69
+ index.ts process entry point
70
+ processJob.ts
71
+ sandbox/
72
+ index.ts sanitized launcher and command catalog
73
+ integration.policy.json
74
+ runtime.policy.json
75
+ ```
76
+
77
+ The agent enters through `agent/index.ts` and follows dependencies down into
78
+ `ablo/index.ts`. Ablo code never imports the agent or sandbox implementation.
79
+
80
+ ## Coordinate effects that outlive the process
81
+
82
+ A filesystem sandbox cannot prevent two valid agents from overwriting the same
83
+ shared row. Read, claim, and write through the schema-backed Ablo client:
84
+
85
+ ```ts
86
+ const claim = await ablo.jobs.claim({
87
+ id: jobId,
88
+ description: 'processing in a sandbox',
89
+ ttl: '30s',
90
+ heartbeat: { every: '10s' },
91
+ });
92
+
93
+ try {
94
+ const result = await performWork(claim.data);
95
+ await ablo.jobs.update({
96
+ id: claim.data.id,
97
+ data: { status: 'complete', result },
98
+ claim,
99
+ idempotencyKey: `job:${claim.data.id}:complete`,
100
+ });
101
+ } finally {
102
+ await claim.release();
103
+ }
104
+ ```
105
+
106
+ If the sandbox disappears, its heartbeat stops and the lease expires. A later
107
+ holder reads fresh state. If the old process resumes, the checked write is
108
+ refused because it no longer owns the claim.
109
+
110
+ ## Start from deny-first policies
111
+
112
+ Sandbox Runtime denies writes and network access unless they are allowed, but
113
+ filesystem reads require explicit deny regions. A practical policy should:
114
+
115
+ - deny the user's home region, then re-allow the selected repository;
116
+ - keep real `.env*`, SSH material, cloud configuration, and credentials denied;
117
+ - allow writes only to the selected application root and a dedicated temporary directory;
118
+ - allow only `api.abloatai.com:443` for an Ablo runtime agent;
119
+ - add a model-provider domain only when the model process itself runs inside the sandbox;
120
+ - leave Docker sockets and Apple Events disabled; and
121
+ - use resolved literal paths because filesystem globs are not supported on Linux.
122
+
123
+ Domain allowlists are coarse: an allowed domain can still be an exfiltration
124
+ channel. Filesystem isolation, environment sanitation, and least-authority
125
+ credentials must be used together.
126
+
127
+ ## Run the complete example
128
+
129
+ The repository example contains the launcher, both policies, the schema-backed
130
+ agent, and boundary tests:
131
+
132
+ ```bash
133
+ cd examples/sandboxed-agent
134
+ npm install
135
+ npm run docs:sandboxed
136
+ npm test
137
+ npm run typecheck
138
+ ```
139
+
140
+ Sandbox Runtime is a beta research preview. Keep it behind the launcher boundary
141
+ so policy and API changes do not spread through application or Ablo code.
142
+
143
+ ## References
144
+
145
+ - [Anthropic Sandbox Runtime](https://github.com/anthropic-experimental/sandbox-runtime)
146
+ - [Agents](../agents.md)
147
+ - [Branch-first development](../branch-development.md)
148
+ - [Coordination](../coordination.md)
@@ -13,6 +13,7 @@ into authoritative application state.
13
13
 
14
14
  | Category | Integration | Status | Use it for |
15
15
  |---|---|---|---|
16
+ | Agent execution | [Anthropic Sandbox Runtime](./integrations/sandbox-runtime.md) | Available | Restricting the filesystem, network, sockets, and inherited authority of an agent process |
16
17
  | Long-running records | [Temporal](./integrations/temporal.md) | Available | Durable Workflows, Activity retries, timers, cancellation, and durable AI SDK calls |
17
18
  | Long-running records | [Inngest](./integrations/inngest.md) | Available | Event-driven durable functions, retriable steps, flow control, and checkpointed AI SDK calls |
18
19
  | Data ingestion | Connector runtimes | Planned | Bringing external data into Ablo-backed models without creating a second write authority |
@@ -22,6 +23,14 @@ the boundary has been tested. A dedicated package comes later still: only
22
23
  repeated production integrations that reveal substantial reusable behavior
23
24
  justify adding another public runtime dependency.
24
25
 
26
+ ## Agent execution
27
+
28
+ Use [Anthropic Sandbox Runtime](./integrations/sandbox-runtime.md) to enforce the
29
+ boundary around an agent process. The runtime owns filesystem and network
30
+ access. Ablo remains below it and owns typed shared-state operations, claims,
31
+ idempotency, and confirmation. The runnable example lives in
32
+ `examples/sandboxed-agent`.
33
+
25
34
  ## Long-running records
26
35
 
27
36
  Use [Temporal](./integrations/temporal.md) when work must survive process
@@ -39,6 +39,13 @@ Ablo prevents a non-holder from writing through another participant's claim by
39
39
  default. It does not infer that every plain update is a read-modify-write
40
40
  operation; use the guarded forms when lost-update protection matters.
41
41
 
42
+ Direct SQL remains outside that enforcement path. If an existing application
43
+ service writes the same row directly, keep its database constraints and locks
44
+ as the final integrity boundary and route every participating caller through
45
+ one named operation. Ablo can select one worker before expensive work begins;
46
+ the existing service must still re-read, validate, and commit authoritatively.
47
+ See [Coordinate an existing database operation](./coordination.md#coordinate-an-existing-database-operation).
48
+
42
49
  ## Three kinds of action
43
50
 
44
51
  Sort any action you're about to take into one of these, and the right move
@@ -10,10 +10,11 @@ confirms it by tailing your write-ahead log (WAL). Your rows live in your databa
10
10
  which stays the system of record. Ablo writes rows but **runs no DDL and owns no
11
11
  schema** — your migration tool stays in charge of the shape of your database.
12
12
 
13
- > No database yet? Pass an `apiKey` only and Ablo keeps your rows in its own log,
14
- > so you can build the whole app today. Point it at a
15
- > separate or local Postgres for a development branch, or at your production
16
- > database when you're ready.
13
+ > **Which database?** A branch needs one connected before a schema will push, so
14
+ > start on a throwaway rather than your production Postgres. `ablo dev` gives
15
+ > each Git branch its own isolated plane: point a development branch at a local
16
+ > or separate database, and connect your production one when you're ready for it
17
+ > to be the system of record.
17
18
 
18
19
  ## 1. Install and initialize
19
20
 
@@ -26,14 +27,16 @@ npx ablo init
26
27
  by signing you in — one browser click, and a project-scoped `mk_` management
27
28
  credential is saved locally. Later, `npx ablo dev` (step 4) prepares an isolated branch and writes
28
29
  its temporary `ABLO_API_KEY` into `.env.local`, so the SDK follows your Git
29
- branch with no manual copy-paste. `npx ablo login` also exists standalone. In
30
- CI, set project management access explicitly:
30
+ branch with no manual copy-paste. `npx ablo login` also exists standalone.
31
+ Headless CI uses the same credential input as every other Ablo process; during branch
32
+ preparation it contains an `mk_` credential:
31
33
 
32
34
  ```bash
33
- export ABLO_MANAGEMENT_KEY=mk_...
35
+ export ABLO_API_KEY=mk_...
34
36
  ```
35
37
 
36
- Every runtime call needs a branch-bound API key. `ablo dev` exchanges the
38
+ Do not pass that value to application code. Every runtime call needs a
39
+ branch-bound API key. `ablo dev` exchanges the
37
40
  stored management credential for
38
41
  an expiring `sk_*` key bound to the current development branch.
39
42
  Production runtimes use `sk_*`. In production a key points at the database
@@ -67,9 +70,12 @@ export const schema = defineSchema({
67
70
  });
68
71
  ```
69
72
 
70
- **Reserved fields** `id`, `createdAt`, `updatedAt`, `organizationId`, and
71
- `createdBy` are provided by the SDK automatically. Don't declare them in your
72
- `model(...)` fields; declare only your own.
73
+ **`id` is the only automatic field.** Every row carries one, so leave it out of
74
+ `model(...)`. Everything else is yours, audit fields included: declare
75
+ `createdAt`, `updatedAt` or `createdBy` and `ablo migrate` gives each a column,
76
+ which your write or a database default then fills. Ablo does not write them for
77
+ you. A model that omits them still reads and writes, it just orders and
78
+ attributes its history less precisely.
73
79
 
74
80
  The schema is registered once (init scaffolds `ablo/register.ts` for you), and
75
81
  every type is one parameter away — no `typeof schema` re-stating, anywhere:
@@ -229,12 +235,12 @@ const updated = await ablo.weatherReports.update({
229
235
  console.log({ id: updated.id, status: updated.status }); // { id: '...', status: 'ready' }
230
236
  ```
231
237
 
232
- Read a single row back with `get({ id })`. It resolves to the row, or to
238
+ Read a single row back with `read({ id })`. It resolves to the row, or to
233
239
  `undefined` when no row has that id — so narrow it once, then the fields are
234
240
  fully typed:
235
241
 
236
242
  ```ts
237
- const report = await ablo.weatherReports.get({ id: created.id });
243
+ const report = await ablo.weatherReports.read({ id: created.id });
238
244
  if (!report) throw new Error(`weatherReports ${created.id} not found`);
239
245
 
240
246
  console.log(report.status); // 'ready'