@abloatai/ablo 0.56.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 (82) hide show
  1. package/AGENTS.md +10 -4
  2. package/CHANGELOG.md +428 -10
  3. package/LICENSE +1 -1
  4. package/NOTICE +3 -3
  5. package/README.md +2 -1
  6. package/dist/ai-sdk.d.ts +1 -1
  7. package/dist/ai-sdk.d.ts.map +1 -1
  8. package/dist/context/evidence.d.ts +6 -8
  9. package/dist/context/evidence.d.ts.map +1 -1
  10. package/dist/context/evidence.js +6 -20
  11. package/dist/context/evidence.js.map +1 -1
  12. package/dist/context/index.d.ts +23 -0
  13. package/dist/context/index.d.ts.map +1 -0
  14. package/dist/context/index.js +26 -0
  15. package/dist/context/index.js.map +1 -0
  16. package/dist/context/onChange.d.ts +9 -0
  17. package/dist/context/onChange.d.ts.map +1 -0
  18. package/dist/context/onChange.js +37 -0
  19. package/dist/context/onChange.js.map +1 -0
  20. package/dist/source-conformance.d.ts +1 -1
  21. package/dist/source-conformance.d.ts.map +1 -1
  22. package/dist/source-conformance.js +1 -1
  23. package/dist/source-conformance.js.map +1 -1
  24. package/dist/source-drizzle.d.ts +1 -1
  25. package/dist/source-drizzle.d.ts.map +1 -1
  26. package/dist/source-drizzle.js +1 -1
  27. package/dist/source-drizzle.js.map +1 -1
  28. package/dist/source-kysely.d.ts +1 -1
  29. package/dist/source-kysely.d.ts.map +1 -1
  30. package/dist/source-kysely.js +1 -1
  31. package/dist/source-kysely.js.map +1 -1
  32. package/dist/source-next.d.ts +1 -1
  33. package/dist/source-next.d.ts.map +1 -1
  34. package/dist/source-next.js +1 -1
  35. package/dist/source-next.js.map +1 -1
  36. package/docs/agent-integration-decision-guide.md +123 -0
  37. package/docs/agents.md +74 -13
  38. package/docs/api-keys.md +6 -6
  39. package/docs/api.md +117 -43
  40. package/docs/branch-development.md +23 -4
  41. package/docs/cli.md +16 -9
  42. package/docs/client-behavior.md +21 -15
  43. package/docs/concurrency-convention.md +67 -77
  44. package/docs/context.md +56 -31
  45. package/docs/coordination.md +115 -36
  46. package/docs/customer-organizations.md +49 -31
  47. package/docs/data-sources.md +12 -6
  48. package/docs/debugging.md +1 -1
  49. package/docs/examples/agent-human.md +6 -18
  50. package/docs/examples/coordination-conformance.md +69 -0
  51. package/docs/examples/existing-document-pipeline.md +488 -0
  52. package/docs/examples/existing-python-backend.md +10 -13
  53. package/docs/examples/nextjs.md +49 -6
  54. package/docs/examples/scoped-agent.md +18 -1
  55. package/docs/examples/server-agent.md +2 -2
  56. package/docs/groups.md +19 -139
  57. package/docs/guarantees.md +5 -6
  58. package/docs/identity.md +2 -1
  59. package/docs/index.md +5 -0
  60. package/docs/integration-guide.md +46 -19
  61. package/docs/integrations/sandbox-runtime.md +148 -0
  62. package/docs/integrations.md +9 -0
  63. package/docs/operating-on-your-database.md +7 -0
  64. package/docs/quickstart.md +19 -13
  65. package/docs/react.md +9 -9
  66. package/docs/schema-contract.md +14 -13
  67. package/docs/session-settings.md +9 -0
  68. package/docs/sessions.md +1 -1
  69. package/examples/README.md +2 -2
  70. package/examples/agent-turn.ts +1 -1
  71. package/examples/data-source/customer-server.ts +12 -5
  72. package/examples/expensive-agent-turn.ts +1 -1
  73. package/llms.txt +72 -10
  74. package/package.json +6 -6
  75. package/dist/context/sources.d.ts +0 -21
  76. package/dist/context/sources.d.ts.map +0 -1
  77. package/dist/context/sources.js +0 -36
  78. package/dist/context/sources.js.map +0 -1
  79. package/dist/context.d.ts +0 -22
  80. package/dist/context.d.ts.map +0 -1
  81. package/dist/context.js +0 -33
  82. package/dist/context.js.map +0 -1
@@ -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'
package/docs/react.md CHANGED
@@ -25,7 +25,7 @@ as props. Construct the client once, then pass that instance to the provider.
25
25
 
26
26
  ```ts
27
27
  // lib/ablo.ts
28
- import Ablo from '@abloatai/ablo';
28
+ import { Ablo } from '@abloatai/ablo/react';
29
29
  import { createAbloReact } from '@abloatai/ablo/react';
30
30
  import { schema } from '@/ablo/schema';
31
31
 
@@ -140,12 +140,12 @@ const reports = useAblo((ablo) =>
140
140
  ## Server Load
141
141
 
142
142
  ```tsx
143
- const report = await ablo.weatherReports.get({ id });
143
+ const report = await ablo.weatherReports.read({ id });
144
144
  ```
145
145
 
146
146
  Use `get` in Server Components when the row may not be in the local pool
147
147
  yet — it hydrates from the local store and the server, and returns a Promise, so
148
- `await` it. (Server reads come in two shapes: `get({ id })` for one row and
148
+ `await` it. (Server reads come in two shapes: `read({ id })` for one row and
149
149
  `list({ where })` for many; both are async. The synchronous local reads are
150
150
  the `local` reads, used in render below.)
151
151
 
@@ -156,12 +156,12 @@ For Server Actions and route handlers, call the SDK directly:
156
156
  ```ts
157
157
  import { ablo } from '@/lib/ablo';
158
158
 
159
- const snap = ablo.snapshot({ weatherReports: id });
159
+ const report = await ablo.weatherReports.read({ id });
160
+ if (!report) throw new Error('report not found');
160
161
  await ablo.weatherReports.update({
161
162
  id,
162
163
  data: patch,
163
- readAt: snap.stamp,
164
- onStale: 'reject',
164
+ reads: [report],
165
165
  });
166
166
  ```
167
167
 
@@ -173,12 +173,12 @@ const ablo = useAblo();
173
173
 
174
174
  async function markReady() {
175
175
  if (!ablo) return;
176
- const snap = ablo.snapshot({ weatherReports: id });
176
+ const report = await ablo.weatherReports.read({ id });
177
+ if (!report) return;
177
178
  await ablo.weatherReports.update({
178
179
  id,
179
180
  data: { status: 'ready' },
180
- readAt: snap.stamp,
181
- onStale: 'reject',
181
+ reads: [report],
182
182
  });
183
183
  }
184
184
  ```
@@ -51,26 +51,27 @@ The model key (`weatherReports`) becomes the client namespace
51
51
  contract. You should not create a parallel string-keyed write path for the same
52
52
  data.
53
53
 
54
- ### Reserved fields
54
+ ### The one field you don't declare
55
55
 
56
- The SDK provides these on every row automatically do **not** declare them in
57
- your `model(...)` fields:
56
+ `id` is supplied on every row, so leave it out of your `model(...)` fields. That
57
+ is the whole list.
58
58
 
59
- - `id`
60
- - `createdAt`
61
- - `updatedAt`
62
- - `organizationId`
63
- - `createdBy`
59
+ Two things look like framework territory and are not. **Audit fields are yours to
60
+ declare and yours to fill.** Add `createdAt`, `updatedAt` or `createdBy` and
61
+ `ablo migrate` gives each a column, which your own write or a database default
62
+ then populates; Ablo records who changed what in its transaction log and does not
63
+ write these columns for you. Omit them and no column is created at all; the model
64
+ still reads and writes, it just orders and attributes its history less precisely.
64
65
 
65
- Declare only your own fields; the reserved ones are still present on the row and
66
- readable, you just don't author them.
66
+ **The tenancy column** (`organizationId` by default) comes from the model's
67
+ `policy` rather than its field list, so you neither declare it nor lose it.
67
68
 
68
69
  ## Reads and writes
69
70
 
70
71
  Use async reads when the row may not be local:
71
72
 
72
73
  ```ts
73
- const report = await ablo.weatherReports.get({ id: reportId });
74
+ const report = await ablo.weatherReports.read({ id: reportId });
74
75
  const ready = await ablo.weatherReports.list({ where: { status: 'ready' } });
75
76
  ```
76
77
 
@@ -123,8 +124,8 @@ session route, never a raw API key.
123
124
  - Keep direct database writes out of the coordinated path unless they are reported
124
125
  back through Data Source events.
125
126
  - Use `claim` for slow read -> think -> write spans.
126
- - Use `readAt` + `onStale: 'reject'` when a write must fail if the row changed
127
- after it was read.
127
+ - Use `read` and pass its exact row in `reads` when a write must fail if the row
128
+ changed after it was read.
128
129
 
129
130
  For the shortest runnable path, start with [Quickstart](./quickstart.md). For a
130
131
  production app, continue with [Integration Guide](./integration-guide.md).
@@ -50,6 +50,7 @@ context, whether or not you map anything:
50
50
  | `app.current_participant_id` | The participant making the write |
51
51
  | `app.current_participant_kind` | Whether that participant is a person, an agent, or the system |
52
52
  | `app.current_user_id` | The person on whose behalf the write is made |
53
+ | `app.current_subject_groups` | The subject groups the caller belongs to, as a JSON array of `group:value` strings |
53
54
 
54
55
  If your policies read these names directly, you need no mapping at all — this
55
56
  page is for the case where they read different ones.
@@ -62,6 +63,14 @@ from `app.current_org_id` can be useful as defense in depth, but it is not a
62
63
  substitute for the tenant policy and you should never loosen RLS to make an
63
64
  Ablo write pass.
64
65
 
66
+ `app.current_subject_groups` is the one a model with a `subject` rule reads. A
67
+ subject rule names a field and a group — `subject: { field: 'teamId', group:
68
+ 'team' }` — and Ablo provisions a policy asking whether the array contains
69
+ `team:` followed by that row's value. The setting is always present, and it is
70
+ `[]` when the caller belongs to no group, so a policy on a pooled connection
71
+ reads an empty membership as an empty membership rather than inheriting what the
72
+ previous transaction left behind.
73
+
65
74
  `app.current_user_id` is worth reading twice, because it has three states rather
66
75
  than two. It carries a person's id when a person is behind the write. It carries
67
76
  `*` when a backend credential is acting as the organization itself, which is the
package/docs/sessions.md CHANGED
@@ -74,7 +74,7 @@ plumbing:
74
74
  ```tsx
75
75
  'use client';
76
76
 
77
- import Ablo from '@abloatai/ablo';
77
+ import { Ablo } from '@abloatai/ablo/react';
78
78
  import { AbloProvider } from '@abloatai/ablo/react';
79
79
  import { schema } from '@/ablo.schema';
80
80
 
@@ -30,8 +30,8 @@ For read-reason-write work, pass the exact returned rows that informed the
30
30
  decision. Their watermarks stay opaque:
31
31
 
32
32
  ```ts
33
- const record = await ablo.records.get({ id: recordId });
34
- const policy = await ablo.policies.get({ id: policyId });
33
+ const record = await ablo.records.read({ id: recordId });
34
+ const policy = await ablo.policies.read({ id: policyId });
35
35
  const result = await model({ record, policy });
36
36
  await ablo.records.update({
37
37
  id: record.id,
@@ -21,7 +21,7 @@ if (!recordId) throw new Error('RECORD_ID is required');
21
21
  const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
22
22
  try {
23
23
  await ablo.ready();
24
- const record = await ablo.records.get({ id: recordId });
24
+ const record = await ablo.records.read({ id: recordId });
25
25
  if (!record) throw new Error(`Record ${recordId} was not found`);
26
26
  const commitId = `record:${recordId}:cheap`;
27
27
  await ablo.records.update({
@@ -114,10 +114,14 @@ export const handleAbloSource = dataSource({
114
114
  // own transaction. The example uses a synchronous in-memory
115
115
  // update; the surrounding `apply` helper shows where you would
116
116
  // open `db.transaction(async (tx) => { ... })`.
117
- commit({ operations, clientTxId }) {
117
+ commit({ operations, clientTxId, context }) {
118
+ // The routes an outbox event carries come from the trusted scope Ablo
119
+ // signed into the request, never from the row itself. Ablo adds the
120
+ // organization group on its side; these are the finer ones.
121
+ const syncGroups = context.scope?.syncGroups ?? [];
118
122
  const rows: RecordRow[] = [];
119
123
  for (const op of operations) {
120
- const row = applyOperation(op, clientTxId);
124
+ const row = applyOperation(op, clientTxId, syncGroups);
121
125
  if (row) rows.push(row);
122
126
  }
123
127
  return { rows };
@@ -145,6 +149,7 @@ export const handleAbloSource = dataSource({
145
149
  function applyOperation(
146
150
  op: SourceOperation,
147
151
  clientTxId: string | undefined,
152
+ syncGroups: readonly string[],
148
153
  ): RecordRow | null {
149
154
  if (op.model !== 'records') return null;
150
155
  const id = op.id ?? `record_${Math.random().toString(36).slice(2, 10)}`;
@@ -160,7 +165,7 @@ function applyOperation(
160
165
  : {}),
161
166
  };
162
167
  recordStore.set(id, row);
163
- appendOutbox({ operation: op, entityId: id, data: row, clientTxId });
168
+ appendOutbox({ operation: op, entityId: id, data: row, clientTxId, syncGroups });
164
169
  return row;
165
170
  }
166
171
 
@@ -169,7 +174,7 @@ function applyOperation(
169
174
  if (!existing) return null;
170
175
  const next: RecordRow = { ...existing, ...(op.input as Partial<RecordRow>) };
171
176
  recordStore.set(id, next);
172
- appendOutbox({ operation: op, entityId: id, data: next, clientTxId });
177
+ appendOutbox({ operation: op, entityId: id, data: next, clientTxId, syncGroups });
173
178
  return next;
174
179
  }
175
180
 
@@ -177,7 +182,7 @@ function applyOperation(
177
182
  const existing = recordStore.get(id);
178
183
  if (!existing) return null;
179
184
  recordStore.delete(id);
180
- appendOutbox({ operation: op, entityId: id, data: null, clientTxId });
185
+ appendOutbox({ operation: op, entityId: id, data: null, clientTxId, syncGroups });
181
186
  return existing;
182
187
  }
183
188
 
@@ -189,6 +194,7 @@ function appendOutbox(input: {
189
194
  entityId: string;
190
195
  data: RecordRow | null;
191
196
  clientTxId: string | undefined;
197
+ syncGroups: readonly string[];
192
198
  }): void {
193
199
  outboxSequence += 1;
194
200
  outbox.push(
@@ -197,6 +203,7 @@ function appendOutbox(input: {
197
203
  operation: input.operation,
198
204
  entityId: input.entityId,
199
205
  data: input.data,
206
+ syncGroups: input.syncGroups,
200
207
  ...(input.clientTxId ? { clientTxId: input.clientTxId } : {}),
201
208
  }),
202
209
  );
@@ -49,7 +49,7 @@ try {
49
49
  if (!durable) throw new Error(`Commit ${commitId} was not retained`);
50
50
  console.log({
51
51
  identity: ablo.identity,
52
- readSet: durable.readSet,
52
+ reads: durable.reads,
53
53
  attempts: durable.attempts,
54
54
  claims: durable.claims,
55
55
  authority: durable.authority,
package/llms.txt CHANGED
@@ -6,6 +6,62 @@ Here is the problem it solves. Two agents reach for `report_stockholm` at once.
6
6
 
7
7
  Use AI SDK for the agent loop. Use Ablo when agent reads and writes must persist, coordinate with concurrent work, and leave an audit trail.
8
8
 
9
+ ## When to use Ablo
10
+
11
+ Reach for Ablo when MORE THAN ONE writer changes the same rows and the second one must not clobber the first. That is the whole judgement. If a single process owns the data, an ORM against your database is simpler and you should use it.
12
+
13
+ These are the jobs it is right for:
14
+
15
+ - **Two or more agents on one workload.** A planner and three workers editing the same records; a swarm assigned overlapping tickets; a retry that restarts while the first attempt is still running. Each takes `claim({ id })` before its slow step, so the second waits and is handed the row as the first left it instead of overwriting.
16
+ - **A read → LLM call → write gap.** The row can change during the seconds your model is thinking. Pass the `readAt` stamp from your read back into the write and a stale write is refused (`AbloStaleContextError`) rather than silently applied on top of someone else's.
17
+ - **A human and an agent in the same record.** A person editing in your app is just another holder of the claim. The agent queues behind them; nobody's edit is lost, and neither side needs to know the other exists.
18
+ - **Work that must survive the process.** Commits are durable and receipts are idempotent, so a crashed or re-scheduled agent replays its write with the same `idempotencyKey` and gets the original result instead of a duplicate.
19
+ - **Anything you will later have to explain.** Every accepted change lands in an append-only log with who wrote it, when, and against which read. `logs` answers "what did the agents actually do" without you having instrumented for the question in advance.
20
+ - **Data you are not allowed to move.** Ablo runs on the customer's own Postgres. It tails the replication stream and writes back through a scoped role; your rows never live in Ablo. If a data-residency or procurement rule forbids a vendor holding the records, this is the shape that passes.
21
+
22
+ These are the jobs it is WRONG for, and saying so saves you a wasted integration:
23
+
24
+ - **One writer, no concurrency.** Use Prisma, Drizzle, or SQL. Ablo's coordination costs a round trip and buys you nothing here.
25
+ - **Chat history, traces, or evals.** Append-only telemetry has no contention to resolve. Write it wherever you write logs.
26
+ - **A cache, a queue, or a pub/sub bus.** Ablo is a commit chokepoint over a system of record, not a message broker. Emit your events as usual and use Ablo for the state they are about.
27
+ - **Vector search, embeddings, or retrieval.** Different problem. Ablo coordinates the rows; your vector store indexes them.
28
+ - **A file, blob, or artifact store.** Coordinate the ROW that points at the object; put the bytes in object storage.
29
+
30
+ How to call it, shortest path first: install `@abloatai/ablo`, run `npx ablo init --yes --framework <nextjs|vite|remix|vanilla>`, and construct `Ablo({ schema, apiKey: process.env.ABLO_API_KEY })`. Use `ablo.<model>.get({ id })` to observe a row. Use `const input = await ablo.<model>.read({ id })`, then pass `reads: [input]` to the create/update/delete whose decision used it; if the row moved, that mutation does not land. `list({ where })` is observational. Wrap anything slow and exclusive in `await using claim = await ablo.<model>.claim({ id })`. The sections below cover the rest; "Start here" is the first thing to run.
31
+
32
+ ## Machine-readable surfaces
33
+
34
+ Everything below is public, needs no credential, and is served from `https://www.abloatai.com`:
35
+
36
+ - `/llms.txt` — this file.
37
+ - `/llms-full.txt` — the entire published documentation corpus in one fetch.
38
+ - `/developers` — every developer surface named on one page: the SDK, the API reference, the OpenAPI document, the MCP server, the CLI.
39
+ - `/openapi.json` — the REST contract as OpenAPI 3.1: every route, a stable `operationId` and description on each, typed responses, and the `ErrorEnvelope` every 4xx and 5xx decodes through. Generate a client from it when no SDK exists for your runtime.
40
+ - `/mcp` — the integration-helper MCP server over Streamable HTTP. POST your JSON-RPC here; a GET returns a descriptor rather than the protocol.
41
+ - `/.well-known/mcp` — the same Streamable HTTP server at its direct discovery endpoint, with no page/transport negotiation.
42
+ - `/.well-known/mcp.json` — that server's manifest, in the MCP registry's `server.json` format.
43
+ - `/api/docs/<page>` — any documentation page as plain Markdown, for a client that fetches URLs rather than speaking MCP.
44
+ - `/sitemap.xml` — every indexable page on the domain.
45
+
46
+ Pricing and plan limits are public at [Ablo pricing](https://docs.abloatai.com/pricing). Do not
47
+ infer prices or follow a dashboard billing link: the documentation page is the canonical rate
48
+ card and needs no sign-in.
49
+
50
+ Every page on that host serves a Markdown representation from its own URL: send
51
+ `Accept: text/markdown` (q-values are honoured), or append `.md` to the path if
52
+ your client cannot set the header. Responses carry `Vary: Accept`, a client that
53
+ will accept neither `text/html` nor `text/markdown` gets a `406` listing what is
54
+ available, and a path that does not exist answers a real `404` — never a `200`
55
+ carrying a sign-in page.
56
+
57
+ ## Versioning and deprecation
58
+
59
+ Every route lives under `/v1`, and that segment is part of the address you call. A change that would break you arrives as a new segment beside it, never as a change to this one. Additive changes do land in `/v1` — a new response field, a new optional parameter, a new error code — so ignore what you do not recognize.
60
+
61
+ Every response carries `Ablo-Version`, a date stamp for the contract being served. A route being withdrawn says so on itself for at least 180 days first: `Deprecation` (RFC 9745) carries when the deprecation took effect and the route keeps answering; `Sunset` (RFC 8594) carries when it stops. The same operations are marked `deprecated: true` in `/openapi.json`.
62
+
63
+ Responses also carry `RateLimit-Policy` (the standing allowance, e.g. `"secret";q=600;w=12`) and, once your request is attributed to a key, `RateLimit` (what is left and when it refills). A 429 adds `Retry-After` in whole seconds. Pace against these rather than retrying blind.
64
+
9
65
  ## Surfaces: pick by who is calling
10
66
 
11
67
  Every surface reaches the same coordinated state. They are not interchangeable.
@@ -14,6 +70,7 @@ Every surface reaches the same coordinated state. They are not interchangeable.
14
70
  - Coordination MCP, `@abloatai/mcp` — an agent living inside an MCP host (Claude, Cursor). Two jobs: MANAGING your Ablo the way the CLI does (`get_schema`, `list_projects`, `create_project`, `tail_logs`, `get_usage`), and claim/commit as tools over your rows. Call `get_schema` first — it is the only way to learn the model names every data tool needs. `init`, `push`, `pull`, `generate` have no tools (they touch your repo); run those in a shell.
15
71
  - `humans()` with `@abloatai/humans/react` — the interfaces a person watches agent work arrive in. The bare client is the coordination layer (commit, read, observe, claim); `humans()` is the plugin that adds the local watchable copy, live queries, and presence. It `requires: { duplex: true }`, so a `transport: 'http'` agent is rejected at construction rather than left holding a subscription that never delivers. It remains the compatibility default for omitted `plugins`; new code should install it explicitly from `@abloatai/humans`. There is no `agents()` plugin — agents are the default caller, not a special one. A browser NEVER receives the secret key; mint a session token.
16
72
  - CLI, `ablo` — scaffolding, schema push, connecting a database. Terminals and CI. Agents must run it non-interactively (see the CLI section below).
73
+ - CLI package, [`@abloatai/cli`](https://www.npmjs.com/package/@abloatai/cli) — the official npm package that installs the `ablo` binary.
17
74
  - REST, `/api/v1` — runtimes with no SDK.
18
75
  - Integration-helper MCP, hosted `/api/mcp` — teaching a coding assistant the SDK WHILE BUILDING an integration. Docs, schema lint, and scaffolds only; it has NO per-model data tools and cannot touch application rows.
19
76
 
@@ -52,7 +109,7 @@ const schema = defineSchema({
52
109
 
53
110
  const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
54
111
 
55
- const report = await ablo.weatherReports.get({ id: 'report_stockholm' });
112
+ const report = await ablo.weatherReports.read({ id: 'report_stockholm' });
56
113
  if (!report) throw new Error('Row not found');
57
114
 
58
115
  // Claim the row (waits if someone else holds it), read the fresh copy off
@@ -64,7 +121,7 @@ const updated = await ablo.weatherReports.update({
64
121
  });
65
122
  ```
66
123
 
67
- That is the normal app path: declare models in a schema, then use `ablo.<model>.get({ id })`, `ablo.<model>.create(...)`, `ablo.<model>.update(...)`, and `ablo.<model>.delete(...)`. Every verb takes a single options object.
124
+ That is the normal app path: declare models in a schema, then use `ablo.<model>.read({ id })`, `ablo.<model>.create(...)`, `ablo.<model>.update(...)`, and `ablo.<model>.delete(...)`. Every verb takes a single options object.
68
125
 
69
126
  Treat the schema as the integration contract. It drives typed model clients,
70
127
  React selectors, server and agent writes, Data Source request/response shape,
@@ -75,12 +132,12 @@ For full integrations, use `integration-guide` as the canonical doc. It covers
75
132
  the same model API across your own Data Source-backed app databases,
76
133
  React selectors, multiplayer, and future agent workers.
77
134
 
78
- Reads come in two flavors, and you pick by whether you can wait. `get({ id })`
79
- (one row) and `list({ where })` (many) are async they answer from the local
80
- graph and fall back to the server, so await them. `local.get(id)`,
135
+ Server reads separate observation from decision input. `get({ id })` and
136
+ `list({ where })` return current data without creating stale evidence.
137
+ `read({ id })` returns the same row with private `{ model, id, readAt }` evidence
138
+ for a specific mutation's `reads` array. `local.get(id)`,
81
139
  `local.list({ where })`, and `local.count({ where })` are the same reads narrowed
82
- to what has already synced: nothing to await, reactive in render. There is no
83
- second verb to learn — `local.` is the only difference. The query reads accept
140
+ to what has already synced: nothing to await, reactive in render. The collection queries accept
84
141
  `where`, `filter`, `orderBy`, `limit`, `offset`,
85
142
  and `state`; state defaults to `'live'`, with `'archived'` and `'all'` to include
86
143
  retired rows.
@@ -117,7 +174,7 @@ coordination until the app reports it through Data Source events.
117
174
 
118
175
  ## Change propagation
119
176
 
120
- A change to one row reaches other rows three ways. ROUTING: a write fans out to every sync group the row belongs to, INCLUDING its ancestors' groups (editing a block routes to `block:` + `document:` + `workspace:`), so everyone watching the workspace sees it — delivery, not recomputation. DELETE CASCADE: deleting a parent emits explicit tombstone deltas for its descendants, so open clients never silently hold rows that are gone. VALUE: derived values are NOT recomputed server-side — Ablo surfaces that the source moved and the actor decides. To keep dependent work fresh, declare what you read as a batch premise: `reads: [{ group: 'workspace:abc', readAt: N, onStale: 'notify' }]`. At commit the server checks whether anything in that group moved past `readAt`; `notify` holds the write and returns a `StaleNotification` (re-read the group and regenerate), `reject` aborts. To chain A→B→C, put A+B in one group and B+C in another: A's change reaches B, and C hears it only once B ITSELF writes — the engine wires the edges and signals each hop, the actor walks them. No transitive auto-recompute, no convergence guarantee for cycles. `reads` guards ONE commit; for a long-running actor that reads now and writes much later, register a DURABLE premise with `track`: `ablo.<model>.track({ id })` for a row, or the `track:` write option (`track: [{ group: 'workspace:abc' }]`) alongside a write. A track persists server-side; the next time you commit anything, a change that landed on the tracked target rides back on your receipt's `notifications` (same `StaleNotification` shape as `onStale: 'notify'`). It is an idempotent registration, re-baselines so a change fires once, and never notifies you of your own writes. Delivery is on your next commit — a track does not yet push out of band between commits.
177
+ A change to one row reaches other rows three ways. ROUTING: a write fans out to every sync group the row belongs to, INCLUDING its ancestors' groups (editing a block routes to `block:` + `document:` + `workspace:`), so everyone watching the workspace sees it — delivery, not recomputation. DELETE CASCADE: deleting a parent emits explicit tombstone deltas for its descendants, so open clients never silently hold rows that are gone. VALUE: derived values are NOT recomputed server-side — Ablo surfaces that the source moved and the actor decides. To keep dependent work fresh, pass rows returned by `ablo.<model>.read({ id })` in the mutation's `reads` array. Ablo records compact model/id/readAt evidence, not row contents. At commit the server checks whether anything moved past the read watermark; if so, the mutation does not land. Use `get` when no such relationship exists. To chain A→B→C, put A+B in one group and B+C in another: A's change reaches B, and C hears it only once B ITSELF writes. No transitive auto-recompute, no convergence guarantee for cycles.
121
178
 
122
179
  ## Nouns
123
180
 
@@ -129,7 +186,7 @@ A change to one row reaches other rows three ways. ROUTING: a write fans out to
129
186
  ## Claimed Behavior
130
187
 
131
188
  Reads never silently block. Schema reads stay open while a row is claimed.
132
- Typed reads through `ablo.<model>.get({ id })` return the row by default,
189
+ Typed reads through `ablo.<model>.read({ id })` return the row by default,
133
190
  even while it is claimed. Pass `ifClaimed: 'fail'` to throw
134
191
  `AbloClaimedError`, and inspect active coordination separately through
135
192
  `ablo.<model>.claim.state({ id })`.
@@ -147,7 +204,7 @@ frees. Use `ifClaimed: 'fail'` when you'd rather refuse to read a claimed row.
147
204
 
148
205
  ## Guarantees
149
206
 
150
- Schema model writes update local state optimistically, while their returned promise always waits for authoritative confirmation. Server rejection rolls back local state. To prevent lost updates, read with `snapshot(...)` to capture a `readAt`, then write with `onStale: 'reject'` the server rejects your update if someone else changed the row after that `readAt`.
207
+ Schema model writes update local state optimistically, while their returned promise always waits for authoritative confirmation. Server rejection rolls back local state. To prevent lost updates, fetch the decision input with `read({ id })` and pass that exact row in the mutation's `reads` array. If it changed, the mutation does not land.
151
208
 
152
209
  Claims coordinate writers; they do not block readers. Most users should stay on
153
210
  schema-backed reads/writes and `claim(...)`; manual protocol bookkeeping is not
@@ -231,6 +288,11 @@ Import from these public paths only:
231
288
 
232
289
  Do not teach `/api`, `/agent`, `/core`, `/realtime`, or internal subpaths. (`/source` and `/ai-sdk` are public.)
233
290
 
291
+ `onChange` is WebSocket-only. It exists on the reactive client because that
292
+ client holds an open socket and a local graph. The stateless HTTP client does
293
+ not expose it at the type or runtime level; HTTP agents re-read or inspect
294
+ durable logs instead.
295
+
234
296
  ## CLI: agents run it NON-INTERACTIVELY
235
297
 
236
298
  `ablo init` and other prompts need a TTY; an agent/CI run has none and will HANG. Always:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.56.0",
3
+ "version": "0.58.0",
4
4
  "description": "The public Ablo SDK for coordinated reads, commits, claims, observation, and reactive applications.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -81,9 +81,9 @@
81
81
  "default": "./dist/ai-sdk.js"
82
82
  },
83
83
  "./context": {
84
- "types": "./dist/context.d.ts",
85
- "import": "./dist/context.js",
86
- "default": "./dist/context.js"
84
+ "types": "./dist/context/index.d.ts",
85
+ "import": "./dist/context/index.js",
86
+ "default": "./dist/context/index.js"
87
87
  },
88
88
  "./wire": {
89
89
  "types": "./dist/wire.d.ts",
@@ -139,8 +139,8 @@
139
139
  "directory": "packages/ablo"
140
140
  },
141
141
  "dependencies": {
142
- "@abloatai/humans": "^0.56.0",
143
- "@abloatai/transaction": "^0.56.0",
142
+ "@abloatai/humans": "^0.58.0",
143
+ "@abloatai/transaction": "^0.58.0",
144
144
  "zod": "^4.4.3"
145
145
  },
146
146
  "peerDependencies": {
@@ -1,21 +0,0 @@
1
- import { z } from 'zod';
2
- import type { ContextEvidenceSlice } from './evidence.js';
3
- export declare const contextSourceSchema: z.ZodReadonly<z.ZodDiscriminatedUnion<[z.ZodObject<{
4
- key: z.ZodString;
5
- kind: z.ZodLiteral<"ablo">;
6
- guarantee: z.ZodLiteral<"guardable">;
7
- cursor: z.ZodNumber;
8
- }, z.core.$strip>, z.ZodObject<{
9
- key: z.ZodString;
10
- kind: z.ZodLiteral<"value">;
11
- guarantee: z.ZodLiteral<"informational">;
12
- cursor: z.ZodNull;
13
- }, z.core.$strip>, z.ZodObject<{
14
- key: z.ZodString;
15
- kind: z.ZodLiteral<"mixed">;
16
- guarantee: z.ZodLiteral<"partial">;
17
- cursor: z.ZodNumber;
18
- }, z.core.$strip>], "kind">>;
19
- export type ContextSource = z.infer<typeof contextSourceSchema>;
20
- export declare function sourceFor(key: string, evidence: ContextEvidenceSlice): ContextSource;
21
- //# sourceMappingURL=sources.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"sources.d.ts","sourceRoot":"","sources":["../../src/context/sources.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAE1D,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;4BAmBnB,CAAC;AAEd,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEhE,wBAAgB,SAAS,CACvB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,oBAAoB,GAC7B,aAAa,CAaf"}
@@ -1,36 +0,0 @@
1
- import { z } from 'zod';
2
- export const contextSourceSchema = z.discriminatedUnion('kind', [
3
- z.object({
4
- key: z.string(),
5
- kind: z.literal('ablo'),
6
- guarantee: z.literal('guardable'),
7
- cursor: z.number().int().nonnegative(),
8
- }),
9
- z.object({
10
- key: z.string(),
11
- kind: z.literal('value'),
12
- guarantee: z.literal('informational'),
13
- cursor: z.null(),
14
- }),
15
- z.object({
16
- key: z.string(),
17
- kind: z.literal('mixed'),
18
- guarantee: z.literal('partial'),
19
- cursor: z.number().int().nonnegative(),
20
- }),
21
- ]).readonly();
22
- export function sourceFor(key, evidence) {
23
- if (evidence.reads.length === 0) {
24
- return contextSourceSchema.parse({
25
- key,
26
- kind: 'value',
27
- guarantee: 'informational',
28
- cursor: null,
29
- });
30
- }
31
- const cursor = Math.max(...evidence.reads.map((item) => item.entry.watermark));
32
- return evidence.includesInformational
33
- ? contextSourceSchema.parse({ key, kind: 'mixed', guarantee: 'partial', cursor })
34
- : contextSourceSchema.parse({ key, kind: 'ablo', guarantee: 'guardable', cursor });
35
- }
36
- //# sourceMappingURL=sources.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"sources.js","sourceRoot":"","sources":["../../src/context/sources.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,kBAAkB,CAAC,MAAM,EAAE;IAC9D,CAAC,CAAC,MAAM,CAAC;QACP,GAAG,EAAE,CAAC,CAAC,MAAM,EAAE;QACf,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC;QACvB,SAAS,EAAE,CAAC,CAAC,OAAO,CAAC,WAAW,CAAC;QACjC,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,WAAW,EAAE;KACvC,CAAC;IACF,CAAC,CAAC,MAAM,CAAC;QACP,GAAG,EAAE,CAAC,CAAC,MAAM,EAAE;QACf,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC;QACxB,SAAS,EAAE,CAAC,CAAC,OAAO,CAAC,eAAe,CAAC;QACrC,MAAM,EAAE,CAAC,CAAC,IAAI,EAAE;KACjB,CAAC;IACF,CAAC,CAAC,MAAM,CAAC;QACP,GAAG,EAAE,CAAC,CAAC,MAAM,EAAE;QACf,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC;QACxB,SAAS,EAAE,CAAC,CAAC,OAAO,CAAC,SAAS,CAAC;QAC/B,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,WAAW,EAAE;KACvC,CAAC;CACH,CAAC,CAAC,QAAQ,EAAE,CAAC;AAId,MAAM,UAAU,SAAS,CACvB,GAAW,EACX,QAA8B;IAE9B,IAAI,QAAQ,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAChC,OAAO,mBAAmB,CAAC,KAAK,CAAC;YAC/B,GAAG;YACH,IAAI,EAAE,OAAO;YACb,SAAS,EAAE,eAAe;YAC1B,MAAM,EAAE,IAAI;SACb,CAAC,CAAC;IACL,CAAC;IACD,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC;IAC/E,OAAO,QAAQ,CAAC,qBAAqB;QACnC,CAAC,CAAC,mBAAmB,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC;QACjF,CAAC,CAAC,mBAAmB,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,EAAE,CAAC,CAAC;AACvF,CAAC"}
package/dist/context.d.ts DELETED
@@ -1,22 +0,0 @@
1
- import type { CapturedRow } from '@abloatai/transaction';
2
- import { type AwaitedDeep } from './context/await.js';
3
- import { type ContextSource } from './context/sources.js';
4
- export { contextSourceSchema, type ContextSource } from './context/sources.js';
5
- export type { AwaitedDeep } from './context/await.js';
6
- export interface ContextOptions<TData extends Readonly<Record<string, unknown>>> {
7
- /** The client whose read evidence may guard a later write. */
8
- readonly ablo: object;
9
- /** Values selected by the application. Nested promises are accepted. */
10
- readonly data: TData;
11
- }
12
- export interface ContextResult<TData extends Readonly<Record<string, unknown>>> {
13
- readonly data: AwaitedDeep<TData>;
14
- /** Exact returned Ablo rows, ready to pass to a write's `reads` option. */
15
- readonly reads: readonly CapturedRow[];
16
- /** The greatest watermark among included authoritative reads. */
17
- readonly cursor: number | null;
18
- readonly sources: readonly ContextSource[];
19
- }
20
- /** Assemble selected application values and the Ablo evidence they retain. */
21
- export declare function context<const TData extends Readonly<Record<string, unknown>>>(options: ContextOptions<TData>): Promise<ContextResult<TData>>;
22
- //# sourceMappingURL=context.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,EAAa,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEjE,OAAO,EAAa,KAAK,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAErE,OAAO,EAAE,mBAAmB,EAAE,KAAK,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAC/E,YAAY,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAItD,MAAM,WAAW,cAAc,CAAC,KAAK,SAAS,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC7E,8DAA8D;IAC9D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,wEAAwE;IACxE,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC;CACtB;AAED,MAAM,WAAW,aAAa,CAAC,KAAK,SAAS,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5E,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC,KAAK,CAAC,CAAC;IAClC,2EAA2E;IAC3E,QAAQ,CAAC,KAAK,EAAE,SAAS,WAAW,EAAE,CAAC;IACvC,iEAAiE;IACjE,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,SAAS,aAAa,EAAE,CAAC;CAC5C;AAED,8EAA8E;AAC9E,wBAAsB,OAAO,CAAC,KAAK,CAAC,KAAK,SAAS,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EACjF,OAAO,EAAE,cAAc,CAAC,KAAK,CAAC,GAC7B,OAAO,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CAsB/B"}