@abloatai/ablo 0.16.2 → 0.16.3

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.3
4
+
5
+ ### Patch Changes
6
+
7
+ - **Docs.** The bundled SDK docs are now the single source for the documentation
8
+ site, and several pages were expanded or corrected:
9
+ - The sessions/identity model is reframed around **projects** — push one schema
10
+ to a project, mint an `ek_` per user (your users need no Ablo account), and
11
+ all of them commit to that one schema. Per-customer org isolation
12
+ (`schemaProject`) is presented as the add-on it is, not the default.
13
+ - The declarative `conflict` schema axis (Axis 3) is now documented.
14
+ - The agent docs were corrected to the current claim vocabulary
15
+ (`reason`/`queue`, not the pre-0.12.0 `action`/`wait`).
16
+
17
+ No code changes.
18
+
3
19
  ## 0.16.2
4
20
 
5
21
  ### Patch Changes
@@ -0,0 +1,149 @@
1
+ # Agent Messaging
2
+
3
+ Use a normal model when agents or humans need durable communication inside a
4
+ syncGroup. Use claim `description` and `meta` for live coordination context.
5
+ Use a `messages` row when the information must survive reconnects, work over
6
+ HTTP, or be replayed from the sync cursor.
7
+
8
+ ## The rule
9
+
10
+ | Need | Use | Why |
11
+ | --- | --- | --- |
12
+ | "I am holding this row because..." | `claim({ reason, description, meta })` | Live and low-latency. Peers see it through presence while the claim exists. |
13
+ | "Remember this handoff/status/request" | A `messages` model | Durable row. Ordered in `sync_deltas`, replayed after reconnect, readable by HTTP agents. |
14
+
15
+ Claim context is ephemeral. If a participant was offline, reconnected later, or
16
+ only uses `transport: "http"`, it can miss claim/presence frames. Message rows
17
+ are facts in your database and in the sync log.
18
+
19
+ ## Schema
20
+
21
+ Add a `messages` model to the same schema as the work it discusses. Scope it
22
+ with the same field that scopes the work row.
23
+
24
+ ```ts
25
+ import Ablo from "@abloatai/ablo";
26
+ import { defineSchema, entityRole, model, z } from "@abloatai/ablo/schema";
27
+
28
+ export const schema = defineSchema({
29
+ workItems: model(
30
+ {
31
+ title: z.string(),
32
+ status: z.string(),
33
+ teamId: z.string(),
34
+ },
35
+ {},
36
+ { entityRoles: [entityRole({ kind: "team", source: "teamId" })] },
37
+ ),
38
+
39
+ messages: model(
40
+ {
41
+ body: z.string(),
42
+ kind: z.enum(["status", "request", "handoff"]),
43
+ teamId: z.string(),
44
+
45
+ // Causal link back to coordination or the row being discussed.
46
+ aboutEntityType: z.string().optional(),
47
+ aboutEntityId: z.string().optional(),
48
+ aboutIntentId: z.string().optional(),
49
+ },
50
+ {},
51
+ { entityRoles: [entityRole({ kind: "team", source: "teamId" })] },
52
+ ),
53
+ });
54
+ ```
55
+
56
+ Push the schema before agents call it:
57
+
58
+ ```bash
59
+ npx ablo push
60
+ ```
61
+
62
+ ## Server-side agent
63
+
64
+ Most server-side agents use the direct database path. Pass the schema, API key,
65
+ database URL, and transport selector:
66
+
67
+ ```ts
68
+ const ablo = Ablo({
69
+ schema,
70
+ apiKey: process.env.ABLO_API_KEY,
71
+ databaseUrl: process.env.DATABASE_URL,
72
+ transport: "http",
73
+ });
74
+
75
+ await ablo.ready();
76
+ ```
77
+
78
+ `databaseUrl` is server-only. Browser clients must not receive it; live UIs use
79
+ the default WebSocket transport with a minted user/session token.
80
+
81
+ If your backend mints restricted agent tokens, register the database once from a
82
+ secret-key server process as above. Workers using the restricted token can then
83
+ construct `Ablo({ schema, authToken, transport: "http" })` because the project
84
+ already has a registered data plane.
85
+
86
+ ## Link a message to a claim
87
+
88
+ The claim id is the causal id. Store it in `aboutIntentId` when the message is
89
+ about work currently protected by a claim.
90
+
91
+ ```ts
92
+ const claim = await ablo.workItems.claim({
93
+ id: workItemId,
94
+ reason: "reformatting",
95
+ description: "normalizing the pricing table",
96
+ meta: { estimateSeconds: 120 },
97
+ queue: false,
98
+ });
99
+
100
+ try {
101
+ await ablo.messages.create({
102
+ id: crypto.randomUUID(),
103
+ data: {
104
+ body: "Taking the pricing table for about two minutes.",
105
+ kind: "status",
106
+ teamId,
107
+ aboutEntityType: "workItems",
108
+ aboutEntityId: workItemId,
109
+ aboutIntentId: claim.claimId,
110
+ },
111
+ wait: "confirmed",
112
+ });
113
+ } finally {
114
+ await claim.release();
115
+ }
116
+ ```
117
+
118
+ Peers in `team:${teamId}` receive the message through the normal delta stream.
119
+ Peers outside that syncGroup do not.
120
+
121
+ ## Reading messages
122
+
123
+ Live clients read locally and update when deltas arrive:
124
+
125
+ ```ts
126
+ const rows = ablo.messages.getAll({
127
+ where: { teamId },
128
+ orderBy: { createdAt: "asc" },
129
+ });
130
+ ```
131
+
132
+ HTTP agents read by request:
133
+
134
+ ```ts
135
+ const rows = await ablo.messages.list({
136
+ where: { teamId },
137
+ orderBy: { createdAt: "asc" },
138
+ });
139
+ ```
140
+
141
+ Because messages are ordinary synced rows, reconnecting clients catch up from
142
+ their cursor and see the rows they missed while offline.
143
+
144
+ ## Retention
145
+
146
+ Deleting or archiving old `messages` rows is your app's policy. The sync log is
147
+ still durable audit/history: `sync_deltas` has no message-specific TTL. That is
148
+ useful for coordination and compliance, but a chat-scale product should plan
149
+ retention before writing high-volume conversation traffic.
package/docs/agents.md ADDED
@@ -0,0 +1,104 @@
1
+ # Agents
2
+
3
+ An agent is a **reactive** participant: it wakes on something happening, reads
4
+ what it needs, writes a result, and goes idle. That's a request/response
5
+ workload — so agents talk to Ablo over **plain HTTP**, holding no WebSocket. The
6
+ credential *is* the identity; the server resolves the org, scope, and actor from
7
+ the key on every request (the Stripe server-SDK / Liveblocks-node shape).
8
+
9
+ Humans get the live plane (WebSocket: presence, optimistic, sub-100ms). Agents
10
+ get the stateless plane (HTTP). **Both operate on the same typed, coordinated
11
+ state — and coordinate *with each other*.**
12
+
13
+ <Note>
14
+ Agents transact against your **pushed schema**, same as everyone — `ablo.tasks`
15
+ exists because you defined a `task` model and ran `ablo push`. The key
16
+ authenticates; the [schema](/quickstart) defines what you can call.
17
+ </Note>
18
+
19
+ ## The agent client
20
+
21
+ Same `Ablo()` entry point as everywhere else — pass `transport: 'http'`. No
22
+ socket, no connection state — just your schema (for types) and an API key.
23
+
24
+ ```ts
25
+ import Ablo from "@abloatai/ablo";
26
+ import { schema } from "./schema";
27
+
28
+ const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, transport: "http" });
29
+
30
+ // Reads + writes, fully typed off your schema — each is one HTTP round-trip:
31
+ const open = await ablo.tasks.list({ where: { status: "todo" } });
32
+ const { data } = await ablo.tasks.retrieve({ id: open[0].id });
33
+ await ablo.tasks.update({ id: open[0].id, data: { status: "done" } });
34
+ ```
35
+
36
+ It exposes `retrieve` / `list` / `create` / `update` / `delete`, plus `commits`
37
+ and `claim`. It does **not** expose the stateful-only surface (`get` /
38
+ `getAll` / `getCount` local reads, `onChange` live subscription) — those need a
39
+ live connection, so with `transport: 'http'` the return type narrows and they
40
+ are a *compile error*, not a runtime surprise.
41
+
42
+ ## Coordination — claim, queue, reorder
43
+
44
+ The differentiator. A claim is a **durable lease + FIFO wait-line** on a row —
45
+ "who's working on this, who's waiting" — and it's request/response, so an agent
46
+ holds it over HTTP. This is how two agents (or an agent and a human) don't
47
+ clobber the same record.
48
+
49
+ ```ts
50
+ // Acquire a lease, do work with the held row, release on scope exit:
51
+ await using claim = await ablo.tasks.claim({ id: taskId });
52
+ const task = claim.data;
53
+ // …no one else can hold this row while you work…
54
+ await ablo.tasks.update({ id: task.id, data: { status: "in_review" } });
55
+
56
+ await ablo.tasks.claim.state({ id: taskId }); // who holds it now (or null)
57
+ await ablo.tasks.claim.queue({ id: taskId }); // the FIFO wait-line behind the holder
58
+ await ablo.tasks.claim.reorder({ id: taskId, order: line }); // re-rank the line (privileged)
59
+ ```
60
+
61
+ Think of it as a queue per row — a durable, inspectable, reorderable lease line
62
+ ("SQS for entity contention"). Use `{ queue: false }` for fail-fast dedup: *if
63
+ someone else has this job, skip it.*
64
+
65
+ ## Messaging between agents
66
+
67
+ Use claim `description` and `meta` for live "what I am doing now" context. Use
68
+ ordinary synced rows for handoffs, status notes, and requests that must survive
69
+ reconnects or be readable by HTTP agents. The recipe is a `messages` model
70
+ scoped by the same syncGroup field as the work row, with `aboutIntentId` linking
71
+ a message back to the claim it discusses.
72
+
73
+ See [Agent Messaging](/agent-messaging) for the schema and direct
74
+ `databaseUrl` setup.
75
+
76
+ ## Humans + agents on one state
77
+
78
+ There's no separate "agent mode." A human editing a record over their WebSocket
79
+ and an agent acting over HTTP share the same typed state and the same coordination
80
+ plane: the agent can claim the row a human is editing (and wait in line), and the
81
+ human sees the agent's committed changes stream in **live** — over the human's
82
+ own socket, even though the agent committed over HTTP. You write the agent once;
83
+ it's a first-class participant, not a bolt-on.
84
+
85
+ ## How an agent runs
86
+
87
+ ```text
88
+ something happens ──▶ your agent (HTTP, no socket)
89
+ (a job, a webhook, read context (list/retrieve)
90
+ a queue message) claim → work → commit
91
+ done — no held connection
92
+ ```
93
+
94
+ Because it holds nothing open, an agent is a **stateless worker**: deploys and
95
+ restarts are free, and you scale by adding workers. A long-running fleet of idle
96
+ agents costs nothing on the live plane — that capacity stays for humans.
97
+
98
+ ## What stays on the live (human) plane
99
+
100
+ `onChange` (live subscriptions) and `get`/`getAll`/`getCount` (local synced-pool
101
+ reads) require a WebSocket and a local store — they're for interactive UIs, not
102
+ stateless agents. An agent reacts to an external trigger (a job/queue/webhook),
103
+ then reads with `list`/`retrieve`. See [client behavior](/client-behavior) for
104
+ the full surface and [guarantees](/guarantees) for the coordination semantics.
package/docs/index.md CHANGED
@@ -86,7 +86,6 @@ Three things stay true no matter how you use Ablo:
86
86
  - [AI SDK Tool](./examples/ai-sdk-tool.md) — Put Ablo inside an AI SDK tool call.
87
87
  - [Existing Python Backend](./examples/existing-python-backend.md) — Add multiplayer and future agent writes without replacing a Python API server.
88
88
  - [Agent + Human](./examples/agent-human.md) — Yield when a human is editing the same report.
89
- - [Agent Scoped to One Deck](./examples/scoped-agent.md) — Scope an agent to one entity with `scope` / `parent`; realtime for just that deck.
90
89
  - [Server Agent](./examples/server-agent.md) — Schema-backed worker.
91
90
  - [Next.js](./examples/nextjs.md) — App-router setup with React bindings.
92
91
 
@@ -0,0 +1,97 @@
1
+ # Projects
2
+
3
+ A **project** is the isolation unit inside your organization — the shape you
4
+ know from Neon or Supabase. Each app you build gets its own project, and each
5
+ project gets its own schema, its own sandbox/production data planes, and its
6
+ own API keys. Two teams in one org can ship two apps that never see each
7
+ other's models, keys, or rows.
8
+
9
+ ```text
10
+ organization
11
+ ├── project: default ← every org has one; pre-project apps live here
12
+ │ ├── schema (sandbox + production artifacts)
13
+ │ ├── data planes (registered databases)
14
+ │ └── keys (sk_/rk_/ek_/pk_)
15
+ └── project: my-app ← npx ablo init creates this for a new app
16
+ ├── schema
17
+ ├── data planes
18
+ └── keys
19
+ ```
20
+
21
+ ## The default project
22
+
23
+ Every organization has a **default** project. If you never create another
24
+ one, everything works exactly as if projects didn't exist — your keys, schema
25
+ pushes, and registered databases all belong to it. Keys minted before
26
+ projects existed are default-project keys automatically.
27
+
28
+ ## Keys belong to exactly one project
29
+
30
+ A key's project is fixed at mint and can never be changed or overridden —
31
+ the same discipline as its sandbox/production mode. Everything a key mints
32
+ inherits its project: the short-lived session credentials (`ek_`), agent
33
+ keys (`rk_`), everything. There is no way to "switch projects" with an
34
+ existing key; you use a key minted for the project you mean.
35
+
36
+ Schema pushes, database registrations, reads, and writes all act on the
37
+ **key's** project:
38
+
39
+ - `npx ablo push` activates the schema for the pushing key's project — it
40
+ can never demote another project's schema.
41
+ - Registering a database (`DATABASE_URL`) attaches it to the key's project
42
+ and environment.
43
+ - A write or read against a model that belongs to **another** project in
44
+ your org fails with a typed `project_scope_denied` — never a silent empty
45
+ result, and never the misleading "unknown model, run ablo push".
46
+
47
+ ## Sandboxes belong to a project
48
+
49
+ Production is singular per project; sandboxes are many. Each sandbox of a
50
+ project gets its **own data plane** (its own registered dev database) but
51
+ they all share the project's **one** sandbox schema — pushing the same
52
+ schema from a second sandbox doesn't create a second artifact, it just
53
+ provisions that sandbox's database.
54
+
55
+ ## CLI
56
+
57
+ `npx ablo init` creates a project for your app automatically (slug derived
58
+ from your `package.json` name; `--project <slug>` to choose, `--no-project`
59
+ to stay on the org default). Manage projects any time:
60
+
61
+ ```bash
62
+ npx ablo projects list # all projects (default first)
63
+ npx ablo projects create my-app # create one (--name "Display Name")
64
+ npx ablo projects use my-app # set the ACTIVE project locally
65
+ npx ablo projects use default # back to the org default
66
+ npx ablo status # shows the active project
67
+ ```
68
+
69
+ The active project is a local targeting preference (stored next to `mode` in
70
+ your CLI config): new keys you mint pick it up. It never changes what an
71
+ existing key can reach — a key's project scope is decided server-side at
72
+ mint.
73
+
74
+ ## API
75
+
76
+ Projects are a Stripe-shaped control-plane resource, authenticated with a
77
+ secret (`sk_`) key:
78
+
79
+ ```bash
80
+ curl https://api.abloatai.com/api/v1/projects \
81
+ -H "Authorization: Bearer $ABLO_API_KEY" # list
82
+
83
+ curl https://api.abloatai.com/api/v1/projects \
84
+ -H "Authorization: Bearer $ABLO_API_KEY" \
85
+ -H "content-type: application/json" \
86
+ -d '{"slug": "my-app", "name": "My App"}' # create
87
+ ```
88
+
89
+ A create with a taken slug fails with `project_slug_taken` (409). Reads of
90
+ another org's project ids 404 — never confirm existence across orgs.
91
+
92
+ ## Errors
93
+
94
+ | Code | Status | Meaning |
95
+ |------|--------|---------|
96
+ | `project_scope_denied` | 403 | The model/resource belongs to another project in your org — use a key minted for that project. |
97
+ | `project_slug_taken` | 409 | A project with this slug already exists in the organization. |
package/docs/react.md CHANGED
@@ -295,5 +295,5 @@ unmount.
295
295
 
296
296
  ## Next.js
297
297
 
298
- The Next.js [App Router landing](/nextjs) walks through Server Components
298
+ The Next.js [App Router landing](./examples/nextjs.md) walks through Server Components
299
299
  + Server Actions + `useAblo` together.
@@ -0,0 +1,239 @@
1
+ # Sessions
2
+
3
+ A **session** is a short-lived credential your backend mints with its `sk_` and
4
+ hands to one actor — a signed-in **person's browser** or a scoped **agent**. It's
5
+ the same primitive in both cases (backend-minted, short-lived, scoped); the only
6
+ difference is the subject and how much authority it carries.
7
+
8
+ One resource mints both:
9
+
10
+ ```ts Your backend (sk_)
11
+ // A logged-in person's browser session — full authority within their org.
12
+ const userSession = await ablo.sessions.create({
13
+ user: { id: currentUser.id },
14
+ });
15
+
16
+ // A scoped agent session — gated to exactly the operations you name.
17
+ const agentSession = await ablo.sessions.create({
18
+ agent: { id: 'agent:task-writer' },
19
+ can: { Task: ['read', 'update'], Deck: ['read'] },
20
+ });
21
+ ```
22
+
23
+ `user` mints an `ek_` (ephemeral key); `agent` mints an `rk_` (restricted key).
24
+ You pass `user` **or** `agent` — never both.
25
+
26
+ It exists because of one rule: **the browser can never hold a secret.** Your
27
+ `sk_` lives on the server; the browser only ever holds a minted session token
28
+ (which already names your org). So the per-actor credential is minted
29
+ server-side, scoped, and expires in minutes — the model Stripe uses for
30
+ client-side SDKs.
31
+
32
+ ## Why
33
+
34
+ Ablo doesn't authenticate your users — you do, however you like (your own
35
+ sessions, an IdP, anything). Ablo authenticates your **project** (the `sk_` that
36
+ minted the session) and trusts the identity you asserted at mint time. The
37
+ session token *is* that assertion: "this connection is acting as `U`, in org
38
+ `O`, until it expires."
39
+
40
+ ## End-user sessions (`ek_`)
41
+
42
+ For a logged-in person using your app. Mint on a backend route that has already
43
+ authenticated the user:
44
+
45
+ ```ts Your backend route (session-authed)
46
+ const { token } = await ablo.sessions.create({
47
+ user: { id: currentUser.id }, // who the session acts as
48
+ // syncGroups: [...], // optional; defaults to the user's org + user
49
+ });
50
+ return Response.json({ token }); // return ONLY the token to the browser
51
+ ```
52
+
53
+ A user session has **full data authority** within its org — no operation
54
+ allowlist. It's the human acting as themselves.
55
+
56
+ Build a browser `Ablo` client whose `getToken` fetches from that route, and pass
57
+ the **instance** to [`<AbloProvider>`](/react). The client fetches the token,
58
+ opens the connection, and re-mints before expiry — your app writes no token
59
+ plumbing:
60
+
61
+ ```tsx
62
+ 'use client';
63
+
64
+ import Ablo from '@abloatai/ablo';
65
+ import { AbloProvider } from '@abloatai/ablo/react';
66
+ import { schema } from '@/ablo.schema';
67
+
68
+ const ablo = Ablo({
69
+ schema,
70
+ getToken: () =>
71
+ fetch('/api/ablo-session', { method: 'POST' })
72
+ .then((r) => r.json())
73
+ .then((d) => d.token),
74
+ });
75
+
76
+ export function Providers({ children }: { children: React.ReactNode }) {
77
+ return <AbloProvider client={ablo}>{children}</AbloProvider>;
78
+ }
79
+ ```
80
+
81
+ The client owns auth, the credential lifecycle, and the connection; the provider
82
+ is the thin reactive binding over it (Stripe's `<Elements stripe={...}>` model).
83
+ Build the client **once** at module scope — a new instance per render tears down
84
+ the socket. `authEndpoint: '/api/ablo-session'` is accepted as sugar for the
85
+ `getToken` fetch above if you prefer a URL.
86
+
87
+ ## Agent sessions (`rk_`)
88
+
89
+ For a non-human actor — an agent or automation that should only do **specific**
90
+ operations. The `can` map is the permission boundary, and it's **typed against
91
+ your schema** — the model keys are your schema's models, so a typo is a compile
92
+ error, not a silent over-grant:
93
+
94
+ ```ts
95
+ const session = await ablo.sessions.create({
96
+ agent: { id: 'agent:task-writer' },
97
+ can: { Task: ['read', 'update'] }, // typed off the schema — no magic strings
98
+ ttlSeconds: 600,
99
+ });
100
+
101
+ const agent = Ablo({ schema, apiKey: session.token }); // the agent's scoped client
102
+ ```
103
+
104
+ `can: { Task: ['update'] }` serializes to the wire allowlist `task.update`; the
105
+ server rejects any commit whose operation isn't listed. Operations are
106
+ `'read' | 'create' | 'update' | 'delete'`.
107
+
108
+ <Note>
109
+ Use `sessions.create({ agent })` to mint a scoped agent credential, then write
110
+ with `ablo.<model>.update(...)` / `ablo.commits.create(...)` under a `claim`.
111
+ This is the path for custom runtimes, MCP sessions, and protocol-level integrations.
112
+ </Note>
113
+
114
+ ## Mint
115
+
116
+ Only a **secret key** (`sk_`) can mint a session — never another session token.
117
+ The `sk_` is the trust anchor; minting is your backend vouching
118
+ for the actor.
119
+
120
+ | Param | For | Meaning |
121
+ |---|---|---|
122
+ | `user` / `agent` | both | The actor. `id` becomes the token's `participantId`. Pass exactly one. |
123
+ | `can` | agent | Per-model operation allowlist, typed off the schema. |
124
+ | `syncGroups` | both | Narrow the session below its default scope. Omit to inherit. |
125
+ | `ttlSeconds` | both | Lifetime in seconds. Defaults to `900` (15m). |
126
+ | `userMeta` | both | Opaque identity blob echoed back to the client. |
127
+
128
+ ## Lifecycle
129
+
130
+ Sessions are **short-lived by design** (~15 minutes) and, for browsers,
131
+ **auto-refreshed** — the provider re-mints ahead of expiry, so a session never
132
+ drops at the boundary. A revoked or signed-out actor simply stops getting a fresh
133
+ token; the old one expires on its own. There's nothing to revoke by hand.
134
+
135
+ ### Offline & sign-out
136
+
137
+ The short session token is **not** your user's login — it's a minutes-long
138
+ credential layered on top of whatever long-lived auth your `authEndpoint`
139
+ already enforces (your own session cookie, an IdP, etc.). The provider keeps
140
+ those two lifetimes separate, which means:
141
+
142
+ - **Going offline never signs the user out.** The provider keeps working from
143
+ its local cache and treats a failed re-mint (no network, a timeout, a `5xx`
144
+ from your endpoint) as **transient** — it retries, and re-mints the instant
145
+ connectivity or tab focus returns. The user stays signed in for as long as
146
+ your underlying session is valid, however brief or long the network drop.
147
+ - **The user is signed out only when the underlying session is genuinely
148
+ rejected** — i.e. your `authEndpoint` responds **`401`/`403`** because the
149
+ cookie (or IdP session) is missing, expired, or revoked. That's the one
150
+ signal the provider treats as terminal.
151
+
152
+ This mirrors the OAuth refresh-token rule (Okta/Auth0/Authgear): only a
153
+ rejection of the *long-lived* credential ends the session — a network failure
154
+ never does.
155
+
156
+ <Note>
157
+ Your `authEndpoint` contract follows from this: return the token on success,
158
+ respond **`401`/`403`** only when the user's session is actually gone, and let
159
+ network/`5xx` failures surface as errors. Don't collapse "can't reach the mint
160
+ endpoint" into "session expired" — returning a `401` for a transient blip will
161
+ bounce a still-valid user to your sign-in page.
162
+ </Note>
163
+
164
+ ## Scope
165
+
166
+ A user session carries the user's **base** sync-groups (`org:`/`user:`/`team:`),
167
+ derived from the identity you minted it for. **Dynamic, relation-driven
168
+ membership** (e.g. a `dataroom:<id>` the user was just added to) is resolved
169
+ **server-side at connect** and unioned on top — so scope stays live, not frozen
170
+ at mint time. Pass `syncGroups` only when you want to *narrow* below the default.
171
+
172
+ ## Your schema, your users (the default)
173
+
174
+ Your schema lives in a **project** — you push it once (`npx ablo push`) and every
175
+ session you mint resolves against it. The flow for serving end-users:
176
+
177
+ 1. **Push your schema** to your project.
178
+ 2. **Mint an `ek_` per user** — `sessions.create({ user: { id } })`. Your users
179
+ commit to that one schema.
180
+
181
+ **Your users do not have Ablo accounts.** You authenticate them however you
182
+ already do; your server's `sk_` mints the `ek_`. By default the session lands in
183
+ your project's own org, so all your users share one schema and one data tenant,
184
+ isolated from each other by sync-groups. For most apps (the Cursor shape) that's
185
+ the whole story — nothing below is needed.
186
+
187
+ ## Org-per-customer isolation (the add-on)
188
+
189
+ Some apps need each customer to be its **own** tenant — a hard data boundary
190
+ (separate row-level isolation, optionally a separate database), not just per-user
191
+ scoping. The law-firm shape (Legora): every firm is its own org, many users
192
+ inside it.
193
+
194
+ The problem that creates: if each customer is a separate org, a naïve setup would
195
+ make you re-push your schema into every new customer's org. You don't have to.
196
+ Keep **one** project as the home of your schema, and point each customer's
197
+ session's *schema* at it while its *data* stays in the customer's own org:
198
+
199
+ ```ts
200
+ const { token } = await mintUserSessionKey({
201
+ apiKey: process.env.ABLO_PLATFORM_KEY, // sk_ with the ephemeral:mint-any-org scope
202
+ userId,
203
+ organizationId, // DATA → this customer's org (its own isolated tenant)
204
+ schemaProject: { // SCHEMA → the project that owns your schema
205
+ organizationId: schemaOwnerOrgId,
206
+ projectId: schemaProjectId,
207
+ },
208
+ ttlSeconds: 3600,
209
+ });
210
+ ```
211
+
212
+ Server-side the split is clean: the model **shape** loads from your schema
213
+ project, but column enrichment and the tenant connection target the customer's
214
+ `organizationId` — so the shared schema only *describes* the shape; the data
215
+ plane (connection + row-level isolation) stays the customer's. A shared schema
216
+ can't leak data across orgs.
217
+
218
+ <Note>
219
+ This requires a platform `sk_` carrying the `ephemeral:mint-any-org` scope —
220
+ only a trusted first-party key can mint a session into another org and bind its
221
+ schema to your project. Omit these fields and you get the default above: one
222
+ project, one schema, all your users.
223
+ </Note>
224
+
225
+ ## Security
226
+
227
+ The whole safety argument is the short TTL: a session token leaked from a
228
+ browser (XSS) is valid for minutes, scoped to one actor's data, and can't mint
229
+ anything or touch the control plane. Contrast `sk_`, which would be a full org
230
+ compromise — which is exactly why it never leaves your server.
231
+
232
+ ## User vs. agent sessions
233
+
234
+ | | User session (`ek_`) | Agent session (`rk_`) |
235
+ |---|---|---|
236
+ | For | a **person** in the browser | an **agent** / automation |
237
+ | Authority | full, within their org | narrow (explicit `can` allowlist) |
238
+ | Mint | `ablo.sessions.create({ user: { id } })` | `ablo.sessions.create({ agent: { id }, can })` |
239
+ | Lives where | the user's **browser** | the agent runtime |
@@ -0,0 +1,231 @@
1
+ # Webhooks
2
+
3
+ Ablo owns the ordered transaction log — the source of truth. **Webhooks are how
4
+ that log reaches your database.** Ablo streams every committed change to an
5
+ endpoint in your app as a signed event; your handler writes your own database.
6
+
7
+ It's the same two-sided shape as Stripe: you call Ablo to make changes (the
8
+ client), and Ablo calls you to persist them (this webhook). Ablo never holds your
9
+ database credentials.
10
+
11
+ ## The loop
12
+
13
+ ```mermaid
14
+ flowchart LR
15
+ App["Your app<br/>(the client)"]
16
+ subgraph Ablo["Ablo (hosted)"]
17
+ Log["Transaction log<br/><i>source of truth</i>"]
18
+ end
19
+ Clients["Other clients<br/>(live, optimistic)"]
20
+ Route["/api/ablo/[...all]<br/>(your webhook route)"]
21
+ DB[("Your database")]
22
+
23
+ App -->|write| Log
24
+ Log -->|realtime sync| Clients
25
+ Log -->|signed event| Route
26
+ Route -->|write| DB
27
+ ```
28
+
29
+ There are two ways data flows out of Ablo, and they're for different jobs:
30
+
31
+ | | reaches | use it for |
32
+ |---|---|---|
33
+ | **Realtime** (`useAblo`, WSS) | your live UI | instant, optimistic rendering |
34
+ | **Webhooks** (this page) | your database | a durable copy you own — analytics, backups, server logic |
35
+
36
+ Most apps use both: the realtime stream for the UI, the webhook stream to keep
37
+ their database in sync. This page is the webhook stream.
38
+
39
+ If you know Stripe, you already know the shape:
40
+
41
+ | Stripe | Ablo |
42
+ |---|---|
43
+ | `stripe.x.create(...)` — make the change | the Ablo client — make the change (+ live sync) |
44
+ | `/stripe-webhook` — confirm and persist | `/api/ablo/[...all]` — persist into your database |
45
+ | Stripe owns the charges | Ablo owns the transaction log |
46
+ | you mirror charges into your database | you mirror the log into your database |
47
+
48
+ The difference in Ablo's favor: every event carries `syncId`, a monotonic log
49
+ position, so you can both dedupe **and** apply in order — Ablo guarantees the
50
+ order because it owns the log.
51
+
52
+ ## The event object
53
+
54
+ Every delivery is a batch of events. Each event:
55
+
56
+ | field | meaning |
57
+ |---|---|
58
+ | `type` | `"<model>.<verb>"`, e.g. `task.updated` |
59
+ | `model` | the model name (`task`) — the table to write |
60
+ | `objectId` | the changed row's id |
61
+ | `data` | the post-change row, or `null` on delete (like Stripe's `event.data.object`) |
62
+ | `syncId` | monotonic log position — **dedupe and order by this** |
63
+ | `id` | `String(syncId)` — the event id |
64
+ | `createdAt` | ISO commit timestamp |
65
+
66
+ ```ts
67
+ import type { AbloWebhookEvent } from '@abloatai/ablo/webhooks';
68
+ ```
69
+
70
+ ## 1. Create a handler
71
+
72
+ `npx ablo init` scaffolds this for you at `app/api/ablo/[...all]/route.ts`. If
73
+ your project uses Prisma it's a **working generic mirror** — one upsert/delete
74
+ for every model, no per-model code. Otherwise it's a neutral route with a single
75
+ place to plug your database in.
76
+
77
+ ```ts app/api/ablo/[...all]/route.ts
78
+ import { Webhook } from 'svix'; // any Standard Webhooks library
79
+ import type { AbloWebhookEvent } from '@abloatai/ablo/webhooks';
80
+ import { PrismaClient } from '@prisma/client';
81
+
82
+ const wh = new Webhook(process.env.ABLO_WEBHOOK_SECRET!);
83
+ const prisma = new PrismaClient();
84
+
85
+ type ModelDelegate = {
86
+ upsert(a: { where: { id: string }; create: Record<string, unknown>; update: Record<string, unknown> }): Promise<unknown>;
87
+ delete(a: { where: { id: string } }): Promise<unknown>;
88
+ };
89
+
90
+ export async function POST(req: Request): Promise<Response> {
91
+ const body = await req.text(); // RAW body — required to verify
92
+ let batch: { data: AbloWebhookEvent[] };
93
+ try {
94
+ batch = wh.verify(body, Object.fromEntries(req.headers)) as { data: AbloWebhookEvent[] };
95
+ } catch {
96
+ return new Response('invalid signature', { status: 400 });
97
+ }
98
+
99
+ const delegates = prisma as unknown as Record<string, ModelDelegate | undefined>;
100
+ for (const event of [...batch.data].sort((a, b) => a.syncId - b.syncId)) {
101
+ const model = delegates[event.model];
102
+ if (!model) continue; // a model you don't mirror — skip
103
+ if (event.data === null) {
104
+ await model.delete({ where: { id: event.objectId } }).catch(() => {});
105
+ } else {
106
+ await model.upsert({ where: { id: event.objectId }, create: event.data, update: event.data });
107
+ }
108
+ }
109
+
110
+ return new Response(null, { status: 200 }); // 2xx = delivered
111
+ }
112
+ ```
113
+
114
+ You only edit this if your tables **diverge** from Ablo's schema (renamed
115
+ columns, extra side effects) — add a `case` for that model before the generic
116
+ mirror. If your tables match Ablo's, you never touch it.
117
+
118
+ <Note>
119
+ Return a `2xx` quickly. Any other status is a failure and is retried. Do heavy
120
+ work asynchronously after responding.
121
+ </Note>
122
+
123
+ ## 2. Test locally
124
+
125
+ You don't register a `localhost` URL. `npx ablo dev` forwards committed changes
126
+ to your machine with a local signing secret — the same idea as Stripe's
127
+ `stripe listen`. Run your app, run `ablo dev`, and writes flow into your local
128
+ handler.
129
+
130
+ ## 3. Register your endpoint
131
+
132
+ For a deployed URL, register it once. Ablo mints the signing secret and returns
133
+ it a single time — the CLI writes it straight into your `.env.local`.
134
+
135
+ ```bash
136
+ npx ablo webhooks create https://yourapp.com/api/ablo/[...all]
137
+ # ✓ Registered we_… → https://yourapp.com/api/ablo/[...all]
138
+ # ✓ Wrote ABLO_WEBHOOK_SECRET to .env.local (shown once)
139
+ ```
140
+
141
+ Manage and inspect endpoints:
142
+
143
+ ```bash
144
+ npx ablo webhooks list # endpoints + delivery health (status, cursor, last error)
145
+ npx ablo webhooks roll <id> # mint a fresh signing secret
146
+ npx ablo webhooks enable <id> # re-enable a disabled endpoint
147
+ ```
148
+
149
+ Or call the API directly — the org is derived from your secret key:
150
+
151
+ ```bash
152
+ curl https://api.abloatai.com/api/v1/webhook_endpoints \
153
+ -H "authorization: Bearer $ABLO_API_KEY" \
154
+ -H "content-type: application/json" \
155
+ -d '{ "url": "https://yourapp.com/api/ablo/[...all]" }'
156
+ # → { "id": "we_…", "secret": "whsec_…", "status": "enabled", ... }
157
+ ```
158
+
159
+ ## 4. Verify the signature
160
+
161
+ Ablo signs every request with the [Standard Webhooks](https://www.standardwebhooks.com)
162
+ scheme (the spec Svix authored). Verify with any compatible library — `svix` or
163
+ `standardwebhooks` — using the secret from registration. Ablo ships no
164
+ verification code of its own; you use the open library.
165
+
166
+ ```ts
167
+ const wh = new Webhook(process.env.ABLO_WEBHOOK_SECRET!);
168
+ const event = wh.verify(rawBody, Object.fromEntries(req.headers));
169
+ ```
170
+
171
+ Verification checks three headers — `webhook-id`, `webhook-timestamp`,
172
+ `webhook-signature` — and rejects a timestamp outside a 5-minute window (replay
173
+ protection). Always verify against the **raw** request body.
174
+
175
+ ## Event delivery
176
+
177
+ Ablo delivers from a per-endpoint **cursor** over the log, advancing only on a
178
+ `2xx`. A failed delivery leaves the cursor in place, so the same events are
179
+ re-sent until they land — at-least-once, in order.
180
+
181
+ ```mermaid
182
+ sequenceDiagram
183
+ participant L as Ablo log
184
+ participant W as Delivery worker
185
+ participant E as Your endpoint
186
+ W->>L: read events after cursor
187
+ L-->>W: events (ordered by syncId)
188
+ W->>E: POST signed batch
189
+ alt 2xx
190
+ E-->>W: 200
191
+ W->>L: advance cursor
192
+ else non-2xx / timeout
193
+ E-->>W: error
194
+ Note over W,E: retry with backoff<br/>5s → 5m → … → 10h (8 attempts)<br/>then auto-disable
195
+ end
196
+ ```
197
+
198
+ | behavior | how it works |
199
+ |---|---|
200
+ | **Ordering** | Every event carries `syncId`, a monotonic log position. Apply in `syncId` order — Ablo guarantees the order because it owns the log. |
201
+ | **Retries** | A non-`2xx` (or no response within the timeout) is retried with backoff: immediate, 5s, 5m, 30m, 2h, 5h, 10h, 10h — 8 attempts over ~32h. |
202
+ | **Auto-disable** | After the retries exhaust, the endpoint is marked `disabled` and delivery stops until you `ablo webhooks enable <id>`. |
203
+ | **Replay** | Nothing is lost on failure: the log *is* the durable buffer, and delivery resumes from the endpoint's cursor once it's healthy. |
204
+
205
+ ## Best practices
206
+
207
+ - **Dedupe by `syncId`.** Skip any `syncId` you've already stored. Delivery is
208
+ at-least-once, so the same event can arrive twice after a retry.
209
+ - **Apply in `syncId` order.** It's the log position; sort each batch by it.
210
+ - **Return `2xx` fast.** Acknowledge first, then do slow work asynchronously.
211
+ - **Subscribe to only what you need.** Set `enabledEvents` to the models you
212
+ mirror (`['*']` is the default = all).
213
+ - **Roll secrets periodically.** `ablo webhooks roll <id>` mints a new secret;
214
+ Standard Webhooks supports a rotation window so in-flight events still verify.
215
+ - **Verify the raw body.** Frameworks that re-serialize JSON will break the
216
+ signature — verify the bytes you received.
217
+
218
+ ## Event types
219
+
220
+ The verb is derived from the change:
221
+
222
+ | type | when |
223
+ |---|---|
224
+ | `<model>.created` | a row was inserted |
225
+ | `<model>.updated` | a row was updated |
226
+ | `<model>.deleted` | a row was deleted (`data` is `null`) |
227
+ | `<model>.archived` | a row was soft-archived |
228
+ | `<model>.unarchived` | a soft-archived row was restored |
229
+
230
+ Internal coordination changes (permissions, sync groups) carry no webhook — only
231
+ your data models produce events.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.16.2",
3
+ "version": "0.16.3",
4
4
  "description": "The Collaboration Layer For AI Agents",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -140,7 +140,9 @@
140
140
  "build:cli": "tsup --config tsup.cli.config.ts",
141
141
  "typecheck:cli": "tsc -p tsconfig.cli.json",
142
142
  "pack:check": "npm_config_cache=${TMPDIR:-/tmp}/ablo-npm-cache npm pack --dry-run",
143
- "lint": "npm run lint:imports && npm run lint:errors && npm run lint:docs",
143
+ "lint": "npm run lint:imports && npm run lint:errors && npm run lint:docs && npm run lint:mintlify",
144
+ "build:docs": "node scripts/build-mintlify-docs.mjs",
145
+ "lint:mintlify": "node scripts/build-mintlify-docs.mjs --check",
144
146
  "lint:imports": "node scripts/check-js-extensions.mjs",
145
147
  "generate:errors": "tsx scripts/generate-error-docs.mts",
146
148
  "lint:errors": "tsx scripts/check-error-docs.mts",