@abloatai/ablo 0.12.0 → 0.13.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 (45) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +3 -3
  4. package/dist/cli.cjs +149 -40
  5. package/dist/schema/index.d.ts +2 -2
  6. package/dist/schema/index.js +2 -2
  7. package/dist/schema/model.d.ts +38 -84
  8. package/dist/schema/model.js +12 -12
  9. package/dist/schema/roles.d.ts +49 -0
  10. package/dist/schema/roles.js +21 -0
  11. package/dist/schema/schema.d.ts +1 -1
  12. package/dist/schema/schema.js +1 -1
  13. package/dist/schema/serialize.d.ts +4 -2
  14. package/dist/schema/serialize.js +4 -2
  15. package/dist/schema/sugar.d.ts +7 -28
  16. package/dist/schema/sugar.js +2 -7
  17. package/dist/schema/sync-delta-row.d.ts +2 -0
  18. package/dist/schema/sync-delta-row.js +2 -1
  19. package/dist/schema/tenancy.d.ts +67 -28
  20. package/dist/schema/tenancy.js +93 -23
  21. package/dist/server/commit.d.ts +8 -3
  22. package/docs/api.md +1 -1
  23. package/docs/cli.md +43 -4
  24. package/docs/client-behavior.md +2 -2
  25. package/docs/coordination.md +1 -1
  26. package/docs/examples/agent-human.md +6 -6
  27. package/docs/examples/ai-sdk-tool.md +1 -1
  28. package/docs/examples/existing-python-backend.md +0 -2
  29. package/docs/examples/nextjs.md +2 -2
  30. package/docs/examples/scoped-agent.md +3 -3
  31. package/docs/examples/server-agent.md +4 -4
  32. package/docs/identity.md +27 -20
  33. package/docs/index.md +0 -1
  34. package/docs/integration-guide.md +12 -9
  35. package/docs/interaction-model.md +1 -1
  36. package/docs/mcp.md +17 -5
  37. package/docs/quickstart.md +3 -3
  38. package/llms.txt +2 -3
  39. package/package.json +3 -2
  40. package/docs/mcp/claude-code.md +0 -35
  41. package/docs/mcp/cursor.md +0 -35
  42. package/docs/mcp/windsurf.md +0 -33
  43. package/docs/roadmap.md +0 -55
  44. package/docs/the-loop.md +0 -21
  45. package/llms-full.txt +0 -396
package/docs/cli.md CHANGED
@@ -24,18 +24,23 @@ direct-connector commands are tagged **Direct Postgres**.
24
24
 
25
25
  `ablo login` runs the OAuth 2.0 device flow: it opens your browser, you choose
26
26
  **log in** or **create an account** and approve, and the CLI provisions a
27
- **test + live key pair** (90-day, restricted) and stores them locally. This
28
- mirrors `stripe login`.
27
+ **test + live key pair** (90-day) and stores them locally. The test key is a
28
+ sandbox `sk_test_` key; the live key is a restricted `rk_live_` key (read-only
29
+ observation — `logs`, `status`), so a stolen config can't write to production.
30
+ This mirrors `stripe login`.
29
31
 
30
32
  | Command | What it does |
31
33
  | ------------------------ | -------------------------------------------------------------------------- |
32
34
  | `ablo login` | Authorize in the browser; provisions + stores a test and a live key. |
35
+ | `ablo login --project <slug>` | Same, but scope (and mint) the pair to a project, and make it active. |
33
36
  | `ablo logout` | Remove the stored keys. |
34
37
  | `ablo status` | Show the active org, mode, both keys (prefix + expiry), and server health. |
35
38
  | `ablo mode [sandbox\|production]` | Switch the active environment. With no argument, prompts. |
36
39
 
37
- Keys are stored in `~/.config/ablo/config.json` (mode `0600`). In **CI**, don't
38
- log in set `ABLO_API_KEY`, which always overrides the stored key.
40
+ Keys live in `~/.config/ablo/credentials.json` (mode `0600`), keyed by project
41
+ then environment; the non-secret `config.json` holds only the active mode and
42
+ project. In **CI**, don't log in — set `ABLO_API_KEY`, which always overrides
43
+ the stored key.
39
44
 
40
45
  ## Test vs live
41
46
 
@@ -47,6 +52,39 @@ the sandbox by design.
47
52
  The schema, however, is **shared** across the org — pushing a schema (from
48
53
  either environment) defines the same models sandbox and production see; only the rows differ.
49
54
 
55
+ ## Projects
56
+
57
+ An org can have multiple **projects**, each with its own isolated keys, schema,
58
+ and data. Keys are scoped to a project **at mint** and never re-scoped, so the
59
+ CLI keeps a separate credential profile per project — Stripe's
60
+ `login --project-name` model. The active project (set with `projects use`)
61
+ selects which profile every command authenticates with.
62
+
63
+ | Command | What it does |
64
+ | ----------------------------- | ---------------------------------------------------------------------------------- |
65
+ | `ablo projects list` | List the org's projects (marks the active one and the org-default). |
66
+ | `ablo projects create <slug>` | Create a project (`--name "Display Name"`). Its keys/schema/data are isolated. |
67
+ | `ablo projects use <slug>` | Switch the active project. `ablo projects use default` returns to the org-default. |
68
+ | `ablo login --project <slug>` | Mint and store a key pair for a project, and make it active. |
69
+
70
+ Because keys are fixed to a project, `projects use` only changes which profile
71
+ is active — it never re-scopes an existing key. Switch to a project you haven't
72
+ logged into yet and the CLI tells you to mint one:
73
+
74
+ ```bash
75
+ npx ablo projects use war-room
76
+ # ✓ now targeting project war-room (prj_…)
77
+ # No key stored for this project yet — run `ablo login --project war-room` to mint one.
78
+
79
+ npx ablo login --project war-room # mints + stores its key pair, keeps it active
80
+ ```
81
+
82
+ If you run a project-scoped command (`push`, `dev`) while the active project has
83
+ no key — but other projects do — the CLI **refuses** rather than silently
84
+ deploying with the wrong project's credential, and names the fix
85
+ (`ablo login --project <slug>`). In CI, an explicit `ABLO_API_KEY` bypasses
86
+ profiles entirely: it acts in whatever project it was minted for.
87
+
50
88
  ## Commands
51
89
 
52
90
  | Command | What it does | Flags |
@@ -54,6 +92,7 @@ either environment) defines the same models sandbox and production see; only the
54
92
  | `ablo init` | Scaffold `ablo/` (`schema.ts`, client, optional Data Source / agent / component), write `.env`, install the SDK. Offers to log in at the end. | — |
55
93
  | `ablo login` / `logout` / `status` | Authentication & status (above). | — |
56
94
  | `ablo mode [sandbox\|production]` | Switch active environment. | — |
95
+ | `ablo projects list\|create\|use\|rename` | Manage projects and the active one (see [Projects](#projects)). Each project's keys/schema/data are isolated. | `--name "<display>"` (create/rename) |
57
96
  | `ablo dev` | **Hosted** — push the schema to your test sandbox, then watch `ablo/schema.ts` and re-push on save. | `--no-watch`, `--schema <path>`, `--export <name>`, `--url <url>` |
58
97
  | `ablo logs` | Tail your scope's commit activity (`stripe logs tail`). Follows by default. | `-n, --tail <N>`, `--since <dur\|ts>`, `--model`, `--op`, `--json`, `--no-follow`, `--mode sandbox\|production` |
59
98
  | `ablo push` | **Hosted** — upload the schema to Ablo; the server diffs, migrates, and activates it. | `--force`, `--rename old:new`, `--backfill model.field=value`, `--schema`, `--export`, `--url` |
@@ -146,7 +146,7 @@ the latest row, then hands you the fresh row — so you can't overwrite a change
146
146
  see. Options on the claim:
147
147
 
148
148
  - default `claim` waits in the fair queue and re-reads before handing you the row;
149
- - `{ wait: false }` rejects with `AbloClaimedError` instead of queuing;
149
+ - `{ queue: false }` rejects with `AbloClaimedError` instead of queuing;
150
150
  - `{ maxQueueDepth }` rejects if the wait line is already too deep.
151
151
 
152
152
  While waiting, schema clients learn when the claim clears from the live claim
@@ -166,7 +166,7 @@ All SDK errors extend `AbloError` and carry a stable `type`.
166
166
  | `AbloValidationError` | Invalid input or unsupported request shape. |
167
167
  | `AbloServerError` | Server-side 5xx. Retry with backoff if the operation is idempotent. |
168
168
  | `AbloStaleContextError` | Write was based on stale `readAt` state. Re-read and retry. |
169
- | `AbloClaimedError` | An active claim conflicted with `{ wait: false }`, the queue was too deep, or a claim wait timed out. |
169
+ | `AbloClaimedError` | An active claim conflicted with `{ queue: false }`, the queue was too deep, or a claim wait timed out. |
170
170
 
171
171
  ```ts
172
172
  import { AbloClaimedError } from '@abloatai/ablo';
@@ -280,7 +280,7 @@ Releasing **promotes the head of the queue**: the next waiter receives the claim
280
280
  **Example**
281
281
 
282
282
  ```ts
283
- const claim = await ablo.weatherReports.claim({ id: 'report_stockholm', action: 'reviewing' });
283
+ const claim = await ablo.weatherReports.claim({ id: 'report_stockholm', reason: 'reviewing' });
284
284
  const report = claim.data;
285
285
  try {
286
286
  const ok = await reviewExternally(report);
@@ -47,14 +47,14 @@ export async function markReady(reportId: string) {
47
47
  if (!report) return { status: 'not_found' };
48
48
 
49
49
  try {
50
- // wait: false → don't queue behind a current holder. If a human already
50
+ // queue: false → don't queue behind a current holder. If a human already
51
51
  // holds the row, claim rejects with AbloClaimedError (caught below), so the
52
- // agent yields instead of waiting. Omit it, or pass wait: true, to queue
53
- // behind them. action → the label observers see while we work.
52
+ // agent yields instead of waiting. Omit it, or pass queue: true, to queue
53
+ // behind them. reason → the label observers see while we work.
54
54
  await using claim = await ablo.weatherReports.claim({
55
55
  id: reportId,
56
- wait: false,
57
- action: 'marking_ready',
56
+ queue: false,
57
+ reason: 'marking_ready',
58
58
  });
59
59
  const claimed = claim.data;
60
60
 
@@ -120,7 +120,7 @@ export function ReportRow({ report: serverReport }: Props) {
120
120
  - The claim is visible to everyone: the UI reads it synchronously with
121
121
  `claim.state({ id })`, and it also arrives over the live stream.
122
122
  - `claim({ id })` makes writers take turns instead of racing — with
123
- `wait: false`, the agent simply yields when a human already holds the row.
123
+ `queue: false`, the agent simply yields when a human already holds the row.
124
124
  - The `update` made while the claim is held is stale-checked automatically, so a human's
125
125
  edit landing mid-run rejects the agent's write with a typed
126
126
  `AbloStaleContextError` instead of overwriting it.
@@ -43,7 +43,7 @@ const updateReport = tool({
43
43
  // The claim is released automatically when it goes out of scope.
44
44
  await using claim = await ablo.weatherReports.claim({
45
45
  id: reportId,
46
- action: 'editing',
46
+ reason: 'editing',
47
47
  ttl: '2m',
48
48
  });
49
49
  const claimed = claim.data;
@@ -37,10 +37,8 @@ import { defineSchema, model, z } from '@abloatai/ablo/schema';
37
37
 
38
38
  export const schema = defineSchema({
39
39
  weatherReports: model({
40
- id: z.string(),
41
40
  location: z.string(),
42
41
  status: z.enum(['pending', 'ready']),
43
- updatedAt: z.string(),
44
42
  }),
45
43
  });
46
44
  ```
@@ -57,8 +57,8 @@ import { ablo } from '@/lib/ablo';
57
57
  export async function markReady(id: string) {
58
58
  await using claim = await ablo.weatherReports.claim({
59
59
  id,
60
- wait: false,
61
- action: 'marking_ready',
60
+ queue: false,
61
+ reason: 'marking_ready',
62
62
  });
63
63
  const claimed = claim.data;
64
64
 
@@ -19,18 +19,18 @@ import { defineSchema, identityRole, model, relation, z } from '@abloatai/ablo/s
19
19
 
20
20
  export const schema = defineSchema(
21
21
  {
22
- // A deck's rows form the group `deck:<id>` (the kind comes from `scope`).
22
+ // A deck's rows form the group `deck:<id>` (the kind comes from `groups.root`).
23
23
  decks: model(
24
24
  { title: z.string() },
25
25
  {},
26
- { orgScoped: true, scope: 'deck' },
26
+ { groups: { root: 'deck' } },
27
27
  ),
28
28
  // A slide has no group of its own. It inherits its deck's group via the
29
29
  // `parent` edge, so a slide write reaches everyone viewing the deck.
30
30
  slides: model(
31
31
  { deckId: z.string(), body: z.string() },
32
32
  { deck: relation.belongsTo('decks', 'deckId', { parent: true }) },
33
- { orgScoped: true },
33
+ {},
34
34
  ),
35
35
  },
36
36
  {
@@ -38,8 +38,8 @@ export async function completeReport(reportId: string) {
38
38
 
39
39
  await using claim = await ablo.weatherReports.claim({
40
40
  id: reportId,
41
- wait: false,
42
- action: 'completing',
41
+ queue: false,
42
+ reason: 'completing',
43
43
  });
44
44
  const claimed = claim.data;
45
45
 
@@ -60,9 +60,9 @@ the server has accepted it.
60
60
 
61
61
  The two options on the claim:
62
62
 
63
- - `wait: false` — skip this record if another claim is already in progress,
63
+ - `queue: false` — skip this record if another claim is already in progress,
64
64
  rather than queueing behind it. (The default queues.)
65
- - `action: 'completing'` — a human-readable label for what your worker is doing,
65
+ - `reason: 'completing'` — a human-readable label for what your worker is doing,
66
66
  visible to anyone reading `claim.state({ id })`.
67
67
 
68
68
  Because the worker uses the same schema and `claim()` as the UI, its writes sync
package/docs/identity.md CHANGED
@@ -47,18 +47,20 @@ import { defineSchema, identityRole, relation, model, z } from '@abloatai/ablo/s
47
47
 
48
48
  export const schema = defineSchema(
49
49
  {
50
- // A scope root: its rows form the group `deck:<id>` (kind from `scope`).
50
+ // A scope root: its rows form the group `deck:<id>` (kind from `groups.root`).
51
+ // Tenant isolation defaults to a row-local `organization_id` column, so no
52
+ // `policy` is needed here.
51
53
  decks: model(
52
54
  { title: z.string(), status: z.enum(['draft', 'published']) },
53
55
  {},
54
- { orgScoped: true, scope: 'deck' },
56
+ { groups: { root: 'deck' } },
55
57
  ),
56
58
  // A child: it has no group of its own; it inherits its deck's group via the
57
59
  // `parent` edge. A write to a slide reaches everyone viewing the deck.
58
60
  slides: model(
59
61
  { deckId: z.string() },
60
62
  { deck: relation.belongsTo('decks', 'deckId', { parent: true }) },
61
- { orgScoped: true },
63
+ {},
62
64
  ),
63
65
  },
64
66
  {
@@ -208,13 +210,13 @@ You never write a sync-group string for a row. You declare a model's *place* in
208
210
  the entity graph and the engine derives the groups its rows fan out on. Three
209
211
  declarations, in order of how often you reach for them:
210
212
 
211
- **`scope` — this model is a scope root.** Its rows form a group of their own.
212
- The kind comes from the model's `typename` by default, or pass a string to set
213
- it explicitly (use the string form when the wire kind differs from the typename,
214
- e.g. typename `SlideDeck` but group `deck:<id>`):
213
+ **`groups.root` — this model is a scope root.** Its rows form a group of their
214
+ own. The kind comes from the model's `typename` by default, or pass a string to
215
+ set it explicitly (use the string form when the wire kind differs from the
216
+ typename, e.g. typename `SlideDeck` but group `deck:<id>`):
215
217
 
216
218
  ```ts
217
- decks: model({ title: z.string() }, {}, { orgScoped: true, scope: 'deck' });
219
+ decks: model({ title: z.string() }, {}, { groups: { root: 'deck' } });
218
220
  // a deck row → group `deck:<id>`
219
221
  ```
220
222
 
@@ -232,7 +234,7 @@ slides: model(
232
234
  deck: relation.belongsTo('decks', 'deckId', { parent: true }), // ownership → inherit deck:<id>
233
235
  sourceSlide: relation.belongsTo('slides', 'sourceSlideId'), // reference → NOT routed
234
236
  },
235
- { orgScoped: true },
237
+ {}, // default policy: row-local organization_id
236
238
  );
237
239
  ```
238
240
 
@@ -241,8 +243,8 @@ slides: model(
241
243
  > some required FKs are mere references. Containment is a fact only you know, so
242
244
  > it's declared, exactly as it is in OpenFGA/Zanzibar.
243
245
 
244
- **`grants` — a membership edge.** On a join model (e.g. `dataroomMember`), it
245
- says "this row grants a *subject* access to a *scope root*." Both are relation
246
+ **`groups.grants` — a membership edge.** On a join model (e.g. `dataroomMember`),
247
+ it says "this row grants a *subject* access to a *scope root*." Both are relation
246
248
  names on the model. The server resolves it at connect time — for user `U`, it
247
249
  finds the scope-root groups `U` is a member of and adds them to `U`'s allowed
248
250
  set (Linear's `/sync/user_sync_groups`). Use this for sub-org sharing; plain
@@ -255,15 +257,19 @@ dataroomMember: model(
255
257
  member: relation.belongsTo('users', 'userId'),
256
258
  room: relation.belongsTo('datarooms', 'dataroomId'),
257
259
  },
258
- { orgScoped: true, grants: { subject: 'member', scope: 'room' } },
260
+ { groups: { grants: { subject: 'member', scope: 'room' } } },
259
261
  );
260
262
  ```
261
263
 
262
264
  For the rare group keyed on a plain field rather than a relation (per-recipient
263
- inbox fan-out, say), there's an `entityRoles: [entityRole({ kind, source })]`
265
+ inbox fan-out, say), there's a `groups: { roles: [entityRole({ kind, source })] }`
264
266
  escape hatch. For rows that inherit *tenancy* (not a sync group) through a
265
- foreign key without carrying `organization_id`, use `scopedVia` rather than
266
- `orgScoped: false` the latter exposes the whole table cross-tenant. See
267
+ foreign key without carrying `organization_id`, use `policy: { by: 'parent', fk,
268
+ parent }` rather than opting out of isolation. The old `orgScoped: false`
269
+ exposed the whole table cross-tenant, so `validate_schema` rejects the removed
270
+ options as `tenancy-option-removed` errors and steers you to `policy: { by:
271
+ 'parent' }` (FK inheritance) or, for genuinely global reference data, the
272
+ explicit `policy: { by: 'none' }`. See
267
273
  `packages/sync-engine/src/schema/model.ts` for the full option set.
268
274
 
269
275
  ## How identity reaches Ablo — the proxy model
@@ -393,8 +399,8 @@ an entity anchor on the models an agent operates on:
393
399
 
394
400
  ```ts
395
401
  // each scope-root model an agent edits forms a per-entity group
396
- documents: model({ /* … */ }, {}, { orgScoped: true, scope: 'document' }),
397
- decks: model({ /* … */ }, {}, { orgScoped: true, scope: 'deck' }),
402
+ documents: model({ /* … */ }, {}, { groups: { root: 'document' } }),
403
+ decks: model({ /* … */ }, {}, { groups: { root: 'deck' } }),
398
404
  ```
399
405
 
400
406
  Then a run subscribes only to the entity groups for the rows it works on — a
@@ -484,9 +490,10 @@ an agent pointed at the entities it's working on. You **never hand-write**
484
490
  (it returns a participant handle with `.peers`). See
485
491
  [Coordination](./coordination.md).
486
492
 
487
- > **`scope` is the schema model option, not a client setting.** `scope: 'deck'`
488
- > in `model(...)` declares a scope root ([Half 2](#half-2--per-model-scope-row--group)) —
489
- > it names the group (`deck:<id>`) that the mechanisms above then subscribe to.
493
+ > **`groups.root` is the schema model option, not a client setting.**
494
+ > `groups: { root: 'deck' }` in `model(...)` declares a scope root
495
+ > ([Half 2](#half-2--per-model-scope-row--group)) it names the group
496
+ > (`deck:<id>`) that the mechanisms above then subscribe to.
490
497
  > There is no `Ablo({ scope })` constructor option. The lifecycle filter on
491
498
  > [`list()`](./api.md#model-methods) is a separate axis named **`state`**
492
499
  > (`'live' | 'archived' | 'all'`, GitHub's open/closed/all), precisely so it
package/docs/index.md CHANGED
@@ -99,4 +99,3 @@ Three things stay true no matter how you use Ablo:
99
99
  - [README](../README.md) — product overview and first example.
100
100
  - [AGENTS.md](../AGENTS.md) — short installation guidance for coding assistants.
101
101
  - [Changelog](../CHANGELOG.md) — what shipped recently.
102
- - [Roadmap](./roadmap.md) — what's planned next.
@@ -142,20 +142,23 @@ model(
142
142
  },
143
143
  /* relations */ {},
144
144
  {
145
- // Rows carry organization_id and bootstrap filters on it.
146
- orgScoped: true,
145
+ // Axis 1 `policy`: who may READ a row (tenant isolation / RLS). A
146
+ // row-local `organization_id` column is the default, so you omit this for
147
+ // normal tables; set it only for the exceptions (parent-inherited / global).
147
148
 
149
+ // Axis 2 — `groups`: which sync-group CHANNELS a row fans into.
148
150
  // Scope root: rows form the group `matter:<id>`. Children point at it with
149
151
  // `relation.belongsTo('matters', 'matterId', { parent: true })` to inherit.
150
- scope: 'matter',
152
+ groups: { root: 'matter' },
151
153
  }
152
154
  );
153
155
  ```
154
156
 
155
- For rows that don't carry `organization_id` themselves but inherit
156
- tenancy via a foreign key, use `scopedVia` instead of `orgScoped:
157
- false` the latter exposes the entire table cross-tenant. See
158
- `packages/sync-engine/src/schema/model.ts` for the full option set.
157
+ For rows that don't carry `organization_id` themselves but inherit tenancy via a
158
+ foreign key, set `policy: { by: 'parent', fk: '<fk>', parent: '<parentTable>' }`.
159
+ For genuinely global/reference data, `policy: { by: 'none' }`. ⚠ `by: 'none'`
160
+ exposes the whole table cross-tenant, so it's an explicit, named branch — never a
161
+ falsy flag. See `packages/sync-engine/src/schema/model.ts` for the full option set.
159
162
 
160
163
  ## 2. Create The Client
161
164
 
@@ -444,7 +447,7 @@ scope.
444
447
  ```ts
445
448
  await using claim = await ablo.weatherReports.claim({
446
449
  id: reportId,
447
- action: 'forecasting',
450
+ reason: 'forecasting',
448
451
  });
449
452
  const claimed = claim.data;
450
453
  if (!claimed) return;
@@ -510,7 +513,7 @@ them.
510
513
  | `update({ id, data, ...opts })` | Update through the model client. |
511
514
  | `delete({ id, ...opts })` | Delete through the model client. |
512
515
  | `claim.state({ id })` | See who is currently working on a row (synchronous). |
513
- | `claim({ id, action?, ttl? })` | Acquire a disposable handle: wait for your turn, re-read, and hold the row. |
516
+ | `claim({ id, reason?, ttl? })` | Acquire a disposable handle: wait for your turn, re-read, and hold the row. |
514
517
 
515
518
  Keep first integrations on the model methods above. Every mutation and
516
519
  server-read verb takes one options object; only the synchronous `get(id)` stays
@@ -70,7 +70,7 @@ automatically when the scope exits:
70
70
  ```ts
71
71
  await using claim = await ablo.weatherReports.claim({
72
72
  id: 'report_stockholm',
73
- action: 'editing',
73
+ reason: 'editing',
74
74
  });
75
75
  await ablo.weatherReports.update({ id: claim.data.id, data: { status: 'ready' } }); // rejected if the row changed under the claim
76
76
  ```
package/docs/mcp.md CHANGED
@@ -67,11 +67,23 @@ Point your assistant at the hosted endpoint — no auth, no token:
67
67
  claude mcp add --transport http ablo https://<your-app>/api/mcp
68
68
  ```
69
69
 
70
- Per-client walkthroughs:
70
+ The endpoint is identical for every client — only the config surface differs:
71
71
 
72
- - [Claude Code](/docs/mcp/claude-code)
73
- - [Cursor](/docs/mcp/cursor)
74
- - [Windsurf](/docs/mcp/windsurf)
72
+ - **Claude Code** — run the `claude mcp add` command above; verify with `/mcp list`, remove with `claude mcp remove ablo`.
73
+ - **Cursor** — add the server to `~/.cursor/mcp.json` (macOS / Linux), then restart.
74
+ - **Windsurf** — add the same JSON via Settings → Cascade → MCP, then restart.
75
+
76
+ Cursor and Windsurf use the same config shape:
77
+
78
+ ```json
79
+ {
80
+ "mcpServers": {
81
+ "ablo": { "transport": "http", "url": "https://<your-app>/api/mcp" }
82
+ }
83
+ }
84
+ ```
85
+
86
+ Each client then lists the Ablo tools (`search_ablo_docs`, `get_recipe`, `get_api_surface`, `validate_schema`, `scaffold_app`) in its MCP panel.
75
87
 
76
88
  ### What it exposes
77
89
 
@@ -96,7 +108,7 @@ loading everything into context.
96
108
  Reusable, parameterised templates that drive an end-to-end flow:
97
109
 
98
110
  - `integrate-sync-engine` — wire the SDK into an existing project.
99
- - `add-agent` — add an agent worker that coordinates via intents and
111
+ - `add-agent` — add an agent worker that coordinates via claims and
100
112
  conflict-safe writes.
101
113
  - `define-schema` — design a Zod-first schema from a description, then run
102
114
  `validate_schema` before committing.
@@ -222,7 +222,7 @@ Call `handle.release()` when your work is done.
222
222
  // Claim the row so other participants serialize behind us while we work.
223
223
  const handle = await ablo.weatherReports.claim({
224
224
  id: 'weather_stockholm',
225
- action: 'checking_weather',
225
+ reason: 'checking_weather',
226
226
  ttl: '2m',
227
227
  });
228
228
 
@@ -260,7 +260,7 @@ write through the model.
260
260
  ```ts
261
261
  const active = ablo.weatherReports.claim.state({ id: 'weather_stockholm' });
262
262
  if (active) {
263
- console.log(`${active.heldBy} is ${active.action}`);
263
+ console.log(`${active.heldBy} is ${active.reason}`);
264
264
  }
265
265
 
266
266
  const handle = await ablo.weatherReports.claim({ id: 'weather_stockholm' });
@@ -268,7 +268,7 @@ await ablo.weatherReports.update({ id: handle.data.id, data: { status: 'ready' }
268
268
  await handle.release();
269
269
  ```
270
270
 
271
- Use `{ wait: false }` on `claim` when work should be skipped instead of queued
271
+ Use `{ queue: false }` on `claim` when work should be skipped instead of queued
272
272
  behind an active holder.
273
273
 
274
274
  ## Next steps
package/llms.txt CHANGED
@@ -12,7 +12,7 @@ First action when integrating into an app: run `npx ablo init --yes --framework
12
12
 
13
13
  Second: make sure a key exists — WITHOUT printing it. The key is a secret; it must never appear in your output, your reasoning, or a file you echo (it would live in the conversation history forever). Check PRESENCE only: `[ -n "$ABLO_API_KEY" ] && echo set` and `grep -cq '^ABLO_API_KEY=' .env.local && echo wired` — never `cat .env.local`, never `echo $ABLO_API_KEY`. If neither check passes, ask the HUMAN to run `npx ablo login` once — it opens a browser and saves a `sk_test_` key locally; an agent must NOT run it. You never copy the key by hand: the next step writes it into `.env.local` (and gitignores it) for you.
14
14
 
15
- Then PUSH — this is the step everything depends on. The server keeps its OWN copy of the schema. Run `npx ablo push --no-watch`: it pushes `ablo/schema.ts` (sandbox) AND writes `ABLO_API_KEY` into `.env.local` from the stored login. Until the schema is pushed, EVERY write to a new or changed model fails with `server_execute_unknown_model`. Re-run it after schema changes (`npx ablo push` also works once the key is wired; bare `npx ablo push` watches forever don't, you have no TTY).
15
+ Then PUSH — this is the step everything depends on. The server keeps its OWN copy of the schema. Run `npx ablo push`: it pushes `ablo/schema.ts` (sandbox) AND writes `ABLO_API_KEY` into `.env.local` from the stored login. Until the schema is pushed, EVERY write to a new or changed model fails with `server_execute_unknown_model`. Re-run it after schema changes. `push` is one-shot; `dev` is the watcher, so use `npx ablo dev --no-watch` only when you intentionally want the dev command to push once and exit.
16
16
 
17
17
  ## Projects (one org, many apps)
18
18
 
@@ -29,7 +29,6 @@ TYPES: the project registers its schema ONCE via declaration merging — `npx ab
29
29
 
30
30
  const schema = defineSchema({
31
31
  weatherReports: model({
32
- id: z.string(),
33
32
  location: z.string(),
34
33
  status: z.enum(['pending', 'ready']),
35
34
  forecast: z.string().optional(),
@@ -190,6 +189,6 @@ Do not teach `/api`, `/agent`, `/ai-sdk`, `/core`, `/realtime`, or internal subp
190
189
  - `npx ablo init --yes` (flags: `--framework`, `--auth`, `--storage direct|endpoint` (default `direct`), `--no-agent`, `--no-pull`, `--no-install`, `--no-login`). Generates `ablo/schema.ts` + the client; `--storage direct` (default) wires `databaseUrl`, `--storage endpoint` scaffolds the `ablo/data-source.ts` endpoint above instead.
191
190
  - Key: see "Start here" — env → `.env.local` → ask the human to `npx ablo login`; never run `login` yourself, never copy keys by hand (`ablo push` writes `.env.local`).
192
191
  - Adopt an existing DB: `npx ablo pull prisma [path]` / `npx ablo pull drizzle <module>`.
193
- - `npx ablo push --no-watch` pushes the schema (sandbox) AND writes `ABLO_API_KEY` to `.env.local` (default watches forever); `npx ablo logs --no-follow` (default tails forever); `npx ablo mode sandbox|production` (always pass the arg). `npx ablo push`/`status`/`pull`/`check`/`generate` are one-shot.
192
+ - `npx ablo push` pushes the schema (sandbox) AND writes `ABLO_API_KEY` to `.env.local`; `npx ablo dev --no-watch` is the push-once form of the watcher; `npx ablo logs --no-follow` exits instead of tailing forever; `npx ablo mode sandbox|production` always needs the argument. `npx ablo push`/`status`/`pull`/`check`/`generate` are one-shot.
194
193
 
195
194
  Canonical docs to read before integrating: `quickstart`, `schema-contract`, `integration-guide`, `guarantees`, `client-behavior`, `data-sources`, `examples/existing-python-backend`, `api`, `examples/ai-sdk-tool`, and `examples/server-agent`. When upgrading an existing integration, read `migration` — every breaking change, what to change, and which version introduced it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "The Collaboration Layer For AI Agents",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -119,7 +119,6 @@
119
119
  "examples",
120
120
  "AGENTS.md",
121
121
  "llms.txt",
122
- "llms-full.txt",
123
122
  "LICENSE",
124
123
  "NOTICE",
125
124
  "README.md",
@@ -131,9 +130,11 @@
131
130
  "build:cli": "tsup --config tsup.cli.config.ts",
132
131
  "typecheck:cli": "tsc -p tsconfig.cli.json",
133
132
  "pack:check": "npm_config_cache=${TMPDIR:-/tmp}/ablo-npm-cache npm pack --dry-run",
133
+ "lint": "npm run lint:imports && npm run lint:errors && npm run lint:docs",
134
134
  "lint:imports": "node scripts/check-js-extensions.mjs",
135
135
  "generate:errors": "tsx scripts/generate-error-docs.mts",
136
136
  "lint:errors": "tsx scripts/check-error-docs.mts",
137
+ "lint:docs": "node scripts/check-doc-drift.mjs",
137
138
  "lint:pkg": "publint",
138
139
  "prepublishOnly": "npm run build && npm run lint:pkg",
139
140
  "check:dist": "node scripts/check-dist-fresh.mjs",
@@ -1,35 +0,0 @@
1
- # Claude Code
2
-
3
- ## Install
4
-
5
- ```bash
6
- claude mcp add --transport http ablo https://<your-app>/api/mcp
7
- ```
8
-
9
- That's it — no token or header needed. The endpoint is public and serves
10
- only docs, schema lint, and scaffolds. The next `/help` in Claude Code will
11
- list the Ablo Sync tools.
12
-
13
- ## Verify
14
-
15
- In Claude Code, run:
16
-
17
- ```
18
- /mcp list
19
- ```
20
-
21
- You should see `ablo` with the integration tools enumerated:
22
- `search_ablo_docs`, `get_recipe`, `get_api_surface`, `validate_schema`,
23
- `scaffold_app`.
24
-
25
- ## Removing
26
-
27
- ```bash
28
- claude mcp remove ablo
29
- ```
30
-
31
- ## More
32
-
33
- - [MCP overview](/docs/mcp) — what the server exposes and how the transport works.
34
- - [Cursor setup](/docs/mcp/cursor) — same URL, different UI.
35
- - [Windsurf setup](/docs/mcp/windsurf) — same URL, different UI.
@@ -1,35 +0,0 @@
1
- # Cursor
2
-
3
- ## Install
4
-
5
- Add the Ablo Sync MCP server to Cursor's `mcp.json`:
6
-
7
- ```json
8
- {
9
- "mcpServers": {
10
- "ablo": {
11
- "transport": "http",
12
- "url": "https://<your-app>/api/mcp"
13
- }
14
- }
15
- }
16
- ```
17
-
18
- The file lives at `~/.cursor/mcp.json` on macOS / Linux. No auth header is
19
- needed — the endpoint is public and serves only docs, schema lint, and
20
- scaffolds.
21
-
22
- Restart Cursor. The Ablo Sync tools appear under the MCP icon in the agent
23
- panel.
24
-
25
- ## Verify
26
-
27
- In Cursor's agent panel, open the MCP tools list. You should see the
28
- Ablo Sync integration tools and their JSON schemas: `search_ablo_docs`,
29
- `get_recipe`, `get_api_surface`, `validate_schema`, `scaffold_app`.
30
-
31
- ## More
32
-
33
- - [MCP overview](/docs/mcp) — what the server exposes and how the transport works.
34
- - [Claude Code setup](/docs/mcp/claude-code) — CLI install.
35
- - [Windsurf setup](/docs/mcp/windsurf) — same JSON shape.
@@ -1,33 +0,0 @@
1
- # Windsurf
2
-
3
- ## Install
4
-
5
- Add the Ablo Sync MCP server to Windsurf's MCP config:
6
-
7
- ```json
8
- {
9
- "mcpServers": {
10
- "ablo": {
11
- "transport": "http",
12
- "url": "https://<your-app>/api/mcp"
13
- }
14
- }
15
- }
16
- ```
17
-
18
- The config path differs by platform — Windsurf surfaces it in Settings →
19
- Cascade → MCP. Restart Windsurf after saving. No auth header is needed —
20
- the endpoint is public and serves only docs, schema lint, and scaffolds.
21
-
22
- ## Verify
23
-
24
- Cascade's MCP panel lists every configured server with its tools. You
25
- should see `ablo` with the integration tools enumerated:
26
- `search_ablo_docs`, `get_recipe`, `get_api_surface`, `validate_schema`,
27
- `scaffold_app`.
28
-
29
- ## More
30
-
31
- - [MCP overview](/docs/mcp) — what the server exposes and how the transport works.
32
- - [Claude Code setup](/docs/mcp/claude-code) — CLI install.
33
- - [Cursor setup](/docs/mcp/cursor) — same JSON shape.