# Ablo — full documentation Every page Ablo publishes for the @abloatai/ablo SDK, in one file. The short manifest is at https://www.abloatai.com/llms.txt; each page is also served individually as text/markdown at https://www.abloatai.com/api/docs/. Source: https://www.abloatai.com/api/docs/agent-messaging # Agent Messaging > Durable handoffs between agents, linked to the claim they are about. Use a normal model when agents or humans need durable communication inside a syncGroup. Use claim `description` and `meta` for live coordination context. Use a `messages` row when the information must survive reconnects, work over HTTP, or be replayed from the sync cursor. ## The rule | Need | Use | Why | | --- | --- | --- | | "I am holding this row because..." | `claim({ description, meta })` | Live and low-latency. Peers see it through presence while the claim exists. | | "Remember this handoff/status/request" | A `messages` model | Durable row, replayed after reconnect and readable by HTTP agents. | Claim context is ephemeral. If a participant was offline, reconnected later, or only uses `transport: "http"`, it can miss claim/presence frames. Message rows are facts in your database and in the sync log. ## Schema Add a `messages` model to the same schema as the work it discusses. Scope it with the same field that scopes the work row. ```ts import Ablo from "@abloatai/ablo"; import { defineSchema, entityRole, model, z } from "@abloatai/ablo/schema"; export const schema = defineSchema({ workItems: model( { title: z.string(), status: z.string(), teamId: z.string(), }, { entityRoles: [entityRole({ kind: "team", source: "teamId" })] }, ), messages: model( { body: z.string(), kind: z.enum(["status", "request", "handoff"]), teamId: z.string(), // Causal link back to coordination or the row being discussed. aboutEntityType: z.string().optional(), aboutEntityId: z.string().optional(), aboutIntentId: z.string().optional(), }, { entityRoles: [entityRole({ kind: "team", source: "teamId" })] }, ), }); ``` Push the schema before agents call it: ```bash npx ablo push ``` ## Server-side agent Most server-side agents use the stateless HTTP transport. Pass the schema, API key, and transport selector: ```ts const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, transport: "http", }); await ablo.ready(); ``` The secret `apiKey` is server-only. Browser clients must not receive it; live UIs use the default WebSocket transport with a minted user/session token. If your backend mints restricted agent tokens, register the database once from a secret-key server process as above. Workers using the restricted token can then construct `Ablo({ schema, authToken, transport: "http" })` because the project already has a registered data plane. ## Link a message to a claim The claim id is the causal id. Store it in `aboutIntentId` when the message is about work currently protected by a claim. ```ts const claim = await ablo.workItems.claim({ id: workItemId, description: "normalizing the pricing table", meta: { estimateSeconds: 120 }, queue: false, }); try { await ablo.messages.create({ id: crypto.randomUUID(), data: { body: "Taking the pricing table for about two minutes.", kind: "status", teamId, aboutEntityType: "workItems", aboutEntityId: workItemId, aboutIntentId: claim.claimId, }, }); } finally { await claim.release(); } ``` Peers in `team:${teamId}` receive the message through the normal delta stream. Peers outside that syncGroup do not. ## Reading messages Live clients read locally and update when deltas arrive: ```ts const rows = ablo.messages.local.list({ where: { teamId }, orderBy: { createdAt: "asc" }, }); ``` HTTP agents read by request: ```ts const rows = await ablo.messages.list({ where: { teamId }, orderBy: { createdAt: "asc" }, }); ``` Because messages are ordinary synced rows, reconnecting clients catch up from their cursor and see the rows they missed while offline. ## Retention Deleting or archiving old `messages` rows is your app's policy. The sync log is still durable audit/history: messages have no message-specific TTL. That is useful for coordination and compliance, but a chat-scale product should plan retention before writing high-volume conversation traffic. --- Source: https://www.abloatai.com/api/docs/agents # Agents > The stateless participant: wake on a trigger, read, claim, commit, go idle. An agent is a **reactive** participant: it wakes on something happening, reads what it needs, writes a result, and goes idle. That's a request/response workload — so agents talk to Ablo over **plain HTTP**, holding no WebSocket. The credential *is* the identity; the server resolves the org, scope, and actor from the key on every request. Agents get the stateless plane (HTTP). People — when you add the `humans()` plugin — get the live plane (WebSocket: presence, optimistic, sub-100ms). **Both operate on the same typed, coordinated state — and coordinate *with each other*.** Agents transact against your **pushed schema**, same as everyone — `ablo.records` exists because you defined a `record` model and ran `ablo push`. The key authenticates; the [schema](/quickstart) defines what you can call. ## The agent client Same `Ablo()` entry point as everywhere else — pass `transport: 'http'`. No socket, no connection state — just your schema (for types) and an API key. ```ts import Ablo from "@abloatai/ablo"; import { schema } from "./schema"; const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, transport: "http" }); // Reads + writes, fully typed off your schema. // `get` resolves to the row, or `undefined` when none matches. const open = await ablo.records.list({ where: { status: "todo" } }); const record = await ablo.records.get({ id: open[0].id }); if (!record) throw new Error("record not found"); console.log(record.title); await ablo.records.update({ id: record.id, data: { status: "done" } }); ``` It exposes `get` / `list` / `create` / `update` / `delete`, plus `commits` and `claim`. It does **not** expose stateful-only `local` reads or `onChange` subscriptions. Those need a live connection, so with `transport: 'http'` they are compile errors rather than runtime surprises. ## Managed scoped agents When this process owns the secret client and also runs the agent, prefer `agents.create`. It mints the restricted credential, returns a schema-typed client, and renews that credential for a long run. `sessions.create({ agent })` is the raw-token path for handing identity to another runtime. Derive identity and groups from the run row or trusted job payload—not from model output or an HTTP request body. A serverless handler normally creates and disposes one child per invocation: ```ts const run = await control.runs.get({ id: verifiedRunId }); if (!run) throw new Error('run not found'); const agent = await control.agents.create({ id: `run:${run.id}`, name: 'run-worker', can: { records: ['read', 'update'] }, syncGroups: [`workspace:${run.workspaceId}`], }); try { await executeRun(agent, run); } finally { await agent.dispose(); } ``` Use a stable id only when one logical run is serialized; two concurrent workers that share an id appear as the same participant. For independent concurrent work, omit `id` and let Ablo create distinct identities. A long-running worker may cache one managed client per stable scope, but the cache owns lifecycle: evict idle clients, call `dispose()` on eviction, and dispose every client during graceful shutdown. Never cache a client and later reuse it for a different workspace or capability set. ```ts const agents: Record< string, Awaited> | undefined > = {}; async function agentFor(run: Run) { const key = `${run.workspaceId}:${run.workerSlot}`; const cached = agents[key]; if (cached) return cached; const created = await control.agents.create({ id: `worker:${key}`, can: { records: ['read', 'update'] }, syncGroups: [`workspace:${run.workspaceId}`], }); agents[key] = created; return created; } ``` ## AI SDK tools Keep AI SDK in charge of the model loop and expose only the Ablo operations the model needs: ```ts import { generateText } from 'ai'; import { createTool, deleteTool, readTool, updateTool, } from '@abloatai/ablo/ai-sdk'; const tools = { getTask: readTool(ablo.records, { description: 'Read the current record.', inputSchema: z.object({ recordId: z.string() }), id: ({ recordId }) => recordId, }), createTask: createTool(ablo.records, { description: 'Create a record.', inputSchema: z.object({ requestId: z.string(), title: z.string() }), id: ({ requestId }) => requestId, data: ({ title }) => ({ title, status: 'todo' }), }), updateTask: updateTool(ablo.records, { description: 'Update a record without overwriting concurrent work.', inputSchema: z.object({ recordId: z.string(), status: z.string() }), id: ({ recordId }) => recordId, apply: (_current, { status }) => ({ status }), }), deleteTask: deleteTool(ablo.records, { description: 'Delete a record after taking its claim.', inputSchema: z.object({ recordId: z.string() }), id: ({ recordId }) => recordId, // Destructive tools require AI SDK approval by default. }), }; await generateText({ model, messages, tools }); ``` These are adapters over the same typed resources used by ordinary backend code. Ablo does not own the planner, prompt system, memory, provider, worker, or workflow runtime. ## Coordination: claim, queue, reorder The differentiator. A claim is a **durable lease + FIFO wait-line** on a row — "who's working on this, who's waiting" — and it's request/response, so an agent holds it over HTTP. This is how two agents (or an agent and a human) don't clobber the same record. ```ts // Acquire a lease, do work with the held row, release on scope exit: await using claim = await ablo.records.claim({ id: recordId }); const record = claim.data; // …no one else can hold this row while you work… await ablo.records.update({ id: record.id, data: { status: "in_review" }, claim, }); await ablo.records.claim.state({ id: recordId }); // who holds it now (or null) await ablo.records.claim.queue({ id: recordId }); // the FIFO wait-line behind the holder await ablo.records.claim.reorder({ id: recordId, order: line }); // re-rank the line (privileged) ``` Think of it as a queue per row — a durable, inspectable, reorderable lease line. Use `contention: { mode: 'skip' }` for fail-fast dedup: *if someone else has this job, skip it.* ## Messaging between agents Use claim `description` and `meta` for live "what I am doing now" context. Use ordinary synced rows for handoffs, status notes, and requests that must survive reconnects or be readable by HTTP agents. The recipe is a `messages` model scoped by the same syncGroup field as the work row, with `aboutIntentId` linking a message back to the claim it discusses. See [Agent Messaging](/agent-messaging) for the schema and setup details. ## When a person is in the loop There's no separate "agent mode" — and no separate human mode either. The bare client is the coordination layer; `humans()` is the plugin that adds the live plane on top of it. An agent acting over HTTP and a person editing over their socket share the same typed state and the same coordination: the agent can claim the row that person is holding (and wait in line), and they see the agent's committed changes stream in **live** over their own socket, even though the agent committed over HTTP. There is no `agents()` plugin, and the absence is the point — an agent is the default caller here, not a bolt-on. ## How an agent runs ```text something happens ──▶ your agent (HTTP, no socket) (a job, a webhook, read context (list/get) a queue message) claim → work → commit done — no held connection ``` Because it holds nothing open, an agent is a **stateless worker**: deploys and restarts are free, and you scale by adding workers. A long-running fleet of idle agents costs nothing on the live plane — that capacity stays for humans. ## What stays on the live (human) plane `onChange` (live subscriptions) and the `local` reads (local synced-pool reads) require a WebSocket and a local store — they're for interactive UIs, not stateless agents. An agent reacts to an external trigger (a job/queue/webhook), then reads with `list`/`get`. See [client behavior](/client-behavior) for the full surface and [guarantees](/guarantees) for the coordination semantics. --- Source: https://www.abloatai.com/api/docs/AGENTS.md # AGENTS.md Ablo lets AI agents and humans safely edit the same typed data without clobbering each other. When two of them touch the same row, a "claim" makes one wait for the other instead of overwriting it. This file shows a coding assistant the one safe pattern: read a row, claim it, then write. Claims don't lock. If another writer holds the row, `claim` waits for them and re-reads the fresh row before handing it to you — so two writers serialize instead of clobbering. ## Start here — scaffold with `ablo init` Don't hand-write the integration. Run the CLI; it generates the current-API schema, client, the database connection (logical replication by default, or a signed Data Source endpoint as the fallback), and (for Next.js) the browser provider + session route: - **Read the docs for THIS version:** `npx ablo docs` lists every page, `npx ablo docs ` prints one. They ship inside the installed package, so they describe the code in `node_modules` and work with no network. Read them instead of a docs URL — a website describes the newest release, so against a pinned version it will hand you a call your package doesn't have (`retrieve`/`list` replaced `get`/`getAll`/`getCount` in 0.35.0). - **Scaffold:** `npx ablo init --yes` — flag-driven, never prompts. Override defaults with `--framework `, `--auth `, `--no-agent`, `--no-pull`, `--no-install`, `--no-login`. (Plain `ablo init` needs a TTY and will **HANG** in an agent/CI run — always pass `--yes`.) - **Auth:** set `ABLO_API_KEY` in the environment. Do **NOT** run `ablo login` — it opens a browser device flow and blocks an agent. - **Connect your database — logical replication (the primary path):** `npx ablo connect apply --url ` provisions scoped roles and replication using the admin credential supplied for that command. Model writes then go through Ablo and land in your Postgres; its change stream confirms them. Ablo does not own your rows or run application-schema migrations. Your ORM remains responsible for tables, columns, and constraints. - **Fallback — signed Data Source endpoint** (DB can't grant a `REPLICATION` role): the generated `ablo/data-source.ts` exposes one route; Ablo sends signed requests and your app touches its own DB. **Only in this mode** does `npx ablo migrate` provision the adapter's bookkeeping tables (`ablo_outbox`, `ablo_idempotency`) plus your Ablo models — it does **not** touch your other tables. Keep your own migrations (drizzle-kit / prisma migrate) for auth and anything outside the Ablo schema. - **No database yet?** Run `npx ablo dev --no-watch --branch ` to create an isolated non-root branch and obtain its expiring `sk_` credential. The branch uses a throwaway hosted data plane; Production remains the protected root. There is no shared Sandbox mode. - **Adopt an existing DB schema:** `npx ablo pull prisma [path]` / `pull drizzle ` (lossless) or `pull` (live DB, lossy). Writes `ablo/schema.ts`. - **Prepare a branch and push your schema — REQUIRED before any write works.** The server keeps its OWN copy of the schema. After you create or edit `ablo/schema.ts`, run `npx ablo dev --no-watch --branch `: it ensures an isolated branch, mints a temporary branch credential, writes it to gitignored `.env.local`, pushes once, and exits. **Skip this and every write to a new or changed model fails with `server_execute_unknown_model`.** Plain `ablo dev` watches forever — never run it bare in an agent. `ablo push` is the lower-level one-shot command for an already selected key, including reviewed production deploys. - **Do not invent one key variable per environment.** Branches replaced manual `ABLO_STAGING_KEY` / `ABLO_DEV_KEY` switching. Runtime code always reads `ABLO_API_KEY`; `ablo dev` wires the Git branch's expiring key. Use `npx ablo whoami` for the strict server-confirmed project/branch, or `npx ablo whoami --key-env ` only to inspect an explicitly named CI/legacy/recovery key without copying its value into argv. - **Other long-running:** `npx ablo logs --no-follow` (default tails forever). `status`, `branch check`, `push`, `pull`, `check`, `generate`, `docs`, and `dev --no-watch` are one-shot. When you use the signed-endpoint fallback, the generated `ablo/data-source.ts` is the whole endpoint and needs no hand-editing: `dataSourceNext({ schema, apiKey, adapter: prismaDataSource(prisma, schema) })` (or `drizzleDataSource(db, schema)`). The adapter owns commit / idempotency / outbox. **Working on a real database?** Plain model writes are last-write-wins when no active claim applies. Use a functional update, a held claim, or `readAt` when a result depends on an earlier value. Reads are safe to inspect; raw application DDL (`ALTER TABLE …`) and a `--yes` connection cutover belong to a human. When you're unsure whether a write fits, `npx ablo check` reports the live column-by-column fit read-only, before anything runs. Full sorting rule: [Operating on Your Database](./docs/operating-on-your-database.md). ## Rule Edit the generated files; teach this API only: ```ts const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY }); ``` The schema is the integration contract — it drives typed model clients, React selectors, server and agent writes, the Data Source shape, and schema push. Ablo owns only the models you declare; your auth and other non-synced tables stay in your own ORM schema, side by side in the same database. Don't create a parallel string-keyed write path for rows that belong to a schema model. Every model verb takes ONE options object. The common loop: 1. **Read** the row — `await ablo..get({ id })` (async; from the server) or `await ablo..list({ where })` for many. In React render, read synchronously with `useAblo((a) => a..local.get(id))`. 2. **See who's active** (optional) — `ablo..claim.state({ id })` (synchronous; never blocks). 3. **Claim** the row before changing it — `await using claim = await ablo..claim({ id, description?, ttl? })`. If someone else holds it, this waits for them, then gives you the fresh row on `claim.data`. The claim auto-releases when it goes out of scope (`await using`). 4. **Write** — `await ablo..update({ id: claim.data.id, data })`. Because you hold the claim, the write is rejected if the row changed underneath you. Keep coding assistants on this schema-backed path. ## Minimal example ```ts import Ablo from '@abloatai/ablo'; import { defineSchema, model, z } from '@abloatai/ablo/schema'; const schema = defineSchema({ weatherReports: model({ location: z.string(), status: z.enum(['pending', 'ready']), forecast: z.string().optional(), }), }); const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY }); const report = await ablo.weatherReports.get({ id: 'report_stockholm' }); if (!report) throw new Error('Report not found'); // If someone else holds the row, claim waits for them and re-reads the fresh // row before resolving. Auto-released at the end of this scope (`await using`). await using claim = await ablo.weatherReports.claim({ id: 'report_stockholm', description: 'forecasting', ttl: '2m', }); const claimed = claim.data; // Because we hold the claim, update is rejected if the row changed underneath us. await ablo.weatherReports.update({ id: claimed.id, data: { status: 'ready', forecast: await getForecast(claimed.location) }, }); ``` ## Coordination surface Claims live on a callable namespace beside `create` / `update` / `get`. Every member takes an options object: - `await using claim = await ablo..claim({ id })` — acquire the row (waits if held); read it via `claim.data`; auto-releases on scope exit (or call `claim.release()`). - `ablo..claim.state({ id })` — who is currently working on the row (synchronous; never blocks). - `ablo..claim.queue({ id })` — who is waiting behind the current holder. - `ablo..claim.release({ id })` — release a claim early. - `ablo..claim.reorder({ id, order })` — reorder the waiting queue. Keep admission behavior together for anything beyond the default wait: ```ts const claim = await ablo.records.claim({ id, contention: { mode: 'skip', // use 'wait' with maxDepth / timeoutMs when waiting is useful onStatus(event) { if (event.type === 'skipped') console.warn(event.error.message); }, }, }); if (!claim) return; // another participant already owns the work ``` `onStatus` receives typed `queued`, `granted`, `skipped`, and `failed` events. It is request-scoped; use `claim.state` / `claim.queue` for the shared reactive view. Most users declare a schema and write through `ablo..update({ id, data })`. --- Source: https://www.abloatai.com/api/docs/api # API > The per-method reference for every model call an agent or an interface can make. > **Upgrading?** Follow the version-matched workflow in the > [Upgrade Guide](./migration.md), then read the intervening changelog entries. This is the per-method reference for reading and writing rows that stay in sync across sessions. You declare your models once, then call the same `ablo.` methods from React, a server action, or an agent — and every confirmed write streams to everyone watching. When two writers touch the same row, you can optionally `claim` it so they serialize instead of clobbering each other. Two things to know before the method list. **Reads come in two flavors:** `get({ id })` / `list({ where })` are async — they answer from what is already local and fall back to the server. Put `local.` in front of either and you get the same read restricted to what is already here, which is why it can return a value rather than a promise: `local.get(id)`, `local.list({ where })`, `local.count({ where })`. Use those in render, after data has synced. **Claims don't lock.** If another writer holds the row, `claim` waits for them, re-reads the fresh row, then hands it to you — so two writers serialize instead of clobbering. Start with the schema client: ```ts import Ablo from '@abloatai/ablo'; import { defineSchema, model, z } from '@abloatai/ablo/schema'; const schema = defineSchema({ weatherReports: model({ location: z.string(), status: z.enum(['pending', 'ready']), }), }); const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY }); await ablo.ready(); const report = await ablo.weatherReports.get({ id: 'report_stockholm' }); if (!report) throw new Error('Row not found'); await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' } }); ``` For end-to-end app setup across React, existing backends, Data Source, and agents, read the [Integration Guide](./integration-guide.md). ## Model Methods Each schema model becomes a typed model on the client: - `ablo.weatherReports.get({ id })` reads one row asynchronously (server read). - `ablo.weatherReports.list({ where })` reads a collection asynchronously (server read). - `ablo.weatherReports.listAll({ where })` explicitly reads every matching page. - `ablo.weatherReports.local.get(id)` reads one row synchronously from the local graph. - `ablo.weatherReports.create({ data })` creates a row. - `ablo.weatherReports.update({ id, data, ...options })` updates a row. - `ablo.weatherReports.delete({ id, ...options })` deletes a row. `local.` narrows a read to what has already synced. `get({ id })` and `list({ where })` answer from the local graph and fall back to IndexedDB and then the network, so reach for them when the row may not be here yet. `local.get(id)` and `local.list({ where })` are the same reads with the fallback removed — nothing to await, so they return a value. | Method | Returns | Use when | |---|---|---| | `get({ id })` | `Promise` | You need one row, hydrating from local store and server. | | `list({ where })` | `Promise>` | You need to hydrate a collection from local store and server. | | `listAll({ where, maxPages?, signal? })` | `Promise` | You deliberately need every matching row. | | `local.get(id)` | `T \| undefined` | You want a synchronous snapshot of one local row. | | `local.list(options?)` | `T[]` | You want a synchronous snapshot of a local collection. | | `local.count(options?)` | `number` | You want a synchronous count of local rows. | | `create({ data, ...options })` | `Promise` | You want to create through the schema model. | | `update({ id, data, ...options })` | `Promise` | You want to update through the schema model. | | `delete({ id, ...options })` | `Promise` | You want to delete through the schema model. | `get`, `list`, `create`, `update`, and `delete` are the main path — they go through the server. The `local` reads work off the rows a session has already synced, so a cheap re-read needs no round-trip. ### Reading a whole collection Prefer a filtered `listAll` when the application truly needs one complete array. It follows the same cursor loop as async iteration, defaults to at most 100 pages, and checks an abort signal between requests and rows: ```ts const controller = new AbortController(); const open = await ablo.weatherReports.listAll({ where: { status: ['draft', 'review'] }, orderBy: { createdAt: 'asc' }, maxPages: 25, signal: controller.signal, }); ``` A complete traversal can be expensive in latency, memory, and read volume. Narrow it with `where`; use `list` and its cursor when a UI or worker can process one page at a time. `for await` walks the pages: ```ts const open = []; for await (const report of await ablo.weatherReports.list({ where: { status: ['draft', 'review'] }, orderBy: { createdAt: 'asc' }, })) { open.push(report); } ``` `list` returns a page, because the server applies a default size and caps the largest. The result is an array, so it maps and iterates as before, and it carries `hasMore` and `nextCursor` alongside the rows. Iterate it to work with the page you were handed; `for await` it to work with the collection. ```ts const page = await ablo.weatherReports.list({ where: { status: 'draft' } }); page.length; // the rows this page carries page.hasMore; // whether the collection continues past them ``` Take the cursor yourself when the pages go somewhere other than a loop — one screenful at a time, or a job that stops and resumes: ```ts const page = await ablo.weatherReports.list({ where: { status: 'draft' }, limit: 100 }); const next = page.hasMore ? await ablo.weatherReports.list({ where: { status: 'draft' }, limit: 100, cursor: page.nextCursor }) : null; ``` Keep `where` and `orderBy` the same across pages: the cursor encodes the sort position it was issued for, and a read that changes either starts a new walk. `where` accepts operators as well as equality, and both travel to the server: `{ status: ['draft', 'review'] }` is an `IN`, and tuple form spells the rest out, as in `[['title', 'ILIKE', '%storm%'], ['createdAt', '>=', cutoff]]`. ### Changing a field, and clearing one `null` clears a field: ```ts await ablo.weatherReports.update({ id, data: { reviewerId: null } }); // unassigned await ablo.weatherReports.update({ id, data: { reviewerId: 'usr_2' } }); // reassigned ``` An update is a patch, so a field you leave out keeps its value. That makes `undefined` and "leave it alone" the same thing: `{ reviewerId: undefined }` is dropped from the payload and the old reviewer stays. Reach for `null` whenever a value is going away, and the type will hold you to it — only a field your schema declares optional accepts one, since a required field has no empty value to move to. ## Protected Writes Use `snapshot` when a write should reject if the row changed mid-flight: ```ts const snap = ablo.snapshot({ weatherReports: 'report_stockholm' }); await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' }, readAt: snap.stamp, onStale: 'reject', }); ``` Reactive local state changes optimistically at call time; awaiting the model write waits for authoritative confirmation. Protected write options: | Option | Purpose | |---|---| | `readAt` | The state cursor the write was based on. | | `onStale` | Stale-state policy. Prefer `reject` for agent writes. | | `idempotencyKey` | Stable key for retry-safe writes. The SDK generates one when omitted. | | `timeout` | Maximum time to wait for the write call. | ## Claims Before anyone writes a row, they can claim it so other agents and people see who is editing it in real time. Claims don't lock. If another writer holds the row, `claim` waits for them, re-reads the fresh row, then hands it to you — so two writers serialize instead of clobbering. A claim is temporary: it expires on its own if the holder stops, and is never saved as a row. You coordinate a row with calls on its model, beside `create`/`update`/`get`: `ablo..claim({ id })` takes the claim and returns a handle, `ablo..claim.state({ id })` reads who currently holds it (synchronous, never blocks), and `ablo..claim.release({ id })` releases it early. The full coordination surface is `claim.state({ id })` / `claim.queue({ id })` / `claim.release({ id })` / `claim.reorder({ id, order })` hanging off `claim`. The fields on a claim, its lifecycle diagram, and the full method surface are in [Coordination](./coordination.md#the-claim-state-object), which is where that object is defined. Note that the entity half of `target` is spelled `model`/`id` on the SDK's model surface and `type`/`id` on the claim handle and the wait line. ### Reading and claiming `claim.state({ id })` is the read side for observers: synchronous, never blocks, and returns the live claim state object (or `null`). `claim({ id })` is the write side: it takes the claim and returns a `ClaimHandle`. Claims don't lock — if someone else already holds the row, `claim` waits for them to finish, re-reads the fresh row, then hands it to you, so you always proceed from current state. Default reads return the row even while someone is mid-edit; if a server read should not return a row while it's claimed, pass `ifClaimed: 'fail'` to error out instead. Reads never block on a claim — to wait for a row to free up, `claim({ id })` it (the claim queues fairly behind the holder). ```ts const claim = ablo.weatherReports.claim.state({ id: 'report_stockholm' }); if (claim) { claim.heldBy; claim.description; } const handle = await ablo.weatherReports.claim({ id: 'report_stockholm', description: 'editing', ttl: '2m', }); await ablo.weatherReports.update({ id: handle.data.id, data: { status: 'ready' } }); await handle.release(); ``` Writes go through the normal `ablo..update({ id, data })`. While you hold a claim on `id`, that `update` rejects with `AbloStaleContextError` if the row changed underneath you since you took the claim, so you re-read before retrying. Call `handle.release()` (or `ablo.weatherReports.claim.release({ id })`) to release the claim when your work is done. ## Agent Most agents should import the same schema as the app and call `ablo..list(...)`, `ablo..claim({ id })`, and `ablo..update({ id, data })`. ## HTTP API The SDK is a convenience wrapper over a model-scoped HTTP surface — the same noun (`model`) and verbs as `ablo..…`. Non-JS callers (or curl) use it directly. The table below shows the shape with `{model}` as a placeholder; the [OpenAPI spec](./openapi.json) expands it into one **typed** path per model (`/api/v1/models/record`, `/api/v1/models/workspace`, …, generated from your schema) so each endpoint documents that model's real field contract instead of a generic blob. | SDK call | HTTP | |---|---| | `ablo..create({ data })` | `POST /api/v1/models/{model}` | | `ablo..list({ where })` | `GET /api/v1/models/{model}` | | `ablo..get({ id })` | `GET /api/v1/models/{model}/{id}` | | `ablo..update({ id, data })` | `PATCH /api/v1/models/{model}/{id}` | | `ablo..delete({ id })` | `DELETE /api/v1/models/{model}/{id}` | | `ablo..claim({ id })` | `POST /api/v1/models/{model}/{id}/claim` | | (release a claim) | `DELETE /api/v1/models/{model}/{id}/claim` | Auth is a bearer API key: `Authorization: Bearer sk_…`. Mutations take an `Idempotency-Key` header — derive it from the business event, not a random value, so a retry never double-writes. Direct HTTP writes return a protocol receipt; the typed SDK turns single-model writes into their application result (the created or updated row, or nothing for delete). A rejected write carries an error `code` (e.g. `stale_context`, `intent_conflict`) to act on. `GET /api/v1/models/{model}` is cursor-paginated (`limit`, `order`, `order_by`, `cursor`) and returns `{ data, has_more, next_cursor }`. The `starting_after` spelling this parameter used through 0.52.0 is still honoured, and is removed in a later release. `POST /api/v1/commits` remains the path for **atomic multi-op** writes (several operations across rows/models that must commit together) — the per-model routes above are the one-record path. Both run the identical guarded-write engine. The [coordination MCP server](./mcp.md) (`@abloatai/mcp`) is this same surface rendered as agent tools. ## Errors All SDK errors extend `AbloError` and expose a stable `type` string. | Error | Meaning | |---|---| | `AbloAuthenticationError` | Missing, invalid, or expired credential. | | `AbloPermissionError` | Credential is valid but the action is outside scope. | | `AbloRateLimitError` | Rate limit or quota exceeded. | | `AbloIdempotencyError` | Idempotency key was reused with a different request. | | `AbloConnectionError` | Network, timeout, abort, or transport failure. | | `AbloValidationError` | Invalid input. | | `AbloServerError` | Server-side 5xx. | | `AbloStaleContextError` | `readAt` no longer matches current state. | | `AbloClaimedError` | Active claim conflict or claim wait timeout. | See [Client Behavior](./client-behavior.md) for retry and timeout guidance. --- Source: https://www.abloatai.com/api/docs/api-keys # API Keys > The credential that carries an agent's identity and bounds what it may write. Authenticate a server-side client — a route handler, worker, or CLI — by passing an API key when you create the client. ```ts import Ablo from '@abloatai/ablo'; const ablo = Ablo({ apiKey: process.env.ABLO_API_KEY }); ``` The server resolves the organization, project, immutable branch, and authority from the key. Application code does not pass those targeting axes separately. "Trusted" means the runtime can hold a secret: a backend or other server-side environment a browser can't read. Browser and app clients use the same `@abloatai/ablo` import but authenticate differently — they never carry a secret key. ## Start here: the normal workflow Branches replace manual environment-key juggling. You should not normally keep `ABLO_STAGING_KEY`, `ABLO_DEV_KEY`, and `ABLO_API_KEY_LIVE` beside one another and remap them before each command. | Job | Credential | How you get it | |---|---|---| | Manage a project or its branches | `mk_` | `npx ablo login --project ` stores it for the CLI. | | Develop locally | expiring `sk_` bound to the current branch | `npx ablo dev` writes it as `ABLO_API_KEY` in gitignored `.env.local`. | | Prepare a branch once, including CI | expiring `sk_` bound to that branch | `npx ablo dev --no-watch --branch `; CI supplies `ABLO_MANAGEMENT_KEY`. | | Run the production backend | `sk_` bound to the production root | Store it as the deployment's `ABLO_API_KEY`. | | Read in a browser | `pk_` | Publishable, read-only key. | | Write in a browser as a user | short-lived `ek_` | Your backend exposes `authEndpoint` and mints it. | The everyday loop is therefore: ```bash npx ablo login --project # once per project npx ablo dev # follows Git, mints and wires this branch npx ablo status # broad readiness report ``` Application code and agents still read one variable: ```ts const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY }); ``` Switching Git branches does not re-scope the old key. Run `ablo dev` again; it ensures the matching Ablo branch and replaces `.env.local` with a fresh key bound to it. ## Three axes, not a key per environment name A credential answers three separate questions: 1. **Project:** which application inside the organization. 2. **Branch (plane):** the production root or one immutable development/preview child. Rows, schema, claims, logs, and database registration are isolated here. 3. **Capability and audience:** management (`mk_`), trusted runtime (`sk_`), restricted/delegated runtime (`rk_`), publishable browser read (`pk_`), or ephemeral user session (`ek_`). The prefix identifies the credential's capability class, not its target. `sk_` means a trusted runtime secret. The server-side key row decides whether that secret is bound to the production root or to a specific child branch. A project selection or branch slug in a request cannot override that binding. That is why Ablo does not need an application-level `environment` argument and why `ablo projects use` cannot re-scope a credential. The bearer credential already carries the target. ## Which credential to pass to the SDK There's **one field — `apiKey`** — and what you pass depends on **where the code runs**. Pick your row: | Where your code runs | What to pass | Example | |---|---|---| | **Server / worker / agent** (can hold a secret) | your secret `sk_`: it defaults to `ABLO_API_KEY`, so usually pass **nothing** | `Ablo({ schema })` | | **Browser: read-only** | a publishable `pk_` (safe to ship) | `Ablo({ schema, apiKey: process.env.NEXT_PUBLIC_ABLO_PUBLISHABLE_KEY })` | | **Browser: writing as the signed-in user** | `authEndpoint`: the route on your own backend that mints a short-lived per-user token | `Ablo({ schema, authEndpoint: '/api/ablo-session' })` | That's the whole story: one knob, filled by audience. The `mk_` credential created by `ablo login` is different: it is a CLI control-plane credential, not an application API key. It can manage projects and branches and exchange for a branch-bound runtime key. Do not pass it to `Ablo(...)` or put it in `ABLO_API_KEY`. The credential class lives in the prefix: | Prefix | Purpose | Stored where | |---|---|---| | `mk_` | project and branch management | CLI credential store or `ABLO_MANAGEMENT_KEY` | | `sk_` | trusted runtime, full branch authority | server-side `ABLO_API_KEY` | | `rk_` | restricted runtime or agent | trusted runtime that needs the delegated scope | | `pk_` | publishable, browser-safe read access | browser bundle | | `ek_` | short-lived user session | browser memory | The prefix does not select a branch; the immutable server-side binding does. For an `ek_`, the server mints and the client holds the short-lived result. **Why a function for browser writes?** Anything you ship to a browser must be public, and a public `pk_` is **read-only** — it can't carry one specific user's write authority. So when the browser writes *as the logged-in user*, your backend (which holds the secret `sk_` and knows who's signed in) mints a short-lived per-user token with `sessions.create({ user, can })`, and the browser's `apiKey` function fetches it. You don't manage refresh — the SDK calls the function once before connecting and then keeps the token fresh (re-mint before expiry, and on tab-focus / network-online / device-wake). For a read-only app you don't need any of this — just the `pk_` above. Server-side, because `apiKey` defaults to `process.env.ABLO_API_KEY`, most backend and agent code passes nothing. The secret `sk_` is **server-only** — never in a browser bundle. There is no `getToken` or `as` option — `apiKey` (the key a server holds) and `authEndpoint` (the mint route a browser points at) are the two credential knobs, and you set exactly one. ### Minting per-user / agent tokens (server-side, with your `sk_`) | Mint | Call | Result | |---|---|---| | Human end-user session | `await server.sessions.create({ user: { id }, can: { records: ['read'] } })` | `ek_` (scoped to `can`) | | Ready agent client | `await server.agents.create({ can: { records: ['update'] } })` | Auto-refreshing client scoped to `can` | | Raw delegated agent token | `await server.sessions.create({ agent: { id }, can: { records: ['update'] } })` | `rk_` for another runtime | The principal kind comes from *which* shape you pass — `{ user, can }` → `user`, `{ agent, can }` → `agent`. ## Server-Side API Keys Use API keys from trusted (server-side) runtimes: - backend route handlers - workers and agents - CLI tools - webhooks Never ship a secret API key to a browser bundle. ## Publishable key (`pk_`): browser-safe, read-only For a read-only browser experience, a publishable key is safe to ship in the bundle. It is long-lived, org-scoped, and used **directly as the bearer** — never exchanged, never expires, nothing to refresh: ```ts const ablo = Ablo({ apiKey: process.env.NEXT_PUBLIC_ABLO_PUBLISHABLE_KEY }); // pk_… ``` A `pk_` grants **read-only** access to the org's data plane: it cannot write and cannot reach any control-plane operation. The moment the browser needs to write on a specific user's behalf, mint a short-lived `ek_` user session from your backend instead (see the Sessions guide). ## Branches and production A branch is your project at full strength over its own rows: the same models, the same schema, the same claims and the same rules production runs. Production is the project's root branch. Development branches are isolated children, and a key's immutable branch binding decides which rows, schema, claims, and log it can reach: - an `sk_…` bound to a development branch reads and writes only that child; its rows are invisible to production and to other branches. - an `sk_…` bound to the root reads and writes production. `npx ablo dev` derives a branch from Git, ensures the matching child, and mints an expiring `sk_` key for it. The credential carries the immutable branch id; changing a slug in a request cannot change its authority. A child receives the parent's active schema when it is created and owns its artifact after that. A schema change reaches production only through the reviewed root-branch path in [Deployment](./deployment.md). The shared default sandbox is no longer part of the development workflow. Branch identity is required for newly provisioned CLI and runtime credentials. ## Inspecting a credential Use `status` for the whole setup and `whoami` for the narrow identity question: ```bash npx ablo status npx ablo whoami ``` `whoami` succeeds only when the server confirms the credential's organization, project, and branch. It never prints the full secret. For CI or recovery, inspect an explicitly named value without remapping `ABLO_API_KEY`: ```bash npx ablo whoami --key-env PREVIEW_ABLO_KEY npx ablo whoami --key-env ABLO_API_KEY_LIVE --json ``` `--key-env` reads that exact name from the process, `.env.local`, or `.env`; the name makes the choice explicit, while the secret stays out of argv and shell history. `--key ` exists for one-off use but is less safe because shells and process listings may retain the value. Multiple custom-named keys are reasonable at a CI secret boundary or during a one-time migration from the old environment model. They are not the normal local-development workflow. After a stranded-plane recovery, retire obsolete variables rather than keeping them as permanent branch selectors. ## Scopes Keys carry scopes following the principle of least privilege — each key gets only what its job needs. A secret key with **no scopes** has full org authority (the default for a `sk_` backend key); a key with a non-empty scope set is restricted to exactly those grants: - `schema:push` — author the schema artifact on the key's bound plane (`ablo push`, `ablo dev`). A production push is high-risk because it changes the live contract; a child push remains inside that branch. A full-authority key has it implicitly; a restricted key needs it explicitly. - `project:manage` — list, create, and rename projects. - `branch:manage` — list, create, and delete child branches and mint their temporary credentials. - `organization:act-as` — cross-organization authority to mint a short-lived user session into a customer organization. It follows the Stripe Connect shape: the request names the customer organization, but the resulting session is still bounded by its `can` grant and expiry. A key restricted to this scope cannot directly read or write customer organizations' rows, push schema, or manage projects. Both management scopes are explicit grants on `mk_` credentials. Runtime `sk_`, `rk_`, `pk_`, and `ek_` credentials cannot become management credentials through an empty scope set or a CLI fallback. Branch binding remains an authority boundary even when a key has no granular scope strings: a temporary child key can act only inside that child. It cannot manage siblings or gain root authority. ### Cross-organization mint keys Most applications do not need `organization:act-as`: their backend key mints users into its own organization. A multi-organization backend needs it only when each customer is a separate Ablo organization and one trusted service mints sessions for all of them. Treat that key as a dedicated minting credential: - keep it in a server-side secret manager, never a browser or repository; - grant only `organization:act-as`, with no data or schema scopes; - mint short-lived sessions with the smallest typed `can` grant; - rotate it on a schedule and revoke it immediately after suspected exposure; - log the target `organizationId`, minted session id, and request id for audit. The scope's broad name describes the cross-organization check it passes, not the authority of each resulting session. The session can act only inside the named customer organization and only for the models/verbs in `can`. See [Customer Organizations](./customer-organizations.md) for the complete integration. ## Current and legacy key spellings New credentials use one spelling per capability class: ```text sk_… trusted runtime rk_… restricted runtime pk_… publishable read-only browser ek_… ephemeral user session mk_… project and branch management ``` Older `sk_live_…`, `sk_test_…`, `rk_live_…`, and related credentials continue to authenticate during migration. Their `live`/`test` segment is a legacy hint, not the source of truth. Rotation mints the current spelling, and `ablo whoami` shows the persisted branch that actually controls the key. ### `ablo dev` ```sh npx ablo login npx ablo dev ``` The stored `mk_` project credential is used only to ensure the Git-derived child and mint an expiring branch credential. `dev` writes that temporary key to gitignored `.env.local`, pushes `ablo/schema.ts` to the child, and re-pushes on every save. See [Branch-first development](./branch-development.md). --- Source: https://www.abloatai.com/api/docs/audit # Audit log > Trace any committed change back to the key that made it, and to the person behind that key. The audit log records who changed what in your org, and when — including changes an AI agent made on a person's behalf. Every change is one row, and the rows are chained with a keyed hash (HMAC-SHA256) so you can later prove the history wasn't altered. You can filter it, page through it, and export it. ## Row shape Each stored row carries both the attribution fields — who acted, on whose behalf, with which key — and the chain columns that make the log tamper-evident: ```ts { id: string, occurredAt: '2026-05-14T14:22:01.034Z', actorKind: 'user' | 'agent' | 'system', actorId: string, onBehalfOfKind: 'user' | 'agent' | 'system' | null, onBehalfOfId: string | null, capabilityId: string | null, // the API key/capability used for the write capabilityLabel: string | null, // its human-readable name, for scanning the log delegationChainRootUserId: string | null, // always points at a human actionType: 'I' | 'U' | 'D', // insert, update, delete modelName: string, // the model that changed, e.g. 'orders' modelId: string, // the row that changed confirmationState: 'auto' | 'previewed' | 'approved' | 'required_human_approval' | 'auto_historical', diffSummary: unknown, // chain columns, carried on every stored row and checked by verify (below) chainSeq: number, prevHash: string, rowHash: string, } ``` `confirmationState` records whether an agent's write ran on its own (`auto`), was shown first (`previewed`), was signed off (`approved`), or is still waiting on a person (`required_human_approval`) — it's also a filter on the list endpoint. ## Delegation chain Every action traces back to a human. Even when an agent makes the change, `delegationChainRoot` names the person who set that work in motion — there is no audit row whose root is an agent. ## Verify ```bash curl https:///api/orgs//audit/verify-chain?\ principalKind=agent\ &principalId=weather-agent-v3 ``` Returns either: ```json { "ok": true, "rowsChecked": 10472, "fromSeq": 1, "lastSeq": 10472 } ``` or, on tamper: ```json { "ok": false, "brokenAtSeq": 8419, "reason": "hash_mismatch", "expectedHash": "a3f1c9…", "foundHash": "b7e04d…" } ``` Hashes come back as plain hex. `expectedHash`/`foundHash` accompany a `hash_mismatch` or `prev_hash_mismatch`; the other reasons (`sequence_gap`, `missing_root`, `no_rows`) stand on their own. Recomputing a row's hash needs the org's HMAC key, so verification runs where that secret is available. ## Filter and paginate The dashboard at `/[orgSlug]/audit` is the UI for this. The same filters are available on the API: ``` GET /api/orgs//audit?actorKind=agent&since=2026-05-01&limit=100 ``` Cursor-paginated. Continue with the `nextCursor` value from the response. ## Export ```bash curl 'https:///api/orgs//audit/export?actorKind=agent&since=2026-05-01' \ > may-agent-writes.csv ``` One request exports CSV up to a hard row cap. If your window is larger than the cap, the response is truncated at the cap rather than erroring — so for large windows, split the window by date and request each slice, or page through the JSON `GET` endpoint above using `nextCursor`. ## Compliance posture The [audit log landing page](/audit-log) is the marketing-side description. The HMAC-SHA256 chain algorithm and its semantics live in the `@ablo/audit-chain` package — the reference implementation, embeddable if you need to verify chains in a detached service (given the org's HMAC key). --- Source: https://www.abloatai.com/api/docs/branch-development # Branch-first development > Understand exactly what `ablo dev` prepares, what it watches, and how each Git branch gets an isolated Ablo schema and credential. `ablo dev` is Ablo's schema-development loop. It prepares an isolated Ablo branch for the Git branch you are working on, gives your local application a short-lived credential for that branch, pushes the schema, and keeps the schema registered as you edit it. It does **not** start your application or run database migrations. The everyday setup is two terminals: ```bash # Terminal 1: your application npm run dev # Terminal 2: Ablo's schema loop npx ablo dev ``` ## The mental model ```text Git branch │ │ npx ablo dev ▼ Ablo branch ├── active schema artifact ├── isolated transaction plane ├── immutable branch id └── expiring sk_ credential │ ▼ gitignored .env.local │ ▼ your local application ``` A branch name is a human handle. The credential carries the immutable Ablo branch id, so application requests cannot switch branches by changing a slug or request parameter. Production is the root branch. Development branches are children; they do not inherit production write authority. The branch workflow replaces a hand-maintained collection of environment keys. Your application always reads `ABLO_API_KEY`; `ablo dev` changes which branch-bound value sits there. Do not select branches in application code or invent `ABLO_STAGING_KEY`/`ABLO_FEATURE_KEY` conventions. See [API Keys](./api-keys.md#start-here-the-normal-workflow) for the complete project → branch → credential model. ## Before the first run Initialize the project and sign in: ```bash npx ablo init npx ablo login ``` Login stores one project-scoped `mk_` management credential. It has no production/test mode and no application-data authority. It can manage projects and branches and exchange for an expiring credential bound to one branch. If you switch projects, log in for the selected project before running `dev`: ```bash npx ablo projects use orders npx ablo login --project orders ``` ## What happens when you run it Given: ```bash git switch -c feature/order-approval npx ablo dev ``` Ablo performs these steps. ### 1. Discover the branch The first available source wins: 1. `--branch ` 2. `ABLO_BRANCH` 3. GitHub's `GITHUB_HEAD_REF` or `GITHUB_REF_NAME` 4. Vercel's `VERCEL_GIT_COMMIT_REF` 5. GitLab's `CI_COMMIT_REF_NAME` 6. the current local Git branch The reference is normalized into a lowercase plane-safe slug: ```text feature/order-approval → feature-order-approval Feature: Billing V2 → feature-billing-v2 ``` Long names are shortened with a stable hash, so the same Git branch resolves to the same Ablo branch on every machine. ### 2. Ensure the Ablo branch The CLI lists branches for the active project and reuses one with the same slug. If none exists, it creates a child of the project's production root. Creation is idempotent: two CI jobs racing to ensure the same branch converge on the same server record. At creation, the child receives its own copy of the parent's active schema. Later production schema pushes do not silently change an in-flight feature branch. ### 3. Exchange for a temporary credential The stored `mk_` credential authorizes the branch-management call. The server then returns a new `sk_` credential bound only to the child branch. The default lifetime is eight hours: ```bash npx ablo dev --branch-ttl-hours 12 ``` Allowed values are 1–168 hours. Rerunning `ablo dev` mints a fresh credential. This lifetime belongs to the credential, not the branch. The branch persists until `ablo branch delete` or preview automation removes it. Temporary branch credentials can read, write, and push schema on their own branch. They cannot create or delete siblings, mint sibling credentials, or obtain production authority. ### 4. Wire the local application The temporary credential is written to: ```dotenv # .env.local ABLO_API_KEY=sk_... ``` The CLI creates `.env.local` with owner-only permissions when possible and adds it to `.gitignore` if it is not already ignored. The key is not added to Ablo's long-lived credential store. Most application frameworks load `.env.local` automatically. Plain Node can load it explicitly: ```bash node --env-file=.env.local app.ts ``` An `ABLO_API_KEY` exported in your shell overrides `.env.local` for child processes. `ablo dev` warns when it detects that mismatch. Unset the exported value before starting the application: ```bash unset ABLO_API_KEY npm run dev ``` Confirm the branch the resulting key actually targets: ```bash npx ablo whoami ``` ### 5. Load and push the schema By default, the CLI imports: ```text ablo/schema.ts ``` and reads its `schema` export. Override either: ```bash npx ablo dev \ --schema src/ablo-schema.ts \ --export appSchema ``` The schema is serialized and uploaded to the selected branch. The server compares it with that branch's active artifact and returns either: - unchanged, with the current version; - activated, with a new version and hash; or - rejected, with the incompatible changes and the required next action. `push` registers a contract. It does not execute DDL. ### 6. Watch for edits After the first successful push, `ablo dev` watches the schema module. Editor write/rename bursts are debounced into one reload and one push. Stop it with `Ctrl-C`. For a single branch preparation and push: ```bash npx ablo dev --no-watch ``` ## Command reference ```bash # Discover from Git and watch npx ablo dev # Choose the branch explicitly npx ablo dev --branch preview-pr-482 # Push once npx ablo dev --no-watch # Change the temporary-key lifetime npx ablo dev --branch-ttl-hours 24 # Use another schema module/export npx ablo dev --schema db/ablo.ts --export schema ``` Lower-level branch operations are also available: ```bash npx ablo branch list npx ablo branch status feature-orders npx ablo branch check feature-orders npx ablo branch create feature-orders npx ablo branch ensure preview-pr-482 --credential --ttl-hours 168 --json npx ablo branch credential br_... --ttl-hours 8 npx ablo branch delete br_... ``` Automation should retain immutable ids returned by the API. Slugs are for people and discovery. `branch status` and `branch check` are aliases. They show lifecycle state, the active schema and hash, compatibility with the parent schema, the bound database's safe coordinates, and an exact readiness fix. ## CI and preview deployments For a one-shot CI schema check: ```bash # Store the project management credential as the masked secret. ABLO_MANAGEMENT_KEY="mk_..." \ ABLO_BRANCH="preview-pr-${PR_NUMBER}" \ npx ablo dev --no-watch ``` For infrastructure that needs the credential response directly: ```bash npx ablo branch ensure "preview-pr-${PR_NUMBER}" \ --kind preview \ --credential \ --ttl-hours 168 \ --json ``` `--credential` explicitly requests plaintext secret material. Treat the JSON result as a secret, mask it in logs, and pass it through the deployment provider's secret-variable mechanism. Closing a preview should call `branch delete`. Deletion immediately makes branch-bound credentials fail authentication even if their expiry is later. ## Managing branches in Sync Web The persistent dashboard header selects a project and then one branch inside it. The selection scopes API keys, Schema, Audit log, and Server log. Production is the protected root; every other item is a named development branch. Use the **Branches** page to create, select, inspect, or delete a branch. Deleting a child revokes all of its credentials first, removes its datasource connection material, and then removes it from discovery. It never deletes a sibling, the production root, customer tables, or retained schema history. See [Ablo branch lifecycle](../../../docs/explainers/branch-lifecycle.md) for the complete project/branch mental model and dashboard journey. ## Your database and migrations There are two independent contracts: ```text Your ORM/migration tool owns tables, columns, relations, and DDL Ablo schema owns the coordination contract for synced models ``` `ablo dev` currently isolates the Ablo branch and schema. It does not: - run Prisma, Drizzle, or SQL migrations; - create a Neon/Supabase/RDS database branch; - copy production data; - automatically register whichever `DATABASE_URL` happens to be present. The last point is a safety boundary. A generic `DATABASE_URL` does not prove that the database is an isolated feature branch; automatically registering it could bind a development credential to production. Until provider-verified database-branch binding lands, use Ablo's hosted branch plane for the inner loop or explicitly prepare and review an isolated database through [Connect Your Database](./data-sources.md). Never point a child branch at production merely because its migrations are compatible. ## Production rollout There is intentionally no `branch merge` that promotes child rows or logs. Production rollout remains: 1. Merge code and migration files through Git. 2. Run the reviewed migration against the production database. 3. Push the production/root Ablo schema through the deployment workflow. 4. Deploy the application. The feature branch proves the change. It does not become production. ## Troubleshooting ### “Creating a development branch needs the active project CLI key” Run: ```bash npx ablo login ``` For a non-default project: ```bash npx ablo login --project ``` If `ABLO_API_KEY` is exported, it overrides the stored login key. Unset it when you want the CLI to use the active project's stored profile. ### “Branch credentials cannot manage branches” You supplied a temporary child credential to a management command. Branch runtime credentials are deliberately leaf authority. Unset the override and let the CLI use the project login key. ### Check the whole branch ```bash npx ablo branch check feature-order-approval ``` The result distinguishes a missing schema, a branch lifecycle failure, and an unready connected database. A hosted branch is a valid development state and is reported explicitly rather than as a missing database. ### The app still reaches another plane Check for an exported variable: ```bash env | grep '^ABLO_API_KEY=' ``` An exported value wins over `.env.local`. Unset it and restart the application process so the framework reloads `.env.local`. ### `server_execute_unknown_model` The active branch does not have the schema containing that model. Keep `ablo dev` running, or push once: ```bash npx ablo dev --no-watch ``` Confirm that `--schema` and `--export` point at the module your application uses. ### Git branch discovery fails Detached checkouts may not expose a local branch. Pass the intended name: ```bash npx ablo dev --branch preview-pr-482 ``` or set `ABLO_BRANCH` in CI. ## Related guides - [Quickstart](./quickstart.md) — install, connect, define a schema, and write. - [CLI](./cli.md) — the complete command surface. - [Schema Contract](./schema-contract.md) — what the registered schema controls. - [Connect Your Database](./data-sources.md) — Postgres roles, WAL, and database ownership. - [Deployment](./deployment.md) — production schema rollout. --- Source: https://www.abloatai.com/api/docs/cli # CLI > Scaffold a schema, connect a database, push it, and watch it sync. The `ablo` CLI gets you from an empty project to live-syncing data: scaffold a schema, authenticate, push the schema, and watch it sync. Your `defineSchema(...)` is the single source of truth: whether you run the CLI locally or push to the hosted server, the same engine turns it into the same SQL — so what you test is what ships. ```bash npx ablo init # scaffold ablo/schema.ts + client npx ablo login # authorize in the browser npx ablo dev # prepare an isolated Git branch + push/watch ``` **Two setup styles, and they pick your commands.** If your app database is the source of truth, expose a [Data Source endpoint](./data-sources.md) and keep DB credentials in your app. If you explicitly want Ablo to open a Postgres connection, use the **Direct Postgres connector** commands: `ablo migrate` applies changes to your own `DATABASE_URL`, and `ablo check` / `ablo pull` adopt tables you already have. Hosted branch commands are tagged **Hosted**; direct-connector commands are tagged **Direct Postgres**. ## Authenticate `ablo login` runs the OAuth 2.0 device flow: it opens your browser, you choose **log in** or **create an account** and approve, and the CLI provisions a 90-day, project-scoped `mk_` management credential. It has no test/live mode and cannot read or write application data. `ablo dev` uses it to create or resume a branch and exchanges it for a temporary branch-bound runtime key. | Command | What it does | | ------------------------ | -------------------------------------------------------------------------- | | `ablo login` | Authorize in the browser; store one project management credential. | | `ablo login --project ` | Same, scoped to a project, which becomes active. | | `ablo logout` | Remove the stored credentials. | | `ablo whoami` | Strictly confirm which project and branch a credential acts on. | | `ablo status` | Show the active org/project, resolved runtime credential, branch target, and server health. | Credential precedence is deliberately small: 1. `ABLO_API_KEY` in the process environment. 2. An explicitly selected file, for commands that support `--env-file `. 3. A stored credential for legacy compatibility. Read-only orientation commands (`status`, `whoami`, `logs`, and `connect locate/check`) may inspect the application's `.env.local` so they describe what the app would use. Mutations (`push`, `connect apply/rotate/register`, and `connect deregister`) never let an ambient file silently choose a branch. Pass `--env-file .env.local` when that file is the intended source; the command reports the server-confirmed branch before it writes. For one-time recovery, `connect deregister --key-env ` selects that exact named value without putting the secret in argv. Keys live in `~/.config/ablo/credentials.json` (mode `0600`), keyed by project. The non-secret `config.json` holds the active project. In **CI**, don't log in — set the project management credential as `ABLO_MANAGEMENT_KEY`; it overrides the stored credential during branch bootstrap. ## Development branches and the production root A branch is your project at full strength over its own rows: the same models, the same schema artifacts, the same claims and the same rules production runs. Production is the project root. `ablo dev` creates or reuses a child branch for your Git branch, then mints a temporary `sk_` key bound to that child. Reads, writes, schema artifacts, claims, and credentials stay isolated from production and from other development branches, which is what makes a schema-changing pull request as routine as a code-only one. There is no local mode switch. Development selection comes from Git or `--branch`; production authority comes only from an explicit root-bound credential. Production schema changes use the reviewed one-shot path in [Deployment](./deployment.md). `ABLO_API_KEY` is the one runtime variable in every environment. Branches replace manually switching between custom names such as `ABLO_STAGING_KEY` and `ABLO_DEV_KEY`: rerun `ablo dev` when Git branches change. Use `ablo whoami` for the narrow identity question, or `ablo whoami --key-env ` to inspect an explicitly named CI/legacy key without exposing its value in argv. ## Projects An org can have multiple **projects**, each with its own isolated keys, schema, and data. Keys are scoped to a project **at mint** and never re-scoped, so the CLI keeps a separate credential profile per project. The active project (set with `projects use`) selects which profile every command authenticates with. | Command | What it does | | ----------------------------- | ---------------------------------------------------------------------------------- | | `ablo projects list` | List the org's projects (marks the active one and the org-default). | | `ablo projects create ` | Create a project (`--name "Display Name"`). Its keys/schema/data are isolated. | | `ablo projects use ` | Switch the active project. `ablo projects use default` returns to the org-default. | | `ablo login --project ` | Store management access for a project and make it active. | Because keys are fixed to a project, `projects use` only changes which profile is active — it never re-scopes an existing key. Switch to a project you haven't logged into yet and the CLI tells you to mint one: ```bash npx ablo projects use war-room # ✓ now targeting project war-room (prj_…) # No key stored for this project yet — run `ablo login --project war-room` to mint one. npx ablo login --project war-room # stores its management credential, keeps it active ``` If you run a project-scoped command (`push`, `dev`) while the active project has no key — but other projects do — the CLI **refuses** rather than silently deploying with the wrong project's credential, and names the fix (`ablo login --project `). In CI, an explicit `ABLO_MANAGEMENT_KEY` bypasses profiles for project/branch administration; the runtime key remains `ABLO_API_KEY`. ## Commands | Command | What it does | Flags | | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | `ablo init` | Scaffold `ablo/` (`schema.ts`, client, optional Data Source / agent / component), write `.env`, install the SDK. Offers to log in at the end. |: | | `ablo login` / `logout` / `whoami` / `status` | Authentication, exact credential identity, and readiness (above). | `whoami --key-env `, `whoami --json`, `status --json` | | `ablo projects list\|create\|use\|rename` | Manage projects and the active one (see [Projects](#projects)). Each project's keys/schema/data are isolated. | `--name ""` (create/rename) | | `ablo dev` | Ensure an isolated Git branch, wire its temporary key, push, then watch `ablo/schema.ts`. `--local` also serves local Postgres over an outbound signed connector. | `--branch `, `--branch-ttl-hours <1-168>`, `--local`, `--source `, `--no-watch`, `--schema`, `--export`, `--url` | | `ablo branch list\|status\|check\|create\|ensure\|credential\|delete` | Manage and diagnose immutable branch planes and expiring credentials. | Run `ablo branch --help`; use `--json` for automation. | | `ablo logs` | Tail the resolved runtime credential's branch activity. Follows by default. | `-n, --tail `, `--since `, `--model`, `--op`, `--json`, `--no-follow` | | `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` | | `ablo migrate` | **Direct Postgres**: provision just the synced models (plus the adapter's `ablo_outbox` / `ablo_idempotency`) in your own `DATABASE_URL`. Leaves your other tables alone. | `--dry-run`, `--output `, `--schema`, `--export` | | `ablo pull` | **Direct Postgres**: generate `defineSchema(...)` from your existing tables (read-only, like `prisma db pull`). | `--out `, `--app-schema `, `--import `, `--force` | | `ablo check` | **Direct Postgres**: verify your _existing_ tables fit the schema (read-only, no schema changes). | `--schema `, `--export `, `--app-schema ` | | `ablo generate` | Emit TypeScript types from the schema. | `--out `, `--schema`, `--export` | | `ablo docs` | Read these pages for the version you installed: offline, no network (see [`ablo docs`](#ablo-docs)). | `--json` | ## `ablo docs` The documentation for the version in your `node_modules`, not the version on the website. ```bash npx ablo docs # every page, with what it covers npx ablo docs coordination # one page, as markdown npx ablo docs --json # the page list, machine-readable ``` These pages ship inside the npm package, so they describe the code beside them and stay reachable with no network — isolated agent environments and CI runners often have none. That matters most when a project is pinned: `claim` took a callback before it returned a disposable handle, and a website always describes the newest release, so an agent on an earlier version reads the new shape and writes a call its own package doesn't have. Pass a slug (`coordination`), a path (`docs/coordination.md`), or a file name (`AGENTS.md`). A miss names the closest page. The same pages are served over HTTP at `/api/docs/` and through the docs MCP server. ## `ablo dev` The branch-first development loop. It discovers your Git/CI branch, ensures the matching Ablo child branch, exchanges the stored `mk_` project credential for an expiring branch-only key, writes that key to gitignored `.env.local`, pushes `ablo/schema.ts`, and re-pushes on every save. ```bash npx ablo dev # discover from Git, push + watch npx ablo dev --branch preview-pr-482 # explicit branch npx ablo dev --no-watch # prepare, push once, exit npx ablo dev --branch-ttl-hours 24 # change temporary-key lifetime npx ablo dev --local # keep Postgres private on localhost ``` `--local` loads `ablo/data-source.ts` (override with `--source `), registers the branch as connector-only, and opens an outbound authenticated WebSocket to Ablo. Commit, load, list, and outbox-event requests run through the same signed Data Source handler as production; no database credential leaves your process and no public tunnel is opened. Because the connector is long-lived, `--local` cannot be combined with `--no-watch`. It does not start your app, run migrations, create a database-provider branch, or copy production rows. Read [Branch-first development](./branch-development.md) for the exact discovery order, CI flow, database boundary, and troubleshooting. ## `ablo logs` Tail commit activity. Scope comes from the persisted key binding: a child-bound key streams only that child, while a root-bound key streams production. You never pass a project or branch. Follows by default; `--no-follow` prints recent and exits. ```bash npx ablo logs # last 50, then stream npx ablo logs -n 100 --model record # backfill 100, one model npx ablo logs --since 15m --json # last 15m as NDJSON, then stream ``` Each line is `time · op · model · id · actor`. `--json` emits one event per line (NDJSON) for piping to `jq` or an agent. ## `ablo pull` Generate `defineSchema(...)` from the tables you already have — the inverse of provisioning, and read-only (like `prisma db pull`). It introspects `DATABASE_URL`, emits a model per adoptable table (one that has `id` + `organization_id`), maps Postgres types back to Zod, and writes `ablo/schema.ts`. ```bash DATABASE_URL=postgres://… npx ablo pull ``` It never touches the database, and won't overwrite an existing schema without `--force`. Introspection is lossy — enum members, JSON shape, relations, and defaults can't be recovered from columns — so treat the output as a starting point: review it, then run `ablo check`. ## `ablo check` `ablo check` is how you adopt a database you already own. Instead of creating or altering tables, it inspects your existing ones and tells you which fit the schema: it introspects `DATABASE_URL`, compares each table to your `defineSchema(...)`, and reports — per model — whether the table is adoptable. It never writes or alters anything. A table is adoptable when it has a primary key `id` and (for org-scoped models) an `organization_id` column — the tenancy marker the engine isolates on. Every other table in your database is ignored. **Why `organization_id`?** It's the one column that makes a table safe to multiplayer-sync. Row-level security scopes every read and write by it (org A can't see org B's rows), and the engine routes realtime deltas by `org:`. A table without a tenancy key has no isolation boundary, so Ablo excludes it **by default** rather than risk exposing it across tenants. If your tenancy column has a different name, keep that table behind a [Data Source endpoint](/data-sources) for now. ```bash DATABASE_URL=postgres://… npx ablo check ``` ```text ✓ records → records (id, organization_id ok) ✗ projects → projects • missing "organization_id" — add it, or move this model behind a Data Source 2 models · 1 ok · 1 error 12 other tables in your database — ignored by Ablo ``` If a table can't carry `organization_id` (or has business logic Ablo shouldn't bypass), keep it behind a [Data Source endpoint](/data-sources) rather than reshaping it. `ablo check` is read-only; it never proposes a migration. ## `migrate` (Direct Postgres) vs `push` (Hosted) Same engine, two setups. If you use the **Direct Postgres connector**, use `ablo migrate` — it provisions the synced models in your own `DATABASE_URL`. If Ablo manages the hosted store, use `ablo push` and `ablo dev` — the server applies the change and version-gates connecting clients. ```bash ablo migrate --dry-run # preview the exact SQL ablo migrate # apply to DATABASE_URL ablo migrate --output schema.sql # write SQL to a file ``` ### One database, two schemas `ablo migrate` does **not** own your whole database. It creates exactly the models in your `defineSchema(...)` — the synced, collaborative tables — plus the adapter's bookkeeping tables (`ablo_outbox`, `ablo_idempotency`). Nothing else. Your auth, billing, and any other non-synced tables stay in **your own ORM schema** (Drizzle's `schema.ts`, Prisma's `schema.prisma`) and are provisioned by **your own migrations** (`drizzle-kit push` / `prisma migrate`). The Ablo schema is not a replacement for `schema.prisma`, and `ablo migrate` won't touch, drop, or adopt the tables it doesn't manage. One database, two schemas, side by side — each owned by its own migration tool. ## Zod → Postgres type mapping The one type map, shared by both paths (there is no second mapping): | Zod | Postgres | | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | | `z.string()` | `TEXT` | | `z.number()` | `DOUBLE PRECISION`: never `INTEGER`; a Zod number may be fractional, and truncating is silent data loss | | `z.boolean()` | `BOOLEAN` | | `z.date()` | `TIMESTAMPTZ` | | `z.enum([...])` | `TEXT` + a `CHECK (col IN (...))` constraint | | `z.object` / `z.array` / `z.record` / `z.union` / `z.custom` | `JSONB` | | `.optional()` / `.nullable()` | nullable column | Each table also gets the platform columns (`id`, `organization_id`, `created_by`, `created_at`, `updated_at`), an `organization_id` index, and row-level security so each org only sees its own rows — the engine sets this per request (via `current_setting('app.current_org_id')`); you don't manage it. `.default(...)` is **not** emitted as a SQL column default — Zod applies the default at write time (`create`), in one place, so a DB default and a schema default can't drift. ## Structured errors A failed migration aborts the whole transaction (nothing partial lands) and reports the same `migration_failed` shape on both paths — naming the statement that broke and the Postgres SQLSTATE, not just "migration failed". `ablo migrate` (local) logs it: ```txt [migrate] migration plan failed { code: 'migration_failed', failedStatement: 'ALTER TABLE "public"."records" RENAME COLUMN a TO b;', failedStatementIndex: 4, pgCode: '42P01', durationMs: 133 } ``` `ablo push` (hosted) returns the canonical error envelope (HTTP 500), which the SDK reconstructs as a typed `AbloServerError`: ```json { "type": "AbloServerError", "code": "migration_failed", "message": "schema migration failed: relation \"...\" does not exist", "doc_url": "https://docs.abloatai.com/errors#migration_failed", "failedStatement": "ALTER TABLE ... RENAME COLUMN a TO b;", "pgCode": "42P01" } ``` The pushed artifact is recorded `failed` and is never activated, so a broken migration can't leave clients gated against tables that don't match. ## Environment | Variable | Purpose | Default | | ------------------------------------- | ------------------------------------------------------------------------ | -------------------------- | | `ABLO_API_KEY` | Authenticate without `ablo login` (CI). Always overrides the stored key. |: | | `ABLO_API_URL` | Control-plane / API host (`push`, `dev`, `status`). | `https://api.abloatai.com` | | `ABLO_AUTH_URL` | Dashboard origin for `ablo login`'s device flow. | `https://www.abloatai.com` | | `ABLO_CONFIG_DIR` / `XDG_CONFIG_HOME` | Where the credential file lives. | `~/.config/ablo` | --- Source: https://www.abloatai.com/api/docs/client-behavior # Client Behavior > Per-write options, claim behavior, and which errors are safe to retry. When several writers touch the same data at once — an agent worker, a Server Action, a person in the browser — the SDK decides whose write lands and how the others find out. This page is the reference for that: per-write options like `wait` and `onStale`, claiming a record so your slow work runs uninterrupted, and which errors are safe to retry. Claims don't lock. If another writer holds the row, `claim` waits for them, re-reads the fresh row, then hands it to you — so two writers serialize instead of clobbering. ## Constructor ```ts import Ablo from '@abloatai/ablo'; import { defineSchema, model, z } from '@abloatai/ablo/schema'; const schema = defineSchema({ weatherReports: model({ location: z.string(), status: z.enum(['pending', 'ready']), }), }); const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, }); ``` Common options: | Option | Purpose | |---|---| | `schema` | Required for typed model clients. | | `apiKey` | Bearer credential for trusted server runtimes. Defaults to `ABLO_API_KEY` when available. | | `baseURL` | Override the hosted sync endpoint for staging or private deployments. An HTTPS origin, optionally with a path prefix; plain HTTP is accepted for localhost. Your key travels here, so a URL carrying its own credentials, a query, or a fragment is refused at construction. | | `persistence` | `memory` by default. Use `indexeddb` for a durable browser cache that survives reloads. | | `durableWrites` | Optional crash recovery for unacknowledged agent/worker writes. Independent of the default memory cache; accepts `{ store, namespace? }`. | | `transport` | `'websocket'` (default) is the live, stateful client: a persistent socket, a local synced pool, and `onChange` subscriptions. `'http'` returns the **stateless** client for server-side actors (agents, workers, serverless): the same `ablo.` read/write/claim surface, but each call is one HTTP round-trip with no socket. Under `'http'` the return type narrows to `AbloHttpClient`, so stateful-only methods (the `local` reads, `onChange`, `join`) are compile errors rather than runtime gaps. | | `fetch` | Custom fetch implementation for tests or non-standard runtimes. | | `defaultHeaders` | Extra headers attached to every HTTP request. | | `defaultQuery` | Extra query parameters attached to every HTTP request. | | `dangerouslyAllowBrowser` | Required before sending an API key from browser code. Prefer a server route instead. | Your database connects out of band — through logical replication (`npx ablo connect`), or the signed [Data Source](./data-sources.md) endpoint as the fallback for databases that can't grant replication — so the client holds only `apiKey`, never a connection string. See [Connect Your Database](./data-sources.md) for the full setup. ## Model Methods Each schema model becomes a typed model: ```ts await ablo.ready(); const report = await ablo.weatherReports.get({ id: 'report_stockholm' }); const local = ablo.weatherReports.local.get('report_stockholm'); await ablo.weatherReports.create({ data: { location: 'Stockholm', status: 'pending' } }); await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' } }); await ablo.weatherReports.delete({ id: 'report_stockholm' }); ``` On the reactive client, each model write changes local state optimistically before the call returns. Its promise always waits for authoritative confirmation, so `await update(...)` is the confirmation barrier. Call `get`/`list` first — they fetch from the server and you `await` them. After that, `local.get`/`local.list`/`local.count` read the already-synced data instantly with no `await`, and stay reactive in render. Use the async pair to load, the sync trio to read. `local.list` accepts the same practical read options the React selector path uses: `where`, `filter`, `orderBy`, `limit`, `offset`, and `state`. The `state` lifecycle filter defaults to `'live'`; pass `'archived'` or `'all'` when you intentionally want non-live rows. ## Multiplayer Behavior Two writers both try to mark `report_stockholm` ready at the same time. To stop the second write from silently overwriting the first, every participant goes through the same model client path. A human Server Action, a browser view, and an agent worker can all use `ablo.weatherReports`: ```ts const report = await ablo.weatherReports.get({ id }); const snap = ablo.snapshot({ weatherReports: id }); await ablo.weatherReports.update({ id, data: patch, readAt: snap.stamp, onStale: 'reject', }); ``` Once the server accepts the write, every other connected client gets the new row automatically — no polling or manual refresh on your side. React clients that use `useAblo((ablo) => ablo.weatherReports.local.get(id))` receive the new row, and selectors such as `useAblo((ablo) => ablo.weatherReports.claim.state({ id }))` receive active claim state. There is no extra multiplayer setup beyond routing shared state through Ablo. Writes flow through Ablo's commit chokepoint and land in your database, so every actor routing through Ablo is coordinated. The one write it can't coordinate is one made directly against your database, around Ablo — the WAL echo still catches it for reads, but it bypasses claims and ordering. ## Per-Write Options ```ts await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' }, readAt: snap.stamp, onStale: 'reject', idempotencyKey: 'report_stockholm:mark-ready:v1', }); ``` | Option | Purpose | |---|---| | `readAt` | State cursor the write was based on. | | `onStale` | Policy when the target changed after `readAt`. Prefer `reject`. | | `idempotencyKey` | Stable key for retry-safe writes. The SDK generates one when omitted. | ## Claimed Behavior If your update involves a slow step — an API call, an LLM round-trip — and someone else might write the same record meanwhile, claiming the record stops you from overwriting their change. Check who holds the record with `claim.state({ id })`, then take it with `claim({ id })`: ```ts const active = ablo.weatherReports.claim.state({ id: 'report_stockholm' }); if (active) { return { status: 'claimed', active }; } const handle = await ablo.weatherReports.claim({ id: 'report_stockholm' }); await ablo.weatherReports.update({ id: handle.data.id, data: { status: 'ready' }, claim: handle, }); await handle.release(); ``` `claim.state({ id })` returns the current holder (or nothing) without ever blocking. When you call `claim({ id })`, the SDK queues other claimers behind you, re-reads the latest row, then hands you the fresh row — so you can't overwrite a change you didn't see. Options on the claim: - default `claim` waits in the fair queue and re-reads before handing you the row; - `{ queue: false }` resolves `null` when another participant already holds the target; two clients with the same participant identity are re-entrant, not contenders; - `{ maxQueueDepth }` rejects if the wait line is already too deep. While waiting, schema clients learn when the claim clears from the live claim stream, so they never poll. ## Errors All SDK errors extend `AbloError` and carry a stable `type`. | Error | Typical cause | |---|---| | `AbloAuthenticationError` | Missing, invalid, or expired credential. | | `AbloPermissionError` | Valid credential, denied operation or scope. | | `AbloRateLimitError` | Rate limit or quota exceeded. Check `retryAfterSeconds`. | | `AbloIdempotencyError` | Same idempotency key reused with a different request. | | `AbloConnectionError` | Network, timeout, abort, or transport failure. | | `AbloValidationError` | Invalid input or unsupported request shape. | | `AbloServerError` | Server-side 5xx. Retry with backoff if the operation is idempotent. | | `AbloStaleContextError` | Write was based on stale `readAt` state. Re-read and retry. | | `AbloClaimedError` | A write conflicted with another participant's active claim, the queue was too deep, or a claim wait timed out. | ```ts import { AbloClaimedError } from '@abloatai/ablo'; try { await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' } }); } catch (error) { if (error instanceof AbloClaimedError) { return { status: 'claimed' }; } throw error; } ``` ## Retries and Idempotency Model writes are retry-safe by default because the SDK attaches an idempotency key. If you provide your own key, keep it stable for retries of the same logical operation and never reuse it for a different payload. Retry transport failures and 5xx with backoff. Do not blindly retry validation, permission, idempotency, or stale-context errors without changing the request. ## Logging Pass a logger when you need SDK logs in your own observability pipeline: ```ts const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, logger, }); ``` The logger receives lifecycle, sync, retry, and rollback events. Avoid logging request bodies that may contain customer data. ## Public Imports Only these imports are public SemVer surface: - `@abloatai/ablo` - `@abloatai/ablo/schema` - `@abloatai/ablo/react` `dataSource(...)` is exported from the root package for customer-owned storage adapters. Everything outside the three import paths is internal to Ablo-owned apps and infrastructure. For adapter authors, `@abloatai/ablo/source/conformance` is the suite that proves a storage adapter behaves correctly. --- Source: https://www.abloatai.com/api/docs/concurrency-convention # Concurrency Convention > What Ablo checks when a guarded write depends on earlier state. Ablo never infers whether a write depends on earlier state. You decide, in two places. The model's `conflict` setting in the schema says what each kind of participant does when it hits a conflict, and it is the policy for that model. A per-write `onStale` states the disposition for one write. Ablo enforces what you declared and nothing else. ## Unguarded writes A plain write has no stale premise: ```ts await ablo.records.update({ id, data: { status: 'done' } }); ``` If no active claim conflicts with it, the write is last-write-wins. That is a choice rather than a fallback: use it for independent assignments where the latest value should win. When a model's writes are never independent, say so once in its `conflict` setting instead of at every call site. ## Guarded writes Pass the exact returned rows when a write is based on values previously read: ```ts const record = await ablo.records.get({ id }); const policy = await ablo.policies.get({ id: policyId }); if (!record || !policy) throw new Error('required input is missing'); await ablo.records.update({ id: record.id, data: { status: 'done' }, reads: [record, policy], }); ``` Ablo privately resolves each exact object to its model, id, and read watermark, then compares those premises with current state when the write is accepted. Clones, fabrications, and rows returned by another client are rejected locally. | Disposition | If the premise is stale | |---|---| | `reject` | Reject the write with `AbloStaleContextError`. | | `notify` | Keep the current row, return a `StaleNotification`, and let the caller reconcile. | | `overwrite` | Apply the new value without enforcing the stale premise. | `notify` is useful when an agent or human can merge the new information. `reject` is useful when the caller should restart from fresh state. Use `overwrite` only when the newer assignment should unconditionally win. ## Functional updates For a pure read-modify-write calculation, use the functional update form: ```ts await ablo.counters.update(counterId, (current) => ({ value: current.value + 1, })); ``` It performs the read, guarded write, and bounded reconciliation loop for you. See [Coordination](./coordination.md#functional-updates). ## Claims A claim protects a target across a longer interval. By default, other participants cannot write the claimed target, while contenders that claim it wait their turn. Reads remain open. A model's explicit conflict policy can choose a different disposition for a participant kind. Claims and stale guards protect different things: - A claim excludes other participants while it is held. - A stale guard proves that the state a write depended on has not changed. - A write made under a claim is still rejected if its own claimed snapshot has become stale. See [Coordination](./coordination.md#claims) for the API. ## Cross-row and batch premises Model writes and lower-level commits can declare rows they read even when the write targets somewhere else. This protects decisions such as “update the record only if the deal I inspected has not changed.” A stale batch premise applies to the whole batch so atomicity is preserved. Use the high-level model methods unless you are building a custom runtime. When you do use batch premises, declare only the rows or groups that materially influenced the decision; overly broad premises create unnecessary contention. ## Notifications A `StaleNotification` identifies the stale premise and provides the current state needed to reconcile. The original write has not been applied. A typical loop is: 1. Inspect the current value in the notification. 2. Recompute the intended change. 3. Submit a new guarded write with a fresh premise. Give this loop a retry budget. Continuous contention should surface to the caller rather than retry forever. ## Boundaries Concurrency control does not replace: - database constraints and transactions for application invariants; - authorization for deciding who may read or write; - idempotency for safely replaying the same request; - claims for exclusivity across slow, side-effecting work. The rule is simple: the model's `conflict` setting is the policy, and each write declares what it read. Plain writes are last-write-wins because declaring nothing is itself a decision, so make it deliberately. --- Source: https://www.abloatai.com/api/docs/context # Context > Assemble the current information for an action and carry its authoritative > Ablo reads into the write that follows. `context()` is a standalone SDK function. It does not run a model, keep a conversation, search documents, or create memory. The application chooses the values; Ablo awaits them and identifies the exact returned rows that can guard a later write. ## Context, model, write This is the complete shape. `loadMemories()` and `parseTaskUpdate()` are application functions; they are not Ablo APIs. ```ts import { context } from '@abloatai/ablo/context'; import { contextMessage } from '@abloatai/ablo/ai-sdk'; import { generateText } from 'ai'; const ctx = await context({ ablo, data: { record: ablo.records.get({ id: recordId }), records: ablo.records.list({ where: { recordId } }), memory: loadMemories(recordId), }, }); if (!ctx.data.record) throw new Error('Record not found'); const result = await generateText({ model, messages: [...history, contextMessage(ctx)], tools, }); await ablo.records.update({ id: ctx.data.record.id, data: parseTaskUpdate(result.text), reads: ctx.reads, }); ``` If an authoritative row moves during the model call, the update rejects with `AbloStaleContextError`. Rebuild the context before trying again. The model is not called or retried by `context()`. ## Choose the protection separately Context assembly and concurrency policy answer different questions. Choose the protection according to the work: | Situation | Use | Why | |---|---|---| | Bring several current values into one model call | `context()` | Awaits the selected values and collects their evidence. | | Reject if any selected Ablo row moves | `reads: ctx.reads` | Checks those premises when the write reaches the server. | | Avoid paying for a model call while another participant owns the row | `claim()` | Waits first, then supplies fresh state. | | Compute a patch from one current row without external work | Functional `update()` | Re-reads and retries the pure calculation. | A stale guard detects a change after the work has happened. When the work is slow or costly and must be exclusive, take a claim before assembling context. See [Coordination](./coordination.md) for the full choice. ## Result The result has four members: | Member | Meaning | |---|---| | `data` | The selected values, with nested promises resolved. | | `reads` | Exact Ablo rows accepted by a write's `reads` option. | | `cursor` | The greatest watermark among those authoritative reads, or `null`. | | `sources` | One provenance summary for each top-level value. | If a row in `ctx.reads` moves before the write, the server rejects the write as stale. A plain value can inform the action, but it does not gain that guarantee. This distinction is visible in `sources`: ```ts ctx.sources; // [ // { key: 'record', kind: 'ablo', guarantee: 'guardable', cursor: 42 }, // { key: 'memory', kind: 'value', guarantee: 'informational', cursor: null }, // ] ``` A top-level value may contain both kinds. It is then marked `mixed` and only its exact Ablo rows appear in `ctx.reads`: ```ts // data: { briefing: { record, memory } } // sources: [ // { key: 'briefing', kind: 'mixed', guarantee: 'partial', cursor: 42 }, // ] ``` `partial` does not weaken the included Ablo rows. It says the surrounding value also contains information Ablo cannot guard. ## External context Provider results pass through without an adapter or provider dependency. The functions below belong to the application; they may call Mem0, Turbopuffer, Reducto, or another system behind their own interfaces. ```ts const ctx = await context({ ablo, data: { record: ablo.records.get({ id: recordId }), memory: loadMemories({ query, userId }), related: findRelatedChunks({ projectId, query }), evidence: extractEvidence({ recordId }), }, }); ``` These values are informational. Search ranking, citation versions, and memory quality remain guarantees of their own systems. They do not become canonical Ablo state unless the application writes them to an Ablo model and reads that row back. One rejected promise rejects the whole `context()` call. Requested information is never omitted silently. An absent row remains absent. It contributes no read evidence, so check required rows before calling a model. `context()` does not turn a missing read into a create-if-absent guard. ## AI SDK The optional formatter produces a user message. It does not turn retrieved content into a system instruction and does not take ownership of the run. ```ts import { contextMessage } from '@abloatai/ablo/ai-sdk'; import { generateText } from 'ai'; await generateText({ model, messages: [ ...history, contextMessage(ctx, { include: ['record', 'documents', 'memory'] }), ], tools, }); ``` Selection, trimming, token budgets, conversation history, and model execution remain application or framework policy. Applications may format `ctx.data` themselves. ## Current limits The first version deliberately has no: - search or memory API; - provider registry or provider-specific adapter; - `since` cursor or incremental `changes` result; - context session, persistence, or sharing lifecycle; - token counting, trimming, summarisation, or model call; - guarantee that a person or model understood the included information. Store `ctx.cursor` in application-owned state if it is useful. Incremental context is not yet derived from it. `context` remains available as a schema model name. The helper lives at `@abloatai/ablo/context`; it does not add `ablo.context()` or reserve a member of the schema-backed client. --- Source: https://www.abloatai.com/api/docs/coordination # Coordination > Choose plain writes, functional updates, stale guards, or claims without losing concurrent work. Ablo gives you several concurrency tools because not every write has the same meaning. Choose the narrowest one that matches the operation. | Situation | Use | Result | |---|---|---| | Set an independent value | `update({ id, data })` | Last-write-wins when no claim applies. | | Compute a value from the current row | `update(id, current => next)` | Re-reads and retries if the row changes concurrently. | | Write only if earlier rows are still current | `reads: [record, policy]` | Rejects when an explicitly named dependency changed. | | Read, call a model, then write | `claim({ id })` | Other participants cannot write the claimed target by default until your claim ends. | **If a model call sits between the read and the write, take a claim.** A stale guard tells you the row moved after you have already paid for the turn. A claim makes the contender wait before it spends anything, and it reads the winner's result rather than reasoning against state that has since moved. The important boundary is explicit: a plain update does not claim a row and does not carry a stale premise. It is intentionally last-write-wins. ## Explicit read dependencies Pass the exact rows that produced a decision on the write: ```ts const record = await ablo.records.get({ id: recordId }); const policy = await ablo.policies.get({ id: policyId }); if (!record || !policy) throw new Error('required input is missing'); const result = await model({ record, policy }); await ablo.records.update({ id: record.id, data: result, reads: [record, policy], }); ``` This means “apply this update only if the rows used to produce it have not changed.” The exact returned objects carry opaque evidence; no watermark is exposed. Same-row and cross-row dependencies use one shape. Incidental reads do nothing, and cloned, fabricated, or cross-client rows fail locally. When one decision needs several Ablo reads plus application-owned memory or retrieval results, [Context](./context.md) assembles those values and returns the exact authoritative rows as `ctx.reads`. An `undefined` result cannot carry evidence. Guarded absence therefore remains a separate low-level design; do not treat a missing read as an automatic create-if-absent condition. ## Functional updates When the next value is a function of the current one, pass an updater rather than fixed data: ```ts const document = await ablo.records.update(recordId, (current) => ({ revision: current.revision + 1, content: revise(current.content), })); ``` The SDK reads the current row, runs the updater, and writes only if that row is still current. If another write wins first, it re-reads and runs the updater again. This prevents the usual lost-update race without holding a claim across your application code. Use this form only for a pure calculation. Because the updater may run more than once, do not send email, charge a card, call a model, or perform another side effect inside it. You can bound or cancel reconciliation: ```ts await ablo.records.update( recordId, (current) => ({ revision: current.revision + 1 }), { retries: 8, signal: request.signal }, ); ``` If contention continues beyond the retry budget, the call rejects with `AbloContentionError` and does not apply a stale calculation. ## Stale guards Use explicit returned rows when application code reads first and writes later, but does not need to reserve the row: ```ts const report = await ablo.reports.get({ id: reportId }); if (!report) throw new Error('report missing'); await ablo.reports.update({ id: report.id, data: { status: 'ready' }, reads: [report], }); ``` The dispositions are: | `onStale` | Behavior | |---|---| | `reject` | Reject the write if its premise is stale. | | `notify` | Leave the row unchanged and return the current value for reconciliation. | | `overwrite` | Apply the write without a stale check. | See [Concurrency Convention](./concurrency-convention.md) for guarded batches and notifications. ### Decide the model's conflict policy Who yields is a design decision about the model, not something to restate on every write. Declare it once, in the schema, and it travels to the server with the rest of the model: ```ts import { coordination, model, z } from '@abloatai/ablo/schema'; const cards = model( { title: z.string() }, { conflict: coordination.humansOverwrite().agentsReject(), }, ); ``` An omitted participant kind uses the engine default, `reject`. A per-write `onStale` states the disposition for that one write. Keep the policy simple, and document any rule that lets a participant overwrite a held claim. ## Claims Use a claim when work must remain exclusive across a slow gap such as an LLM call or another external service: ```ts await using claim = await ablo.reports.claim({ id: reportId, description: 'generating forecast', }); const forecast = await generateForecast(claim.data.location); await ablo.reports.update({ id: claim.data.id, data: { forecast, status: 'ready' }, }); ``` If another participant already holds the target, `claim` waits its turn and then resolves with a fresh row in `claim.data`. Ordinary reads remain open. By default, a write from a participant that does not hold the active claim is rejected; an explicit model conflict policy can choose otherwise. Bind claims with `await using` whenever possible. The claim then releases when the scope exits, including when the external call or write throws. For runtimes without explicit resource management, use `try/finally` and `await claim.release()`. ### One identity per participant Explicit claims coordinate authenticated participants. Two clients using the same credential represent the same participant and do not exclude one another. Mint a distinct scoped session for each independently coordinated agent: ```ts const { token } = await server.sessions.create({ agent: { id: `forecast-agent-${workerId}` }, }); const agent = Ablo({ schema, apiKey: token }); ``` Functional updates do not require distinct participant identities because they protect the row version rather than a participant-held claim. ### Skip instead of wait For deduplicated jobs, skip work when another participant already owns it: ```ts const claim = await ablo.records.claim({ id: recordId, contention: { mode: 'skip' }, }); if (!claim) return; try { await processTask(claim.data); } finally { await claim.release(); } ``` To wait with limits, keep the policy together: ```ts const claim = await ablo.records.claim({ id: recordId, contention: { mode: 'wait', maxDepth: 3, timeoutMs: 30_000, signal: request.signal, }, }); ``` ### Claim part of a row Narrow a claim when independent fields may be edited concurrently: ```ts await using claim = await ablo.records.claim({ id: recordId, fields: (record) => record.status, }); ``` Claims on disjoint fields can coexist. A whole-row claim conflicts with every field claim on that row. The target options are: | Option | Purpose | |---|---| | `options.field` | Claim one field by its wire-level name. Prefer the typed selector in application code. | | `options.fields` | Claim one or more schema fields with a typed selector. | | `options.meta` | Attach application-defined metadata observers may display. | ## Observe coordination Read current claim state without blocking: ```ts const holder = ablo.records.claim.state({ id: recordId }); const queue = ablo.records.claim.queue({ id: recordId }); ``` Use this state for presence and progress UI. Do not use an observed `null` as a substitute for claiming: another participant can acquire the row immediately after your read. The main methods are: | Method | Purpose | |---|---| | `claim({ id })` | Acquire the target, waiting by default. | | `claim.state({ id })` | Read the current holder without blocking. | | `claim.queue({ id })` | Read the current wait order. | | `claim.release({ id })` | Release early when you do not hold a handle. | | `join({ scope })` | Observe presence for a broader scope. | ## Choosing correctly - Prefer a plain update for values that do not depend on an earlier read. - Prefer a functional update for a quick, pure read-modify-write calculation. - Prefer a stale guard when your caller should decide how to reconcile. - Prefer a claim when you must hold exclusivity across slow or side-effecting work. - Prefer idempotency for safe retries; it solves a different problem from concurrency. For exact error codes and recovery guidance, see [Errors](./errors.md). For what a confirmed write promises, see [Guarantees](./guarantees.md). --- Source: https://www.abloatai.com/api/docs/customer-organizations # Serving Many Customers > One account, one schema, and a session scoped to the customer whose data it may read. Serving many customers from one backend has two shapes, and the first question is whether isolating them is a security boundary or a routing convenience. **One Ablo organization per customer** is the hard boundary. Every row carries the organization, and the engine compares it on every read and every write, below your code. Choose it when one customer reading another's rows would be an incident. **One organization, customers as rows told apart by sync groups** is delivery and read routing. It is declarative, it depends on every model being covered, and it is not enforced on every path. Choose it when cross-customer reads are tolerable or intentional, not when they are a breach. The rest of this page is the second shape. Read *Where the boundary is enforced* before you rely on it. ```ts // 1. src/ablo/schema.ts — your customer table is a scope root. import { defineSchema, identityRole, relation, model, z } from '@abloatai/ablo/schema'; export const schema = defineSchema( { // Its rows form the group `customer:`; the kind comes from `groups.root`. customers: model( { name: z.string() }, { groups: { root: 'customer' } }, ), // A child inherits its customer's group through the `parent` edge. decks: model( { customerId: z.string(), title: z.string() }, { relations: { customer: relation.belongsTo('customers', 'customerId', { parent: true }) } }, ), }, { identityRoles: [ identityRole({ kind: 'org', source: 'organizationId' }), identityRole({ kind: 'user', source: 'userId' }), ], }, ); ``` ```ts // 2. app/api/ablo-session/route.ts — mint for one customer, on your backend. import { syncGroup } from '@abloatai/ablo/schema'; import { credentialEndpointSuccessSchema } from '@abloatai/ablo/auth'; import { ablo } from '@/ablo/server'; export async function POST() { const member = await requireSignedInMember(); const session = await ablo.sessions.create({ user: { id: member.userId }, can: { customers: ['read'], decks: ['read', 'create', 'update'] }, syncGroups: [syncGroup('customer', member.customerId)], }); return Response.json( credentialEndpointSuccessSchema.parse({ token: session.token, expiresAt: session.expiresAt, credentialKind: 'ephemeral', }), { headers: { 'Cache-Control': 'no-store' } }, ); } ``` That is the whole integration. The rest of this page is why each line is where it is. ## What each layer is Four things carry a name in this arrangement, and mixing two of them up is the one mistake worth spending a page to prevent. | Layer | What it is | Where it lives | |---|---|---| | Your account | The organization you signed up with. Colleagues join it with their own logins and share one bill. | Ablo | | Your application | A project. One per app you run, bound to one schema in your database. | Ablo | | Your customer | A row in your own table, with your own id on it. | Your database | | One person's session | An `ek_` your backend mints, cut to one customer's group. | Minted per sign-in | Your customers sit in the third row. They are not accounts, because an account is something you invite colleagues into. They are not projects, because a project binds to a Postgres schema and you run one application, not one per customer. Your `sk_` already carries your account, so a session never names it. What the session adds is which customer the person in front of it may read. ## Where the boundary is enforced Two mechanisms do different jobs, and the difference is the whole of this page. **Your account is the tenant boundary.** Every row Ablo stores carries your organization, project, and branch, and all three are compared on every read and every write, from the credential rather than the request. A client cannot reach past them by asking. This is the boundary that holds unconditionally. **Sync groups are a cut inside your account, and they are not applied everywhere.** They decide which changes are delivered and which rows a log-served read returns. That is routing. It is not a universal authorization boundary, and the gaps are specific: | Path | Group cut applied | |---|---| | Live delivery and fan-out | Yes | | HTTP read on a log-served plane (a connected database) | Yes | | HTTP read on a hosted or direct-query plane | **No.** Scoped by organization | | Writes | **No.** The groups are recorded on the change, never checked against the row | | Claim listings and presence | Yes | So a session cut to one customer, on a hosted plane, can read another customer's rows over HTTP; and on any plane it can write to them. What stops it today is the organization, which both customers share under this shape. If isolating your customers is a security requirement, give each one its own Ablo organization. The stronger row-and-subject authorization that would make this shape safe on every path is not in the engine yet. ## Naming a group Build a group with the `syncGroup(kind, id)` helper rather than a string. The kind is the one you declared in `groups.root`, and the id is your own identifier for the customer. ```ts syncGroups: [syncGroup('customer', member.customerId)] ``` Resolve `member.customerId` from the membership you just authenticated on the server. A signed-in person can put any value in a request body, and the session you mint is what decides what they can read. ## When a customer should be its own organization Whenever their isolation has to hold. Give each customer its own Ablo organization when one of them reading or writing another's rows would be an incident rather than a bug, when you cannot audit group coverage across every model, or when a customer is a separate paying business that signs in to Ablo itself and invites its own developers. Your backend then names the customer's organization on the mint, which takes a secret key carrying `organization:act-as`. The customer never sees Ablo; the scope exists because the session leaves the organization the key belongs to. ## Onboarding a customer Insert the row. There is nothing to register with Ablo, because the group is derived from the row's id, and the first session minted against it is delivered its data. Add a project only when you add an application. `npx ablo projects create` takes a management credential from `ablo login`, and one project holds one schema. ## Troubleshooting ### A session reads nothing Check the groups the session was minted with against the kind in `groups.root`. A group whose kind is not declared matches nothing, which reads as an empty database rather than an error. ### A session reads another customer's rows Check that the model declares a `parent` edge up to the scope root. A model with no group of its own and no parent belongs to no group, so a group cut does not narrow it. ### The mint is refused Naming `organizationId` reaches into a different account and takes `organization:act-as`. A platform serving its own customers names groups instead, and its key needs no scope at all. ## See it yourself ``` npx ablo whoami --json ``` The `syncGroups` it reports are the cut the engine will apply. If a customer's group is missing there, no read will show its rows. ## Related guides - [Identity & Sync Groups](/identity) — how groups are declared and resolved. - [Sessions](/sessions) — session lifetime, refresh, and revocation. - [API Keys](/api-keys) — credential classes and scopes. - [Projects](/projects) — one project per application. --- Source: https://www.abloatai.com/api/docs/data-sources # Connect Your Database > Keep the rows in your own Postgres while Ablo coordinates and confirms every write. ## Localhost development Ablo Cloud cannot dial `localhost`: from a cloud server, that name means the cloud server itself, not your Mac or development container. A development child branch can still use Postgres that listens only on your machine by running a signed Data Source over Ablo's outbound reverse channel: ```bash npx ablo migrate # once: models + ablo_idempotency + ablo_outbox npx ablo dev --local ``` The command loads `ablo/data-source.ts`, registers the current child branch as connector-only, and dials out to Ablo over an authenticated WebSocket. Postgres continues listening only on your machine; `DATABASE_URL` never leaves the process. This is protocol-scoped, not a general-purpose tunnel: only signed Data Source load, list, commit, and event requests traverse it. Use `--source ` when the handler lives elsewhere. Keep `ablo dev --local` running alongside the application. It pushes schema changes and owns the database connector; stopping it deliberately makes the branch's database unavailable instead of silently writing somewhere else. ### Is this full Ablo? Yes for the Ablo application path: model reads and lists, coordinated writes, claims, subscriptions, idempotency, confirmations, and transactional outbox confirmation all work against localhost Postgres. The browser, server code, and agents still connect to Ablo Cloud; only database operations cross the narrow signed connector to your machine. It is not logical replication. Visibility depends on how a row is written: | Write origin | Visible to Ablo in localhost mode? | Why | |---|---:|---| | `ablo..create/update/delete` | Yes | Ablo coordinates the write, the local adapter commits it with idempotency + outbox, and the outbox event confirms it. | | Code using the signed Data Source adapter | Yes | The adapter records the row and authoritative event in one transaction. | | A supported source push/outbox integration | Yes | It explicitly publishes the authoritative event to Ablo. | | Raw SQL, `psql`, or an unrelated ORM write | No, not automatically | There is no WAL reader in signed-endpoint mode, and bypassing the adapter does not append `ablo_outbox`. | If Ablo must observe every arbitrary SQL/ORM write, use the direct logical-WAL path with a network-reachable Postgres endpoint, PrivateLink/peering/VPN, or a database-capable secure tunnel. Do not expose Postgres without TLS, authentication, and network restrictions. ### For localhost-first open-source projects Do not require contributors to buy hosted Postgres or expose port 5432 merely to run the project. Treat the connector as the default contributor topology: ```json { "scripts": { "ablo:setup": "ablo migrate", "ablo:dev": "ablo dev --local" } } ``` Keep `DATABASE_URL=postgres://…@localhost:5432/…` in `.env.example`, commit the generated `ablo/data-source.ts` handler, and document two long-running processes: the application and `npm run ablo:dev`. Contributors provide their own Ablo branch credential through `ablo login`; the repository never contains it. For collaborative models, route mutations through Ablo or the signed Data Source adapter. If the existing project intentionally writes those same tables through raw SQL or an unrelated ORM path, choose one explicitly: - add the supported transactional outbox/source-push integration for those writes; - state that only Ablo-mediated changes participate in live coordination locally; or - make WAL integration tests opt-in through a secure direct tunnel or hosted test database. That keeps the zero-cost localhost quickstart honest without weakening Ablo's coordination boundary or pretending endpoint mode can see WAL. ### Local connector errors Every stable code links to the generated [error reference](https://docs.abloatai.com/errors): | Code | Meaning and fix | |---|---| | `database_loopback_requires_connector` | A direct connection was configured with localhost. For the normal OSS/dev path, run `ablo migrate` and `ablo dev --local`; use a direct network route only when arbitrary SQL writes need WAL observation. | | `source_connector_not_attached` | The branch is connector-only but no process is attached. Start or restart `ablo dev --local`. | | `source_connector_unauthenticated` | The temporary branch key is missing, expired, or rejected. Rerun `ablo dev --local` to mint a fresh key. | | `source_connector_requires_secret_key` | The connector received the wrong key kind. Let `ablo dev` supply its branch-bound `sk_` key. | | `source_connector_no_source_registered` | No endpoint source exists for this branch. Upgrade/rerun the CLI so registration happens before socket attachment. | | `source_connector_localhost_required` | Connector-only registration used a non-local descriptor. Use `ablo dev --local`; deployed handlers use ordinary HTTPS endpoint registration. | | `source_connector_timeout` | The handler or local Postgres exceeded the request deadline. Inspect the local process and database. | | `source_connector_handler_error` | `ablo/data-source.ts` or its adapter threw. The local terminal contains the underlying error. | | `source_connector_protocol_error` | CLI/SDK and service connector protocols disagree. Upgrade the CLI and SDK together. | | `source_connector_production_not_enabled` | A root/production key attempted the development connector. Use a supported production route or explicitly enable production reverse-channel support. | Disconnects, service restarts, and connector replacement are retryable. Keep the same idempotency key: Ablo never falls back from this branch to hosted storage or another database. You write through Ablo, and Ablo writes to your Postgres. A call to `ablo..create / update / delete` enters Ablo's commit chokepoint — where claims, ordering, and idempotency are enforced — and Ablo applies the change to your database through a scoped role. Your rows live in your database, which stays the system of record. Ablo reads your write-ahead log (WAL) to confirm each write landed and to keep every connected human and agent current. Ablo writes your **rows**; it never touches your **schema**. It runs no DDL and no migrations — your migration tool stays in charge of the shape of your database. Ablo only writes rows into tables you already have, through a role scoped to exactly that. > **Just trying Ablo?** You don't need a database to start. Pass an `apiKey` only, > and Ablo keeps your rows in its own log so you can build the whole app today. > `ablo dev` gives each Git branch its own isolated plane. > Keep it hosted with no database, or point that branch at a separate/local > Postgres. Connect your production root (below) when you're ready for its > database to be the system of record. Connecting sets up two capabilities on your Postgres: **logical replication**, so Ablo can read and confirm, and a **scoped DML role**, so Ablo can write. `ablo connect` prints the exact SQL. `ablo connect apply` runs it for you. Connect commands do not silently load a dotenv file for a mutation. Either export `ABLO_API_KEY` and `DATABASE_URL`, pass `--url`, or explicitly select the file: ```bash npx ablo connect apply --env-file .env.local --yes ``` The explicit flag makes the credential choice visible and loads both the branch-bound key and database URL. Shell environment variables take precedence. ### One database, several projects Provider database URLs and Postgres schemas solve different isolation jobs: ```text database URL = production, staging, or preview environment schema = application/project inside that database ABLO_API_KEY = exact Ablo project branch to bind ``` It is safe to keep several apps in one production database when each app has its own schema: ```bash ABLO_API_KEY="$MAIL_KEY" DATABASE_URL="$PRODUCTION_URL" \ npx ablo connect apply --schema mail --yes ABLO_API_KEY="$ENTRIES_KEY" DATABASE_URL="$PRODUCTION_URL" \ npx ablo connect apply --schema entries --yes ``` For a Neon or Supabase preview branch, use that branch's direct URL and keep the schema name stable. Ablo binds one plane to `(database, schema)`: the same database may add `billing`, but a second project cannot also claim `mail`. Cross-organization conflicts reveal only that the binding is occupied. Push the Ablo schema before connecting, or pass `--tables`. The publication is an explicit list of schema-qualified mapped tables; Ablo never uses a database-wide `FOR ALL TABLES` publication for this multi-project path. If scoped roles already exist but their passwords are unavailable, do not drop them or run `DROP OWNED`. Rotate them in place and re-register the fresh credentials: ```bash npx ablo connect rotate --env-file .env.local --yes ``` This is the supported recovery after moving a database between branches. For a one-time release from an older branch, select the named recovery key directly—no shell remapping: ```bash npx ablo connect deregister --key-env OLD_ABLO_KEY --yes npx ablo connect rotate --env-file .env.local --yes ``` `--key-env` reads that exact variable from the process, `.env.local`, or `.env` without printing the secret. Retire the old variable after the move. ## Connect in one command ```bash npx ablo connect apply --url postgres://admin:...@host:5432/db --schema mail ``` Pass an admin connection string with `--url` and select the application namespace with `--schema` (default `public`). It creates a per-binding publication, two per-binding scoped roles, and the grants, turns on logical decoding where it can, registers both scoped roles with Ablo, and proves the setup by reconnecting and reading back. The admin credential is used on this machine only and never persisted — nothing is written to your `.env`, which keeps holding only `ABLO_API_KEY`. Pass `--show-sql` to see every statement first, or drop `--apply` to print the SQL and run it yourself. Rotate the scoped passwords any time with `ablo connect rotate`. The rest of this page is what that command sets up, step by step, for when you want to run it by hand or review exactly what changes. ### Existing rows load automatically When Ablo creates the replication slot, it takes a consistent initial snapshot of every mapped table in the publication before following new changes. Rows that predate `ablo connect` therefore become available to `get`, `list`, and reactive `local.*` reads without an application backfill. Run `ablo connect check` before removing an existing HTTP/database read fallback. It reports the initial load as `loading` until the snapshot is complete. Do not write a script that updates every row to make it visible: an Ablo update requires the row to be visible already, and touching application rows is neither necessary nor a safe bootstrap mechanism. If a connection was snapshotted with an older replication role whose row-level security hid historical rows, repair that role and request the load again without deregistering or rotating credentials: ```bash npx ablo connect rotate # reasserts BYPASSRLS and safely re-registers both roles npx ablo connect resnapshot # recreates only the slot; the load is asynchronous npx ablo connect check # repeat until the existing-row load is complete ``` Use the same `resnapshot` step after adding an existing populated table to the publication. Following its future WAL changes is not enough to load rows written before publication membership; the snapshot coverage guard therefore refuses to record completion when even one mapped table is absent. Relation matching is schema-qualified using the DataSource's configured `schema` (default `public`): an identically named table in another Postgres schema neither counts as coverage nor enters the snapshot or WAL stream for your model. ## The setup, step by step ### 1. Enable logical decoding Turn on logical WAL so Ablo can decode row changes and confirm writes: ```sql ALTER SYSTEM SET wal_level = 'logical'; ``` `wal_level` is **not reloadable** — you must **restart Postgres** for it to take effect. On Amazon RDS / Aurora you can't `ALTER SYSTEM`; set `rds.logical_replication = 1` in the instance's parameter group instead, then reboot. (`ablo connect apply` attempts this for you and, where a managed provider refuses, hands you the one remaining step.) ### 2. Run `ablo connect` for the publication and roles ```bash npx ablo connect ``` `ablo connect` prints the exact, copy-pasteable setup SQL for **your** Postgres. Run it against your database as a superuser or the DB owner. It creates: - **A per-binding publication** naming only the schema-qualified mapped tables Ablo reads and confirms against. Its suffix is derived from the authenticated Ablo plane and is stable across re-runs: ```sql CREATE PUBLICATION "ablo_publication_" FOR TABLE "mail"."messages", "mail"."threads"; ``` Override the pushed model set with `--tables a,b,c`. - **A replication role:** it streams the WAL and `SELECT`s, nothing more. This is the role Ablo reads and confirms through. You choose the password; it never passes through Ablo's CLI or servers: ```sql CREATE ROLE "ablo_replicator_" WITH NOSUPERUSER BYPASSRLS NOCREATEDB NOCREATEROLE REPLICATION NOINHERIT LOGIN PASSWORD ''; GRANT SELECT ON TABLE "mail"."messages", "mail"."threads" TO "ablo_replicator_"; ``` `BYPASSRLS` is required because the initial load is an ordinary `SELECT`, while logical replication already exposes every row in the publication independently of row-level-security policies. Keep this role's `SELECT` grants scoped to the published tables; it has no write or DDL privileges. On Amazon RDS the `REPLICATION` attribute is granted, not set directly: `GRANT rds_replication TO "ablo_replicator";`. - **A scoped writer role:** the role Ablo writes your rows through. It gets row DML (`SELECT, INSERT, UPDATE, DELETE`) and the sync ledger, and nothing else: no `REPLICATION`, no schema `CREATE`, `NOSUPERUSER NOBYPASSRLS`, row security on. It can change rows in your tables; it cannot change your database: ```sql CREATE ROLE "ablo_writer_" WITH LOGIN PASSWORD '' NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE NOREPLICATION NOINHERIT; GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE "mail"."messages", "mail"."threads" TO "ablo_writer_"; ``` Rename either role with `--role ` / `--write-role `. The schema-local `ablo_idempotency` ledger lives beside that app's tables. The **replication slot** (`ablo_slot_`) is created and owned by Ablo's runtime when it first subscribes — you don't pre-create it. Registration checks `max_replication_slots` first and explains how to free or add capacity. `ablo connect --manual` retains the legacy canonical object names for compatibility and is therefore single-binding within a physical database. Use `connect apply --schema …` when several projects share that database. ### 3. Register the database with Ablo `ablo connect apply` already did this. If you ran the SQL by hand instead, hand Ablo both scoped connection strings once — the replication role it reads and confirms through, and the writer role it lands your rows through. Set them just long enough to register: ```bash export ABLO_REPLICATION_DATABASE_URL=... # the replication role export ABLO_WRITE_DATABASE_URL=... # the writer role npx ablo connect register ``` `--register` posts them to Ablo, which holds them encrypted and uses them to read and write your database. Ablo holds them from here, so you can drop both from your environment — your app keeps only `ABLO_API_KEY`. The role passwords are generated for you and never printed; rotate them any time with `ablo connect rotate`. After this, Ablo does all the connecting. ### 4. Verify readiness with `ablo connect check` ```bash npx ablo connect check ``` `--check` needs only `ABLO_API_KEY`. It asks Ablo to check the database it now holds, from the same infrastructure replication runs on, and prints a green checklist or the precise per-item fix: - `wal_level` is `logical` - the `ablo_publication` publication exists - the replication role has the `REPLICATION` attribute - every published table has a usable `REPLICA IDENTITY` (a primary key, or `REPLICA IDENTITY FULL`) so `UPDATE`/`DELETE` can replicate - the writer role is DML-ready — scoped, non-superuser, with the idempotency ledger in place - the initial snapshot is complete, so rows that existed before connecting are available to Ablo reads Because Ablo checks from its own network, a database your own machine can't reach — IPv6-only, IP-allowlisted, behind a VPN — still verifies. Re-run it until every item is green. Your **app** holds only the API key, never a connection string: ```bash # .env, server runtime only, never the browser ABLO_API_KEY=sk_... ``` ```ts import Ablo from '@abloatai/ablo'; import { schema } from './ablo/schema'; export const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, }); ``` The key names its own project and branch, so there is nothing else to configure. If you want a process to refuse a key you did not expect, pin `projectId` or `branchId` (they default to `ABLO_PROJECT_ID` and `ABLO_BRANCH_ID`). Both are assertions, never routing inputs: during `ready()` Ablo asks the server what the key actually targets and refuses to start when a coordinate differs. That is worth setting where one deployment can be handed keys for more than one environment, and worth leaving out everywhere else. The Ablo schema describes **only your synced, collaborative models** — the rows Ablo coordinates and fans out in realtime. It is _not_ your whole-database schema and does _not_ replace your `schema.prisma` (or Drizzle schema). Your auth, billing, and other tables stay in your own ORM schema, owned by your own migrations. `ablo check` reflects this — it reports tables you didn't declare as "ignored / owned by you," which is exactly right. ### 5. Write through `ablo.` Every change goes through Ablo. The write enters the commit chokepoint, Ablo applies it to your Postgres through the writer role, and the WAL echo confirms it landed: ```ts // Enters the chokepoint (claims, ordering, idempotency), lands in your Postgres. await ablo.weatherReports.update({ id: 'report_stockholm', data: { high: 21 } }); // Block until your database has it and the WAL echo confirms. await ablo.weatherReports.update({ id: 'report_stockholm', data: { high: 21 } }); // Reads are live off the same stream. const report = ablo.weatherReports.local.get('report_stockholm'); ``` A commit is accepted the moment Ablo takes it (`queued`); it becomes `confirmed` once the row appears on your WAL. See [Guarantees](./guarantees.md) for what each state means and when to wait. ## What Ablo touches in your database: the honest footprint This is the complete list. Nothing else. | Object | What it is | Owned by | | ---------------------- | -------------------------------------------------------------------------------- | ------------------------------------------- | | `ablo_publication` | A publication naming the tables Ablo reads and confirms against. | You create it (step 2). | | `ablo_replicator` role | A `REPLICATION` + `SELECT` role Ablo reads and confirms through. | You create it (step 2). | | `ablo_writer` role | A scoped DML role Ablo writes your rows through: row DML + ledger, nothing more. | You create it (step 2). | | Replication slot | A logical slot Ablo subscribes through to track its WAL position. | Ablo's runtime creates it on first connect. | | `wal_level = logical` | A server setting that **requires a restart**. | You set it (step 1). | Operational reality you should know up front: - **`wal_level = logical` needs a restart.** It is a one-time, server-wide change and is not reloadable. - **A replication slot retains WAL.** While Ablo is connected, the slot holds the WAL it hasn't yet acknowledged. If Ablo is disconnected for a long time, that WAL accumulates and consumes disk. **Ablo monitors slot lag and WAL retention** and surfaces it, so disk pressure never surprises you; an abandoned slot is dropped rather than left to grow unbounded. - **The writer role changes rows, not your database.** It carries row DML plus the sync ledger and nothing more — no `REPLICATION`, no DDL, no object ownership, and it runs with row security on and `NOBYPASSRLS`. It is a real, tightly-scoped privilege — describe it that way in a security review. Ablo runs **no DDL** and **owns no schema**: your migration tool stays in charge of the shape of your database, and Ablo writes only rows, only into tables you already have. ## What Ablo stores on its side Your schema _definition_ (model names, fields, types — pushed with `ablo push`), your hashed API keys, a safe projection of the connection registration (host, database, schema — the connection string itself is sealed and never echoed back), the replication slot position, and the ordered transaction log that drives sync and coordination. Your rows live in your database. > **Postgres replication status: Preview.** Registration, readiness checks, and > the server replication fleet are implemented and boot-wired. Preview describes > product rollout and support, not an inactive code path. Maintainers: see > [internal/postgres-replication.md](./internal/postgres-replication.md) for the > architecture and operational invariants. ## When your database can't grant replication Some managed databases won't grant a `REPLICATION` role. For those, Ablo connects through a **signed Data Source endpoint** instead: you expose one signed HTTP route built from your ORM (`prismaDataSource` / `drizzleDataSource`, with `ablo_outbox` / `ablo_idempotency` bookkeeping), and Ablo writes and confirms through it — same `ablo.` surface, same commit chokepoint, same `queued` → `confirmed` lifecycle. It needs no replication setup, which is exactly why it's the fallback: reach for it only when logical replication isn't available, and prefer `ablo connect` everywhere else. The current Prisma, Drizzle, and Kysely adapters are PostgreSQL bindings. Their profiles record three independent facts: the database is PostgreSQL, the binding is Prisma/Drizzle/Kysely, and observation is either the transactional outbox or PostgreSQL WAL. An ORM name does not imply that the same adapter supports every database that ORM can connect to. The outbox automatically observes writes made through Ablo. A write made directly by other application code is visible only if that code writes the same outbox record in its transaction. Native WAL observation sees both Ablo and external writes. ## Next steps - [Quickstart](./quickstart.md) — connect and write through `ablo.`. - [Schema Contract](./schema-contract.md) — what the schema drives across SDK, React, and agents. - [Guarantees](./guarantees.md) — what confirmed writes and stale checks mean. - [Integration Guide](./integration-guide.md) — the full app, React, multiplayer, and agent path. --- Source: https://www.abloatai.com/api/docs/debugging # Debugging & Logs > Watch claims, queueing, and grants as they happen while you build. By default the SDK is quiet — it logs only warnings and errors. When you're building a multi-agent flow and want to *see* the coordination happen (who claimed what, who's waiting in line, who got preempted), turn on Ablo's diagnostic logging. Every line is prefixed `[Ablo]` so it's obvious which output is ours in a console full of other tools. ## Turn it on ```ts import Ablo from '@abloatai/ablo'; import { schema } from './ablo/schema'; const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, debug: true }); ``` ## CLI environment and target Read-only diagnostics (`status`, `whoami`, `logs`, and `connect locate/check`) may inspect the application-facing chain: exported `ABLO_API_KEY`, `.env.local`, `.env`, then the stored credential. An exported value wins over project files. Mutations (`push` and `connect apply/rotate/register/deregister`) are intentionally stricter: they read the process environment, an explicit `--env-file`, or a stored compatibility credential. An ambient file cannot silently choose the branch a mutation acts on. Use the two diagnostics according to the question: ```bash npx ablo whoami # strict: which project + branch does this credential target? npx ablo status # broad: target, database, schema, drift, and write blockers ``` For an old, CI, or recovery key stored under another variable name: ```bash npx ablo whoami --key-env ABLO_API_KEY_LIVE ``` That explicit lookup checks the process, `.env.local`, and `.env` for the named variable, keeps the value out of argv, and either returns a server-confirmed identity or fails non-zero. Do not infer that a key is invalid from an older CLI's generic identity error; rerun with the current CLI. `debug: true` is the simple switch. For finer control use `logLevel`, or set it without touching code via the `ABLO_LOG_LEVEL` environment variable. ```ts Ablo({ schema, apiKey }) // quiet — warnings + errors only (default) Ablo({ schema, apiKey, debug: true }) // everything (coordination + lifecycle) Ablo({ schema, apiKey, logLevel: 'info' }) // coordination + connection, no per-model noise ``` ```bash ABLO_LOG_LEVEL=debug npm run dev # same, from the environment ``` ### Levels | Level | What it shows | |---|---| | `silent` | nothing | | `error` | failures only | | `warn` | **default**: warnings + errors | | `info` | the above + the **coordination trace** (claims, grants, queueing) + connection state | | `debug` | the above + internal lifecycle (per-model registration, store hydration): the full firehose | Precedence: an explicit `logLevel` wins, then `debug: true` (⇒ `debug`), then `ABLO_LOG_LEVEL`, then the `warn` default. `debug: false` (or omitting it) just means "don't raise the level." > For watching coordination, **`logLevel: 'info'` is the sweet spot** — you get the claim trace without the per-model registration chatter that `debug` adds. ## What you'll see: the coordination trace These lines (all at `info`) let you watch the handover you built: ``` [Ablo] claim: requesting records:doc_42 for "editing" (will queue if contended) [Ablo] claim: queued for records:doc_42 — position 2 of 3, waiting [Ablo] claim: granted 7f3c… — your turn (waited in queue) [Ablo] claim: rejected records:doc_42 — held by agent_writer [Ablo] claim: lost records:doc_42 (preempted or expired) [Ablo] claim: released records:doc_42 ``` Read it as the lifecycle of one claim: - **`requesting`:** your code (or an agent) called `ablo..claim(...)`. `(will queue if contended)` appears when you passed `{ queue: true }`. - **`queued … position N of M`:** the row was held, so you're waiting in the FIFO line. This is the "an agent is waiting behind a claim" moment; it re-logs only when your position changes, so you can watch it advance. - **`granted … your turn`:** you reached the head of the line; the lease is now yours and the row may have changed while you waited. - **`rejected … held by `:** your claim was refused because someone else holds it (and the model's policy didn't let you in). - **`lost`:** you held the lease and it was taken (preempted by a higher-priority writer, or it expired). - **`released`:** you (or `await using`'s scope exit) gave the lease back. ## Where the logs run The coordination trace and the proactive credential refresh run **in the browser** (and any client that holds a live socket) — that's where the live coordination activity is. Server-side code that mints credentials or does one-shot reads won't emit the trace; it has no live session to narrate. ## Bring your own logger Pass a `logger` to route Ablo's output into your own logging stack (Pino, Sentry breadcrumbs, etc.). A custom logger bypasses `debug`/`logLevel` entirely — you decide what to do with each level. ```ts Ablo({ schema, apiKey, logger: { debug: (...a) => {}, info: (...a) => myLogger.info({ ablo: a }), warn: (...a) => myLogger.warn({ ablo: a }), error: (...a) => myLogger.error({ ablo: a }), }, }); ``` ## Read the coordination in code: the activity log The console trace above is for *you*, at a terminal. To put the same activity **inside your app** — an activity feed, a "who's editing" badge, a Sentry breadcrumb trail — read it programmatically. Same events, three layers; pick by audience: | Layer | You get | Best for | |---|---|---| | `logger` (above) | `[Ablo]` text lines | watching a terminal | | `observability` | typed `ClaimEvent` / `ConflictEvent` objects | dashboards, alerting (Sentry / Datadog / OTel) | | `ClaimLog` | an ordered, **reactive** list of both | rendering an activity feed on a page | ### The events Every claim state change is a `ClaimEvent`; every notify-instead-of-abort stale write (a write that succeeded but whose premise had moved) is a `ConflictEvent`: ```ts interface ClaimEvent { phase: 'acquired' | 'queued' | 'granted' | 'lost' | 'rejected' | 'expired'; model?: string; id?: string; field?: string; // the claimed row actor?: string; participantKind?: 'user' | 'agent' | 'system'; position?: number; // FIFO position, when queued reason?: string; // why, on rejected claimId?: string; } interface ConflictEvent { clientTxId: string; rows: { model: string; id: string; fields: string[]; writtenBy?: 'user' | 'agent' | 'system' }[]; } ``` `phase` is past-tense — the state the claim just entered — and maps one-to-one to what arrives on the wire. ### Collect them: `ClaimLog` `ClaimLog` records both into an ordered list. Hand it to `observability`, then read it back: ```ts import Ablo, { ClaimLog } from '@abloatai/ablo'; const log = new ClaimLog(); const ablo = Ablo({ schema, apiKey, observability: log }); // …run the agents… console.log(`${log}`); // a printable, ⚠-marked timeline log.entries; // ClaimLogEntry[] — every event, in order, with a `.line` log.collisions(); // just the rejected/lost claims + stale writes ``` It's also the simplest way to **assert** coordination in a test — no log scraping: ```ts expect(log.collisions()).toHaveLength(0); // no one stepped on anyone ``` ### Show it on a page: reactive `ClaimLog.onChange` fires on every event and returns an unsubscribe — the exact shape `useSyncExternalStore` wants, so a live feed is a few lines: ```tsx import { useSyncExternalStore } from 'react'; import { ClaimLog } from '@abloatai/ablo'; function ActivityFeed({ log }: { log: ClaimLog }) { const entries = useSyncExternalStore(log.onChange, () => log.entries); return (
    {entries.map((e) => (
  • {e.line}
  • ))}
); } ``` > `ClaimLog` lives in browser memory: it starts empty on load and shows events that arrive while mounted. For a feed that survives reload, persist `entries` yourself — but for a live coordination panel, the in-memory log is exactly right. For **"who holds *this* row right now"** (a badge, not a feed), don't use `ClaimLog` — read the reactive claim state directly. It re-renders on change with no extra wiring: ```tsx const holder = useAblo((ablo) => ablo.records.claim.state({ id })); // Claim | null ``` See [React](./react.md) and [Coordination](./coordination.md) for the claim-read APIs. ### Route to your own backend `ClaimLog` is one implementation of the `observability` slot. To push events into Sentry, Datadog, or OpenTelemetry instead, spread `noopObservability` and override just the two coordination hooks: ```ts import Ablo, { noopObservability } from '@abloatai/ablo'; const ablo = Ablo({ schema, apiKey, observability: { ...noopObservability, captureClaim: (e) => { if (e.phase === 'rejected') Sentry.captureMessage(`claim blocked: ${e.model}/${e.id} by ${e.actor}`); }, captureConflict: (e) => Sentry.captureMessage(`stale write tx ${e.clientTxId} on ${e.rows.length} row(s)`), }, }); ``` `ClaimLog` implements the full `SyncObservabilityProvider`, so it drops straight into the `observability` slot. The surface exports `ClaimLog`, `formatClaim`, `formatConflict`, and `noopObservability`, plus the types `ClaimEvent`, `ConflictEvent`, `ClaimLogEntry`, and `SyncObservabilityProvider`. > **Both transports, from 0.21.0.** Observability fires on the WebSocket and on > the stateless HTTP transport (claim acquired, plus coordination-conflict > rejections on every write door). Before 0.21.0 only WebSocket emitted, so a > `ClaimLog` on an HTTP client, such as a headless server-agent eval, stayed > silent even though coordination still worked. ## Errors Ablo's thrown errors are typed and self-describing — `String(err)` (or logging it) yields one clean line, never a stack dump: ``` AbloValidationError [model_required_field_missing]: A required field was absent. (see https://docs.abloatai.com/errors#model_required_field_missing) [request_id: req_8Fk2aQ] ``` Branch on `err.code` (stable) — never on the message (rewordable). See [Client Behavior](./client-behavior.md) for the full error model and which codes are safe to retry. ### Diagnosing `capability_scope_denied` The same stable code covers two different enforcement layers, so inspect `error.details.origin`: - `capability_allowlist`: the branch/session credential did not grant the operation. `requiredCapability.scope` names the missing `model.verb`, and `details.resolvedOperations` shows the grants the server actually resolved. - `database_row_level_security`: Ablo's capability gate allowed the operation, but Postgres rejected it under the customer table's RLS policy. `details.databaseSessionContext` shows the organization, project, branch, participant kind, user principal, and the complete built-in-plus-custom session-setting values configured for that transaction. `customSessionSettings` isolates only the mappings declared by the schema; an empty object there does not mean built-in settings such as `app.current_org_id` were absent. Do not respond by changing a tenant policy to `USING (true)` or granting `BYPASSRLS`. Compare the row's tenant value with `databaseSessionContext.sessionSettings.app.current_org_id`. On CREATE, Ablo server-stamps the authenticated organization into the model's row-local tenancy field; a missing value is a server/version fault to report with `requestId`, not a requirement to make the column nullable or open the policy. Every rejected live commit carries `requestId` on the thrown error and `request_id` in its JSON form and warning line: ```ts import { AbloError } from '@abloatai/ablo'; try { await ablo.records.create({ data, }); } catch (error) { if (error instanceof AbloError) { console.error(error.code, error.requestId, error.requiredCapability, error.details); } } ``` An awaited model write rejects with that complete typed error. `onMutationFailure` remains the notification channel for deliberately unawaited optimistic writes; it is not required to recover details from an awaited write. ### Local reads versus a confirmed server read `list()` without a completeness option may return the current local pool immediately. That is why it can be empty while Postgres contains rows: it is not evidence that the replication source has no history. Use: ```ts await ablo.records.list({ type: 'complete' }); ``` `type: 'complete'` waits for a server round trip and returns the confirmed result. `type: 'unknown'` returns the local result immediately and refreshes it in the background. The distinction is freshness/completeness, not claimed versus unclaimed data. --- Source: https://www.abloatai.com/api/docs/deployment # Deployment > What production takes: a database Ablo can reach, a key minted for the plane you mean, and a schema push in the deploy. One command answers whether the infrastructure needed for a write is ready right now, and if not, why: ```bash ABLO_API_KEY=sk_… npx ablo status ``` ```text ablo status key sk_51H8… (ABLO_API_KEY env — overrides stored) org org_3nKq… project checkout (prj_7Yb2…) acts on production root push production with sk_51H8… (env) api https://api.abloatai.com reachable data ✓ database connected to this plane (direct) schema 4 models pushed (rev 12) hash 3f9a2c81 @ 2026-07-18 • orders typename=orders • lineItems typename=lineItems • fulfilments typename=fulfilments • reviews typename=reviews ✓ write infrastructure is ready. Your database constraints and row-level policies still apply. ``` `status` asks the routing authority rather than sampling a read, because reads resolve while writes are held — a plane with no database connected serves every read and refuses every write. The verdict at the bottom is the whole page in one line, and `--json` puts the same conclusion in a `blockers` array you can gate a deploy on. ## The three ingredients There is no Ablo service for you to deploy. Ablo is hosted, your rows live in your own Postgres, and your app runs where it already runs — so a deployment is three pieces pointed at the same plane. | Ingredient | Who runs it | What "deploying" means for it | |---|---|---| | **Your Postgres** | You (or your provider) | Registering it against the production root, once, with a root-bound key. | | **Ablo** | Hosted at `api.abloatai.com` | Nothing to run. You choose a project, a plane, and the keys that reach them. | | **Your app and agents** | You | Holding the right credential for the runtime, and pushing the schema in the deploy. | Everything below is those three in order. ### Planes: what a deployment targets A **plane** is the isolation unit a credential acts on. `production` is the root branch; development and preview branches are children. Three things are per-plane, and knowing which three is most of what production readiness means: - **Rows:** a child-branch write is invisible to production and every sibling. - **The registered database:** one per plane, so your production database and your dev database are separate registrations. - **The active schema artifact:** the model shapes the engine actually routes on. Every `sk_` is fixed to one branch at mint. The persisted branch id—not a suffix or user-supplied slug—says whether that is the production root or one development child. This is why application code never passes an environment. A child copies its parent's active schema when it is created, then owns its schema history. A child push never changes production. Production gets new models only when the reviewed deployment pushes them to the root. There is no shared development plane. New credentials are branch-bound; follow [Branch-first development](./branch-development.md). ## 1. The database production writes to Your production database joins Ablo the same way your dev database did — logical replication so Ablo can read and confirm, a scoped writer role so Ablo can land rows — run once, with a root-bound key so the registration attaches to the production root: ```bash ABLO_API_KEY=sk_… npx ablo connect apply --url postgres://admin:…@host:5432/db ABLO_API_KEY=sk_… npx ablo connect check ``` [Connect Your Database](./data-sources.md) is the full walkthrough — the SQL, the two roles, and the complete list of what Ablo touches. Five things about it are specifically production concerns: **Your agents do not each hold a connection.** Every agent, worker, and function talks to Ablo, and Ablo holds the database connections — at most 4 connections per plane, the same 4 whether one caller is writing behind them or ten thousand are. They identify themselves as `ablo-direct-writer`, so `pg_stat_activity` accounts for everything Ablo has open at any moment. Size the database for that number rather than for your agent count. **Register the direct host, not the pooler.** A pooler terminates the session that replication needs, and it refuses the connection in the same words a wrong password would — so a pooled host reads as a credentials problem for as long as you let it. `ablo status` names a pooled host when it sees one, with the direct host to use instead. **Reachability is measured from Ablo's network, not yours.** `connect check` runs from the infrastructure replication runs on, so an IPv6-only, IP-allowlisted, or VPC-private database still verifies — and a database your laptop can reach but Ablo cannot fails here rather than at the first write. **`wal_level = logical` needs a restart.** It is server-wide and not reloadable. On RDS and Aurora it is a parameter-group change plus a reboot. Schedule it; it is the one setup step with downtime in it. **A replication slot retains WAL.** While Ablo is connected the slot holds what it has not yet acknowledged, so a long disconnection accumulates disk. Ablo monitors slot lag and retention and surfaces it, and drops an abandoned slot rather than letting it grow without bound. A database that cannot grant a `REPLICATION` role connects through the signed [Data Source endpoint](./data-sources.md) instead. Same model surface, same commit chokepoint — it is the marked fallback, so reach for it when replication is genuinely unavailable. ## 2. The credential each runtime holds There is one field, `apiKey`, and what goes in it follows from where the code runs. In production that resolves to four rows: | Runtime | Credential | Notes | |---|---|---| | Server, worker, agent, cron | `sk_` in `ABLO_API_KEY` | Defaults from the environment, so most code passes nothing. | | Serverless function | `sk_` in `ABLO_API_KEY`, with `transport: 'http'` | Stateless request/response; nothing held open across invocations. | | Browser, read-only | root-bound `pk_` | Publishable, safe to ship, and read-only. | | Browser, writing as the signed-in user | `authEndpoint` | A route on your backend mints a short-lived `ek_` per user. | [API Keys](./api-keys.md) covers the model; [Sessions](./sessions.md) covers minting. Two things bite specifically at deploy time. **`ablo login` does not give the application production authority.** It stores a project-scoped `mk_` management credential that manages branches and exchanges for temporary child credentials; it cannot read or write application data or push the production schema. A production deploy needs a **secret** `sk_` from the dashboard, supplied as `ABLO_API_KEY`. `ablo whoami` names what an explicit key acts on, and `ablo status --json` reports its capability as `runtimeKey.kind` for a pipeline to check before pushing. There is no local mode switch. **An explicit key always wins.** Application-facing commands resolve `ABLO_API_KEY`, then `.env.local`, then `.env`, then a legacy stored runtime credential — never the `mk_` management login for data operations. `ablo status` prints what it found under `key`, with its source. When a deploy lands somewhere surprising, that line is usually the answer. Use `ablo whoami` when you need only the authoritative identity: ```bash ABLO_API_KEY=sk_… npx ablo whoami ``` For a deployment secret already stored under another name, prefer `ablo whoami --key-env ` over copying it into argv or permanently maintaining parallel local variables. The command reads the explicitly named value from the process or project env files and reports its project and branch without printing the full secret. ## 3. Pushing the schema is a deploy step The server keeps its own copy of your schema and routes on that copy. Until it has yours, a write to a new model fails with `server_execute_unknown_model` — so `ablo push` belongs in your deploy pipeline, ordered **before** the code that depends on the new models goes live. ```bash ABLO_API_KEY=sk_… npx ablo push --yes # or, explicitly: npx ablo push --env-file .env.production --yes ``` Production requires confirmation: interactively you type the destination project's name, which is what makes a wrong-project deploy impossible to do by reflex. In CI there is no TTY, so `--yes` is the confirmation and a push without it stops rather than proceeding unattended. **Additive changes pass; destructive ones ask.** Adding a model or an optional field applies cleanly. Dropping a model or a field, narrowing an enum, or a lossy cast is classified as data loss and needs `--force`; adding a required field to a populated table needs a `--backfill`. A push that fails is recorded `failed` and never activated, so a broken migration cannot leave clients gated against tables that do not match. This is the same expand-and-contract shape any online migration has, and it sequences the same way: push the additive change, deploy the code that writes both shapes, backfill, then push the removal in a later deploy once nothing reads the old field. **Drift is a connect-time rejection, not a runtime surprise.** A client built against a schema the server is no longer running is turned away when it connects. `ablo status` prints the local hash beside the deployed one, and the running client reports the same `serverSchemaHash` value, so the two can be matched at a glance. ## Gate the deploy on the verdict `ablo status --json` reports the same conclusion the human output ends with, in a form a pipeline can act on. An empty `blockers` array is the machine-readable form of "ready": ```bash blockers=$(ABLO_API_KEY=$ABLO_API_KEY npx ablo status --json | jq '.blockers | length') [ "$blockers" -eq 0 ] || { npx ablo status; exit 1; } ``` Each blocker carries a `problem` and the single `fix` that resolves it, in the order you should act on them: an unreachable API makes every other finding unverifiable, and a plane with nothing connected makes a schema question academic. The JSON also carries `confirmedTarget` — the org, project, and environment the server says this key resolves to — which is the authoritative answer to where a push would land. ## Webhooks point at the deployed URL `npx ablo dev` prepares the schema branch; it does not forward webhooks. A deployed HTTPS endpoint is registered once, and Ablo returns the signing secret a single time: ```bash ABLO_API_KEY=sk_… npx ablo webhooks create https://yourapp.com/api/ablo/[...all] ABLO_API_KEY=sk_… npx ablo webhooks list # endpoints + delivery health ``` `webhooks list` reports each endpoint's status, cursor, and last error — the place to look when a mirror falls behind. [Webhooks](./webhooks.md) covers the handler, the Standard Webhooks signature, and rolling a secret. ## What to watch once it is live - **`ablo logs`:** commit activity as it happens, scoped by the key. A root-bound key streams production; a child-bound key streams only that child. `--json` emits NDJSON for piping. - **`ablo status`:** the readiness verdict. Cheap enough to run from a health check on your own side. - **The [audit log](./audit.md):** every confirmed write traced back to the key that made it and the person who authorized that key. - **Your own logger:** pass `logger` to the client and SDK lifecycle, sync, retry, and rollback events join your existing pipeline. Writes carry receipts rather than being fire-and-forget: a commit is accepted the moment Ablo takes it (`queued`) and becomes `confirmed` once the row appears on your database's WAL. [Guarantees](./guarantees.md) covers which state to wait for and what each promises. ## When something is wrong | What you see | What it means | The fix | |---|---|---| | `no database is connected to this plane` | Writes are held rather than routed. Reads still resolve, which is why a read probe stays quiet. | `ablo connect apply` with a key for that plane. | | `password authentication failed` during connect | Often a pooled host refusing a session it cannot serve, in the words of a wrong password. | Register the direct database host. | | `server_execute_unknown_model` | The plane's active schema does not carry that model. | `ablo push` with a key for that plane. | | Clients rejected at connect | The deployed schema and the client's schema disagree. | Push this tree, or deploy the revision the server is running. | | `project_scope_denied` (403) | The model belongs to another project in your org. | Use a key minted for that project: a push cannot cross projects. | | 403 on `ablo push` | The key authenticated but cannot author schema. | A secret `sk_`; the `mk_` stored by `ablo login` is management-only. | ## The checklist 1. Production database registered against the production plane, direct host, and `ablo connect check` all green. 2. A secret `sk_` in the deploy environment as `ABLO_API_KEY` — never in a browser bundle. 3. `ablo push --yes` in the pipeline, ahead of the code that needs the new models. 4. `ablo status --json` gating the deploy on an empty `blockers` array. 5. Browser clients on a root-bound `pk_` or an `authEndpoint`, not a secret key. 6. Webhook endpoints registered at their deployed URLs, with the signing secret in your environment. ## Next steps - [Connect Your Database](./data-sources.md) — the setup this page registers, in full. - [Projects](./projects.md) — one org, many apps, each with its own planes and keys. - [API Keys](./api-keys.md) — which credential each runtime holds, and what it may do. - [CLI](./cli.md) — every command, its flags, and the environment variables. - [Operating on Your Database](./operating-on-your-database.md) — which actions run freely and which belong to a human. - [Debugging & Logs](./debugging.md) — watching claims, queueing, and grants while you build. --- Source: https://www.abloatai.com/api/docs/examples/agent-human # Agent + Human > An agent that yields the row when a person is already holding it. A record-writing agent that yields when a person is editing the same record. ## Scenario The same records are edited by agents and by the people watching them. They must not collide: - If a person already holds the row, the agent yields instead of fighting for it. - While the agent is updating, the UI can show who is active. - If the record changes mid-run, the commit is rejected instead of overwriting the newer edit. A **claim** does both jobs. Claims don't lock — if another writer holds the row, `claim` waits for them, re-reads the fresh row, then hands it back to you on `claim.data`, so two writers serialize instead of clobbering. The handle is an `AsyncDisposable`: hold it with `await using` and it releases on scope exit. And once you hold a claim, any `update` you make while it's held is stale-checked for free: the SDK records the row version you were handed and rejects the write with a typed error if the row moved underneath you while the agent was busy. ## Schema-Backed Worker The worker uses the same schema client the app uses. It reads the record from the server with `get({ id })`, claims the row, and writes through `ablo.records.update(...)` with a stale-check so a concurrent edit can't be overwritten. ```ts import Ablo, { AbloClaimedError, AbloStaleContextError } from '@abloatai/ablo'; import { defineSchema, model, z } from '@abloatai/ablo/schema'; const schema = defineSchema({ records: model({ title: z.string(), status: z.enum(['todo', 'doing', 'done']), }), }); const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, transport: 'http', }); export async function markDone(recordId: string) { await ablo.ready(); // get({ id }) is an async server read — await it. const record = await ablo.records.get({ id: recordId }); if (!record) return { status: 'not_found' }; try { // queue: false → don't queue behind a current holder. If another // participant holds the row, claim resolves null, so the agent yields // instead of waiting. Omit it, or pass queue: true, to queue behind them. const acquired = await ablo.records.claim({ id: recordId, queue: false, description: 'marking_done', }); if (!acquired) return { status: 'yielded' }; await using claim = acquired; if (claim.data.status === 'done') return { status: 'noop' }; // Inside an active claim, `update` is stale-checked automatically: the SDK // attaches the claim's snapshot version as `readAt` and sets // `onStale: 'reject'`. The write below is therefore equivalent to passing // those options yourself: // // ablo.records.update({ // id: claim.data.id, // data: { status: 'done' }, // readAt: , // onStale: 'reject', // }); // // If a newer version landed mid-run, the row no longer matches `readAt`, so // the server rejects this commit with AbloStaleContextError (caught below) // instead of clobbering that edit. const updated = await ablo.records.update({ id: claim.data.id, data: { status: 'done' }, }); return { status: 'done', record: updated }; } catch (err) { // The lease was lost or a foreign holder rejected the write. if (err instanceof AbloClaimedError) return { status: 'yielded' }; // A newer version was saved while we held the claim. The stale-check // rejected our commit, so nothing was overwritten — re-run on fresh data. if (err instanceof AbloStaleContextError) return { status: 'stale' }; throw err; } } ``` Keep workers on the same schema-backed client as the app. ## UI ```tsx 'use client'; import { useAblo } from '@abloatai/ablo/react'; export function RecordRow({ record: serverTask }: Props) { const data = useAblo((ablo) => ablo.records.local.get(serverTask.id)) ?? serverTask; const holder = useAblo((ablo) => ablo.records.claim.state({ id: serverTask.id })); const agentActive = holder?.participantKind === 'agent'; return (
{data.title} {agentActive ? Agent is updating... : null}
); } ``` ## Why It Works - The claim is visible to everyone: the UI reads it synchronously with `claim.state({ id })`, and it also arrives over the live stream. - `claim({ id })` makes writers take turns instead of racing — with `queue: false`, the agent simply yields when someone already holds the row. - The `update` made while the claim is held is stale-checked automatically, so an edit landing mid-run rejects the agent's write with a typed `AbloStaleContextError` instead of overwriting it. - That same write carries the claim, so each accepted change is attributed to the run that made it. --- Source: https://www.abloatai.com/api/docs/examples/ai-sdk-tool # AI SDK Tools > Give an AI SDK agent safe access to the same typed Ablo resources as your > backend. Use AI SDK for the agent loop and Ablo for the state boundary inside the tool. When an agent updates a shared record from inside a tool call you have a concurrency problem: another agent or a person may be editing the same row, and a naive write can overwrite work the model never saw. Ablo's tool adapters put the authoritative read, retry, claim, and confirmed-write behavior behind the ordinary AI SDK tool contract. ```ts // app/api/chat/route.ts import Ablo from '@abloatai/ablo'; import { defineSchema, model, z as schemaZ } from '@abloatai/ablo/schema'; import { anthropic } from '@ai-sdk/anthropic'; import { streamText, convertToModelMessages, stepCountIs, type UIMessage, } from 'ai'; import { updateTool } from '@abloatai/ablo/ai-sdk'; import { z } from 'zod'; export const runtime = 'nodejs'; const schema = defineSchema({ records: model({ title: schemaZ.string(), status: schemaZ.enum(['todo', 'doing', 'done']), summary: schemaZ.string().optional(), }), }); const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, transport: 'http', }); const updateTask = updateTool(ablo.records, { title: 'Update record', description: 'Update a record without overwriting concurrent work.', inputSchema: z.object({ recordId: z.string(), status: z.enum(['todo', 'doing', 'done']).optional(), summary: z.string().optional(), }), id: ({ recordId }) => recordId, apply: (current, { status, summary }) => ({ status: status ?? current.status, summary: summary ?? current.summary, }), }); export async function POST(req: Request) { // useChat sends UIMessage[]; convert before handing to the model. const { messages }: { messages: UIMessage[] } = await req.json(); const result = streamText({ // The model is a SERVER-bound provider instance — never sent from the client. model: anthropic('claude-sonnet-5'), messages: await convertToModelMessages(messages), tools: { updateTask }, stopWhen: stepCountIs(5), maxOutputTokens: 2048, }); return result.toUIMessageStreamResponse(); } ``` The model provider is interchangeable — swap `anthropic(...)` for any server-bound provider instance. What matters is that the route binds the model on the server (never trusting one sent in the request body) and converts the incoming `UIMessage[]` with `convertToModelMessages`. `updateTool` defaults to a functional update: Ablo re-reads and reapplies the patch if another participant writes first. Use `strategy: 'claim'` when the model should skip work already owned by someone else, or `strategy: 'queue'` when it should wait in Ablo's server-owned FIFO claim queue. The same entrypoint also exports `readTool`, `createTool`, and `deleteTool`; deletes require AI SDK approval unless the application explicitly disables it. When the model call needs several current reads rather than one model tool, [Context](../context.md) assembles them and formats an optional user message without taking ownership of the AI SDK loop. --- Source: https://www.abloatai.com/api/docs/examples/existing-python-backend # Existing Python Backend > Add agent coordination to a Python API server without replacing it. Put Ablo in front of the records several agents (or the people alongside them) edit at once and you get two things at no cost to your stack: every edit fans out live to everyone watching, and agents and people write through one shared contract. The goal is not to replace the backend — your Python service and database stay the source of truth, and Ablo coordinates the writes into them. You stop calling your endpoint directly; you call Ablo, Ablo calls your endpoint, and Ablo pushes the result back out to every browser and agent on that record. Use this path when a product already has a Python API server and every button currently calls an application endpoint. It applies to any API-backed app, not only Python — a YC company's existing dashboard can keep its current endpoint/service/database shape and migrate one coordinated model at a time. Here is the full path a button takes. After your Python service commits the change, Ablo pushes it live to every other browser and agent watching that record (the "realtime fanout" step at the bottom): ```txt Browser UI -> Ablo model write -> Python Data Source endpoint -> existing Python service layer -> app database -> Ablo realtime fanout -> browser UI and agents ``` ## 1. Declare The Shared Models Create a schema for the records that need realtime coordination. ```ts // web/ablo/schema.ts import { defineSchema, model, z } from '@abloatai/ablo/schema'; export const schema = defineSchema({ weatherReports: model({ location: z.string(), status: z.enum(['pending', 'ready']), }), }); ``` ```ts // web/ablo.ts — SERVER-ONLY client (holds the sk_ key; never imported in the browser). import Ablo from '@abloatai/ablo'; import { schema } from './ablo/schema'; export const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, }); ``` Mount the React provider near the app root. Build the browser client first — with an `apiKey` resolver (an async `() => Promise`) that fetches the short-lived session token your backend minted, instead of carrying the secret key — then pass it to the provider via `client`. ```tsx // web/app/providers.tsx 'use client'; import Ablo from '@abloatai/ablo'; import { AbloProvider } from '@abloatai/ablo/react'; import { schema } from '@/ablo/schema'; // Browser client: no secret key — `authEndpoint` points at the session route // your server exposes (below); the SDK fetches and refreshes the token. const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session', }); export function Providers({ children }: { children: React.ReactNode }) { return {children}; } ``` The session route mints with the server client that holds the `sk_` key — the browser only ever sees the short-lived token: ```ts // web/app/api/ablo-session/route.ts import { ablo } from '@/ablo'; import { credentialEndpointSuccessSchema } from '@abloatai/ablo/auth'; export const runtime = 'nodejs'; export async function POST() { const userId = await currentUserId(); // your auth const { token, expiresAt } = await ablo.sessions.create({ user: { id: userId }, can: { records: ['read', 'update'] }, }); return Response.json( credentialEndpointSuccessSchema.parse({ token, expiresAt, credentialKind: 'ephemeral', }), { headers: { 'Cache-Control': 'no-store' } }, ); } ``` ## 2. Add Live Reads In The UI Keep the first render backed by the existing Python endpoint. After that, subscribe to the same model client Ablo writes through. ```tsx 'use client'; import { useAblo } from '@abloatai/ablo/react'; export function ReportRow({ report: serverReport, }: { report: { id: string; location: string; status: string }; }) { const report = useAblo((ablo) => ablo.weatherReports.local.get(serverReport.id)) ?? serverReport; const active = useAblo((ablo) => ablo.weatherReports.claim.state({ id: serverReport.id })); const claimed = Boolean(active); return ( ); } ``` No string model key is needed in the first example. Because the selector reads straight from `ablo.weatherReports`, your reads, your writes, and any agent all go through one client — so a live edit shows up here without extra wiring. ## 3. Add One Python Data Source Endpoint Expose one customer-owned Data Source endpoint: ```txt https://api.example.com/api/ablo/source ``` Store the Ablo API key in the Python server: ```bash ABLO_API_KEY=sk_... ``` Then expose one route that verifies the signed request and calls the existing service functions. ```py # app/ablo_source.py import base64 import hashlib import hmac import json import os import time from fastapi import APIRouter, HTTPException, Request from app.services.reports import get_report, list_reports, apply_report_operations router = APIRouter() def verify_ablo_signature(request: Request, raw_body: bytes) -> None: api_key = os.environ["ABLO_API_KEY"].encode() message_id = request.headers.get("webhook-id") timestamp = request.headers.get("webhook-timestamp") signature_header = request.headers.get("webhook-signature", "") if not message_id or not timestamp or not signature_header: raise HTTPException(status_code=401, detail="missing signature") signed_at = int(timestamp) if abs(int(time.time()) - signed_at) > 5 * 60: raise HTTPException(status_code=401, detail="expired signature") payload = message_id.encode() + b"." + timestamp.encode() + b"." + raw_body expected = base64.b64encode( hmac.new(api_key, payload, hashlib.sha256).digest() ).decode() presented = [ part.removeprefix("v1,") for part in signature_header.split() if part.startswith("v1,") ] if not any(hmac.compare_digest(expected, value) for value in presented): raise HTTPException(status_code=401, detail="invalid signature") @router.post("/api/ablo/source") async def ablo_source(request: Request): raw_body = await request.body() verify_ablo_signature(request, raw_body) body = json.loads(raw_body) if body["type"] == "load": if body["model"] == "weatherReports": return {"row": await get_report(body["id"])} if body["type"] == "list": if body["model"] == "weatherReports": return {"rows": await list_reports(body.get("query", {}))} if body["type"] == "commit": rows = await apply_report_operations( operations=body["operations"], client_tx_id=body.get("clientTxId"), scope=body.get("scope", {}), ) return {"rows": rows} raise HTTPException(status_code=400, detail="unsupported request") ``` `apply_report_operations` should reuse the same transaction and validation logic the existing Python endpoints already use. Dedupe by `clientTxId` so retries are safe. ## 4. Move Buttons Gradually Existing button path: ```txt Button -> Python endpoint -> service -> database ``` Target button path: ```txt Button -> ablo.weatherReports.update(...) Ablo -> Python Data Source endpoint Python service -> database Ablo -> realtime fanout and receipt ``` The app does not need a flag-day rewrite. Move one model at a time. ```ts const snap = ablo.snapshot({ weatherReports: reportId }); await ablo.weatherReports.update({ id: reportId, data: { status: 'ready' }, readAt: snap.stamp, onStale: 'reject', }); ``` Use `readAt` and `onStale: 'reject'` for actions that depend on state the user or agent already saw. If two people both click "mark ready" on a report one of them already finished, `onStale: 'reject'` makes the second write fail instead of silently clobbering — `readAt: snap.stamp` is the version the user actually saw, and the write is rejected if the row changed underneath them. ## 5. Report Direct Database Writes Some writes will still happen through old Python endpoints, cron jobs, admin tools, or imports. Those bypass Ablo until the backend reports them. Add an outbox table in Python and expose it through Data Source `events`: ```txt old Python endpoint -> service -> database -> outbox row Ablo polls events -> realtime fanout ``` Each event needs a stable event id, model name, entity id, event type, row data, and timestamp. If the change originated from an Ablo commit, include the same `clientTxId` so Ablo can ignore its own echo. ## 6. Add Agents Later Agents use the same model API as the UI: ```ts const report = await ablo.weatherReports.get({ id: reportId }); const snap = ablo.snapshot({ weatherReports: reportId }); await ablo.weatherReports.update({ id: reportId, data: { status: 'ready' }, readAt: snap.stamp, onStale: 'reject', }); ``` Agents reach for the exact same calls the UI does — the same write contract stated at the top of this page. The Python backend keeps owning the business logic and the database; agents just become another safe writer in front of it. --- Source: https://www.abloatai.com/api/docs/examples/nextjs # Next.js Example > App-router setup: the two clients, the session route, and reactive reads. A production-shaped Next.js app on Ablo — App Router, Server Actions, React Server Components, and live client subscriptions. It handles three things at once: a fast initial render from the server, writes that don't overwrite work already in progress, and a UI that updates the moment data changes. The key piece is `claim()`. Commit a write through it and Ablo rejects the write if the record moved since you read it, so nothing is silently clobbered. Claims don't lock: if another writer holds the row, `claim` waits for them, re-reads the fresh row, then hands it to you — writers serialize instead of colliding. ## Structure ```txt app/ layout.tsx # wraps the tree in providers.tsx # Client: browser Ablo client + api/ ablo-session/ route.ts # mints a per-user ek_ token for the browser records/ [id]/ page.tsx # RSC: get + render actions.ts # Server Action: claim, then write RecordEditor.tsx # Client: live updates lib/ ablo.ts # Server Ablo client (holds ABLO_API_KEY) ablo.schema.ts # shared schema ``` There are **two** Ablo clients, and the split is the whole point: - **Server** (`lib/ablo.ts`) holds the secret `apiKey` (`sk_`). Used by RSCs, Server Actions, and route handlers. Never imported into a client component. - **Browser** (`app/providers.tsx`) holds **no secret**. It fetches a short-lived per-user token (`ek_`) from a backend route via `authEndpoint`. Skipping the browser half is the most common setup mistake — the client then has no credential and the engine fails to initialize with `session_expired`. ## Server Client ```ts // lib/ablo.ts — server-only import 'server-only'; import Ablo from '@abloatai/ablo'; import { schema } from './ablo.schema'; export const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, transport: 'http', }); ``` ## Session Route The browser can't hold `sk_`, so a backend route mints a scoped, short-lived `ek_` for the signed-in user. Being signed in is not workspace authorization: revalidate the active membership immediately before every mint, and derive all organization, workspace, team, and group ids on the server. Never accept them from the request body. ```ts // app/api/ablo-session/route.ts import { ablo } from '@/lib/ablo'; import { getCurrentUser } from '@/auth'; import { headers } from 'next/headers'; import { credentialEndpointErrorSchema, credentialEndpointSuccessSchema, } from '@abloatai/ablo/auth'; const noStore = { 'Cache-Control': 'no-store' }; export async function POST(request: Request) { if (!(await isSameOrigin(request))) { return Response.json( credentialEndpointErrorSchema.parse({ error: { code: 'origin_mismatch', message: 'Cross-origin mint rejected' }, }), { status: 403, headers: noStore }, ); } const user = await getCurrentUser(); if (!user) { return Response.json( credentialEndpointErrorSchema.parse({ error: { code: 'session_expired' }, }), { status: 401, headers: noStore }, ); } // Query your membership table now—not when the login session was created. // The helper reads the active workspace from server-side session state and // returns null when the membership is stale or revoked. const scope = await authorizeActiveWorkspace(user.id); if (!scope) { return Response.json( credentialEndpointErrorSchema.parse({ error: { code: 'policy_denied', message: 'Workspace membership is stale or revoked' }, }), { status: 403, headers: noStore }, ); } const { token, expiresAt } = await ablo.sessions.create({ user: { id: user.id }, syncGroups: scope.syncGroups, can: { records: ['read', 'create', 'update'] }, }); return Response.json( credentialEndpointSuccessSchema.parse({ token, expiresAt, credentialKind: 'ephemeral', }), { headers: noStore }, ); } async function isSameOrigin(request: Request): Promise { const origin = request.headers.get('origin'); if (!origin) return request.headers.get('sec-fetch-site') !== 'cross-site'; const host = (await headers()).get('host'); return host !== null && new URL(origin).host === host; } ``` `authorizeActiveWorkspace` is application code: it must query the authoritative membership store and return server-derived sync groups. If fifteen-minute token expiry is too slow for your revocation requirements, mint a shorter `ttlSeconds` and revoke active sessions when membership changes. ## Provider The browser client points `authEndpoint` at that route and is handed to `` as an instance. Build it once at module scope so the socket isn't torn down on every render. ```tsx // app/providers.tsx 'use client'; import Ablo from '@abloatai/ablo'; import { AbloProvider } from '@abloatai/ablo/react'; import { schema } from '@/lib/ablo.schema'; const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session', }); export function Providers({ children }: { children: React.ReactNode }) { return {children}; } ``` ```tsx // app/layout.tsx import { Providers } from './providers'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` ## RSC Initial Render ```tsx // app/records/[id]/page.tsx import { ablo } from '@/lib/ablo'; export default async function RecordPage({ params, }: { params: Promise<{ id: string }> }) { const { id } = await params; await ablo.ready(); const record = await ablo.records.get({ id }); if (!record) return null; return ; } ``` ## Server Action Commit ```ts // app/records/[id]/actions.ts 'use server'; import { ablo } from '@/lib/ablo'; export async function markDone(id: string) { // Claim grants exclusive, ordered access and hands back the fresh row. await using claim = await ablo.records.claim({ id }); const record = await ablo.records.update({ id, data: { status: 'done' }, claim, }); return { status: 'done', record }; // claim auto-releases as the action returns } ``` The write runs while the claim is held. If anything else commits between the read and the write, the commit is rejected because the row changed underneath you — re-fetch and retry. ## Live Client ```tsx 'use client'; import { useAblo } from '@abloatai/ablo/react'; export function RecordEditor({ record: serverTask }: Props) { const data = useAblo((ablo) => ablo.records.local.get(serverTask.id)) ?? serverTask; const holder = useAblo((ablo) => ablo.records.claim.state({ id: serverTask.id })); const busy = Boolean(holder); return ( ); } ``` ## More - [React reference](../react.md) — every option on `useAblo`. - [API reference](../api.md) — every option on the write path. --- Source: https://www.abloatai.com/api/docs/examples/scoped-agent # Agent Scoped to One Workspace > Narrow an agent to a single record's audience, so its writes reach that group and no one else. You want an agent that edits **one workspace** and pushes realtime updates to the participants on **that workspace only** — not a broadcast to the whole org. The catch most people hit: which write reaches whom is decided by how the rows *relate*, not by which columns the write touched. So a record edit that never sets `workspaceId` still reaches everyone watching the workspace, because the record already belongs to it. You get this by declaring the relationship once, then narrowing the agent to the workspace id — you never assemble a `workspace:` audience string by hand. The three steps below show how to declare it, scope the agent, and write. See [Identity & Sync Groups](../identity.md) for the full reference. ## 1. Schema: declare the relationship, once ```ts import { defineSchema, identityRole, model, relation, z } from '@abloatai/ablo/schema'; export const schema = defineSchema( { // A workspace's rows form the group `workspace:` (the kind comes from `groups.root`). workspaces: model( { title: z.string() }, { groups: { root: 'workspace' } }, ), // A record has no group of its own. It inherits its workspace's group via the // `parent` edge, so a record write reaches everyone watching the workspace. records: model( { workspaceId: z.string(), title: z.string() }, { relations: { workspace: relation.belongsTo('workspaces', 'workspaceId', { parent: true }) } }, ), }, { // People get their full org scope automatically from these. identityRoles: [ identityRole({ kind: 'org', source: 'organizationId' }), identityRole({ kind: 'user', source: 'userId' }), ], }, ); ``` ## 2. Dispatch: narrow the agent to the workspace it's working on An agent can never reach more than the user who triggered it — that's the upper limit. From there you narrow it to a single workspace by minting the agent's session against **just that workspace's sync group**. You build the group from the **model kind and id** with the typed `syncGroup` helper — `syncGroup('workspace', workspaceId)`, never a hand-assembled `workspace:` string — where `'workspace'` is the kind declared by the `workspaces` model's `groups.root`. Mint the scoped session on your backend (it holds the `sk_` key; the browser never does), then hand the short-lived token to the browser client: ```ts // server — mints a scoped agent session for one workspace import Ablo from '@abloatai/ablo'; import { syncGroup } from '@abloatai/ablo/schema'; import { schema } from './schema'; const server = Ablo({ schema, apiKey: process.env.ABLO_API_KEY }); export async function mintProjectAgentSession(workspaceId: string, agentId: string) { const { token } = await server.sessions.create({ agent: { id: agentId }, can: { records: ['read', 'update'] }, // operation allowlist for this run syncGroups: [syncGroup('workspace', workspaceId)], // narrowed to just this workspace }); return token; } ``` ```tsx // client — the browser client carries only the scoped token. import Ablo from '@abloatai/ablo'; import { AbloProvider } from '@abloatai/ablo/react'; import { schema } from './schema'; const ablo = Ablo({ schema, apiKey: async () => mintProjectAgentSession(workspaceId, agentId), }); // The agent run is mounted on behalf of its triggering user. {children} ``` `syncGroups` requests, it never grants: at connect the server intersects the groups the session asks for with the groups the identity is actually allowed, so the agent can never reach a workspace its triggering user couldn't. ## 3. Write: it fans out to everyone on that workspace Inside any component under the provider, grab the scoped client with `useAblo()` and write. The connection is already narrowed to `workspace:` from Step 2. ```ts const ablo = useAblo(); // Other participants subscribed to workspace: — a reviewer agent, a // person watching in the UI — receive this delta in realtime. Participants on // other workspaces never see it. await ablo.records.update({ id: recordId, data: { title: 'Ship the Q4 report' } }); ``` The record's delta is stamped `workspace:`, derived server-side from the record → workspace `parent` edge — not from `workspaceId` appearing in this particular write, and not from whatever the agent happened to subscribe to. The routing is decided by the data: a record belongs to its workspace, so its writes go to the workspace's group, full stop. ## See also - [Identity & Sync Groups](../identity.md) — the full scope / parent / grants model. - [Agent + Human](./agent-human.md) — yielding when someone else edits the same row. --- Source: https://www.abloatai.com/api/docs/examples/server-agent # Server Agent > A stateless schema-backed worker: wake, claim, commit, go idle. A server agent is backend code — a cron job, a queue worker, an AI record — that reads and writes your app's records outside the browser. The hard part is doing it without racing whatever else is working: if two workers pick up the same record at once, one write clobbers the other. This is what `claim()` is for. Agents hold no socket, so pass `transport: 'http'` and import the same schema the rest of the app uses. Below, a worker finishes a record by claiming it, writing the result, and releasing it automatically when the claim goes out of scope. `claim({ id })` takes the record for your worker and returns a disposable handle: the fresh post-lease row is on `claim.data`, and holding the handle with `await using` releases the claim on scope exit (or call `claim.release()`). Claims don't lock. If another writer holds the row, `claim` waits for them, re-reads the fresh row, then hands it to you — so two writers serialize instead of clobbering. ```ts import Ablo from '@abloatai/ablo'; import { defineSchema, model, z } from '@abloatai/ablo/schema'; const schema = defineSchema({ records: model({ title: z.string(), status: z.enum(['todo', 'doing', 'done']), summary: z.string().optional(), }), }); const control = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, }); async function clientForWorker(workerId: string) { const { token } = await control.sessions.create({ agent: { id: workerId }, can: { records: ['read', 'update'] }, }); return Ablo({ schema, apiKey: token, transport: 'http' }); } export async function completeTask(recordId: string, workerId: string) { // Participant identity comes from this worker-specific session. Two clients // made directly from the same root key are re-entrant, not contenders. const ablo = await clientForWorker(workerId); await ablo.ready(); const record = await ablo.records.get({ id: recordId }); if (!record) return { status: 'not_found' }; const acquired = await ablo.records.claim({ id: recordId, contention: { mode: 'skip', onStatus(event) { if (event.type === 'skipped') { console.info('record already owned', event.error.code); } }, }, description: 'completing', }); if (!acquired) return { status: 'already_claimed' }; await using claim = acquired; const updated = await ablo.records.update({ id: claim.data.id, data: { status: 'done' }, }); return { status: 'done', record: updated }; // claim auto-releases as the function returns } ``` `get({ id })` is an async server read — it hits the server and returns the row (or `undefined`, which the early `not_found` guard handles). The update runs while the claim is held; awaiting it resolves only once your database has confirmed the row landed. The two options on the claim: - `queue: false` — skip this record if another claim is already in progress, rather than queueing behind it. Fail-fast dedup: *if someone else has this job, skip it.* It resolves `null`; it does not throw. (The default queues.) - `description: 'completing'` — a readable label for what your worker is doing, visible to anyone reading `claim.state({ id })`. ## Atomic batches When several rows must change together, submit one atomic commit through the same schema-backed client: ```ts await ablo.commits.create({ operations: [ { action: 'update', model: 'records', id: 'record_123', data: { status: 'done' } }, ], wait: 'confirmed', }); ``` Because the worker uses the same schema and `claim()` as everything else, its writes reach every connected client in real time and never collide with work already in progress. --- Source: https://www.abloatai.com/api/docs/groups # Change Propagation > How one row's change reaches the rows and actors that depend on it. > How a change to one row reaches the rows and actors that depend on it, and how > to keep a chain of dependent work fresh. This is the propagation half of sync > groups; [`identity.md`](./identity.md) is the access half (who may read a > group), and [`concurrency-convention.md`](./concurrency-convention.md) is the > convention this rests on. --- ## Start from the problem An agent reads workspace `A` to write document `B`. A moment later it reads `B` to write block `C`. Between those steps someone else edits `A`. The agent is now building `C` on a premise that has moved — and nothing about writing `C` looks wrong in isolation. That is stale context, and it is the thing sync groups let you catch. The recipe is one field on the commit: declare the group you read as a premise, and say what should happen if it moved. ```ts // The agent read everything under workspace:abc to compose this write. await ablo.blocks.update({ id: 'block-C', data: { text: revised }, reads: [{ group: 'workspace:abc', readAt: watermark, onStale: 'notify' }], }); ``` At commit, inside the write transaction, the engine asks a single question: *did any delta routed to `workspace:abc` land after `watermark`?* If nothing moved, the write applies. If something moved, `onStale` decides — `notify` holds the write and hands the agent a `StaleNotification` naming the group, so it re-reads `workspace:abc` and regenerates; `reject` aborts the batch with a `409`. The agent never persists work built on a premise it can no longer see. --- ## How you hear about it Four channels carry "something changed", and they answer four different questions. Pick by the question you have. ```ts // A screen that stays current. ablo.records.onChange((docs) => render(docs)); // Who else is in here, and what are they holding. await using room = await ablo.records.join(documentIds, { ttl: '5m' }); room.peers; // Stop this write if the thing I read moved while I composed it. await ablo.blocks.update({ id, data, reads: [{ group: 'workspace:abc', readAt, onStale: 'notify' }] }); // Tell me later if this moves, even though I am not writing now. await ablo.records.track({ id: 's-1' }); ``` | Question | Channel | Arrives | | --- | --- | --- | | What do the rows say right now? | `onChange` | As deltas land, on the socket | | Who else is working here? | `join`, then `room.peers` and `room.claims` | As participants come and go, on the socket | | Did the premise for **this** write move? | `reads` on the write | On that write's receipt, before it applies | | Has anything I read moved since? | `track` | On your next commit's receipt | | Stop me writing if it has | `track` with `onStale: 'reject'` | Refuses that commit, at the chokepoint | Two distinctions do most of the work here. **`join` is about people; `track` is about data.** Both open a subscription and both are scoped by sync group, which is why they look alike. `join` reports participants: who is present, what they are doing, which rows they hold. `track` reports the rows themselves: something you said you cared about moved, here is the watermark to re-read it at. A tool that wants to avoid duplicating a peer's work needs `join`. A tool whose output goes stale when its inputs change needs `track`. **`reads` guards one write; `track` outlives it.** They speak the same vocabulary and produce the same `StaleNotification`. A `reads` entry is checked once, at the commit that carried it, and discarded. A `track` is persisted and re-checked against every delta after it, so a long-running actor hears about a change that landed while it was thinking, on the next commit it makes. `onChange` and `join` need a live socket, so they are available on the default WebSocket client. `reads` and `track` ride the commit, so they reach a socketless actor over HTTP too, which is what makes them the notification path for agents and workers. --- ## Three ways a change reaches other rows "A affects B and C" means three different things. The engine does the first two for you and leaves the third to you — on purpose. **Routing — who hears about a change.** Every row belongs to one or more sync groups, and a write fans out to all of them. A row also inherits its ancestors' groups: editing a block stamps the delta with `block:…`, `document:…`, *and* `workspace:…`, so everyone watching the workspace sees the block move. This is delivery, resolved by walking the ownership tree at commit time. It routes the change; it never recomputes a value. **Structural cascade — what disappears with a change.** Deleting a workspace removes its documents and blocks. The database does that through `ON DELETE CASCADE`, but a database-level cascade emits no delta, so open clients would quietly hold rows that no longer exist. The engine closes that gap: before the delete it snapshots the subtree and emits a tombstone for each descendant, routed to the right group. Watchers see the whole subtree vanish. **Value recomputation — what a change implies for derived state.** If `B` holds a number rolled up from `A`, the engine does not recompute `B` when `A` changes. It surfaces that `A` moved and lets the actor decide what `B` should become. This is the non-coercion principle: coordinate and report, resolve nothing by fiat. Merging derived state is a judgment call, and for an agent in the loop that judgment is the whole point. --- ## The chain: A → B → C Model a dependency as shared group membership. Put `A` and `B` in one group, `B` and `C` in another, and you have wired the edges of a chain. What travels along those edges is a *signal*, one hop at a time — not a recomputation. ``` A writes ──▶ group {A,B} ──▶ B hears it │ B decides, B writes ▼ group {B,C} ──▶ C hears it ``` A's delta lands in `{A,B}` and stops there. `C` is not in that group, so `C` learns nothing from A directly. `C` advances only when **B itself writes** and that new delta lands in `{B,C}`. `B` is the translator: it takes "A moved," decides what that means for its own state, commits, and *its* commit is what reaches `C`. The direction matters. The signal flows forward, A to B to C, and each hop is a real write an actor chose to make. The engine supplies the edges (group membership) and a stale signal on each edge (the premise check); the actors are the runtime that walks them. It is closer to a dataset an analyst recalculates cell by cell than to a reactive engine that recomputes the whole column for you. Two consequences worth designing around: - **The chain runs as fast as actors react.** If `B` never acts on its signal, the chain stops at `B` and `C` stays as it was. Freshness is an actor responsibility; the engine guarantees the signal, not the follow-through. - **Cycles don't settle themselves.** If `C` writes back to `A`, each hop is a separate commit with its own stale check, and nothing damps the oscillation. Keep the dependency graph acyclic, or give one actor the job of reaching a fixpoint. Convergence lives above the engine. --- ## Declaring the batch premise `reads[]` declares what the commit was based on. Each entry is a premise, and each governs the *whole* commit: if one goes stale, its disposition applies to every write in the batch, not just one operation. You choose the granularity per entry. ```ts reads: [ { group: 'workspace:abc', readAt: N, onStale: 'notify' }, // did anything in the workspace move? { model: 'Document', id: 's-1', readAt: N, fields: ['title'] }, // did this row (this field) move? ] ``` A **group** premise asks "did anything I was watching change?" — the native Ablo granularity, and the right tool for the chain above. A **row** premise is literal: this object, optionally these fields. A row premise with `fields` conflicts only on real field overlap, so two actors editing disjoint fields of the same row don't collide. `onStale` has three settings, defaulting to `reject`: - **`notify`** holds every write in the batch and returns a `StaleNotification`. For a group premise it carries the group name and the new watermark (`observedSyncId`); re-read the group at that point and regenerate. This is the setting a chain wants — the actor gets the truth and resolves it. - **`reject`** aborts the batch with a `stale_context` error (`409`). The right default when there is nothing to reconcile and the write should simply not land. - **`overwrite`** skips the check and lets the write land — last-write-wins, the explicit escape hatch. --- ## Staying subscribed across commits: `track` A batch premise guards a single commit: you state what you read, the engine checks it, the premise is gone. That fits an actor that reads and writes in one breath. It does not fit a long-running one — an agent that reads a row now, works for a few minutes, and writes much later. By the time it commits, the premise it would have declared is stale, and there was no commit in between on which to hear that the ground had shifted. `track` is the durable half of the same idea. Register what you are watching and it persists on the server; the next time you commit anything, a change that landed on the tracked target since you registered rides back on your receipt — the same `StaleNotification` an `onStale: 'notify'` premise would have handed you, arriving on the write you were going to make anyway. ```ts // Register interest and walk away — no write required. await ablo.records.track({ id: 's-1' }); // …minutes of other work later, on your next commit… const res = await ablo.blocks.update({ id: 'block-C', data: { text: revised } }); res.notifications; // populated if s-1 moved under you in the meantime ``` The target is a row (`{ id }` on the model verb) or a sync group (as a write option, below). A track is an idempotent registration: calling it again refreshes the same subscription rather than stacking duplicates, and once a change fires the track re-baselines, so the same change notifies once. Your own writes to a target you track never notify you — the signal is about what *others* did. You can also register a track as part of a write you are already making, the persisted companion to `reads`: ```ts await ablo.records.update({ id: 's-1', data: { title: revised }, reads: [{ group: 'workspace:abc', readAt: N, onStale: 'notify' }], // guards THIS commit track: [{ group: 'workspace:abc' }], // and keeps watching after it }); ``` So `reads` is the premise for the commit in hand; `track` is a standing subscription that outlives it. Both speak the same notification vocabulary. Delivery is on your next commit's receipt; a track does not yet push out of band between commits. ### Reporting, or gating: `onStale` A track says what a moved belief should do to your **next write**. Same enum the `reads` premise carries, minus the one mode that cannot apply: ```ts await ablo.records.track({ id: 's-1' }); // notify (default) await ablo.records.track({ id: 's-1', onStale: 'reject' }); // gate ``` - **`notify`** — the change rides your next receipt and the commit proceeds. You decide what it means. - **`reject`** — your next commit is **refused** while that belief is stale, even if it writes an unrelated row. The gate is on what you *know*, not on what you are touching. `reject` is for an actor that must not act on a stale picture — an agent that read a row, reasoned for minutes, and is about to write based on what it believed. Ablo enforces it at the commit chokepoint, so it is a guarantee rather than a convention: the write cannot land. The gate takes a key. It does **not** reopen on its own, because an agent that retried blindly would land exactly the write `reject` was asked to prevent. You re-read, then re-register the track to say so: ```ts try { await ablo.records.update({ id, data }); } catch (err) { if (err.code === 'stale_context') { const fresh = await ablo.records.get({ id: 's-1' }); // read await ablo.records.track({ id: 's-1', onStale: 'reject' }); // acknowledge await ablo.records.update({ id, data: reconsider(fresh) }); // now it lands } } ``` A track-only call is never gated, so acknowledging is always reachable. `overwrite` is not offered: it means "apply my write anyway", and a track guards no write of its own to apply. --- ## Sizing groups A group is the unit of both delivery and staleness, so its size is a real tradeoff. A change fans out to every subscriber of every group it touches, and a group premise fires when *anything* in the group moves — so a group that is too broad wakes actors for changes they don't care about, and one that is too narrow misses the dependency you meant to track. The rule of thumb: **make a group the smallest set of rows that must stay mutually consistent.** A workspace and its documents belong together because editing one changes what the others mean; two unrelated workspaces do not. Reach for finer, overlapping groups when you genuinely have a dependency chain to track, and keep them coarse everywhere else. --- ## Where this is defined - **Access**, meaning who may read or write a group, is [`identity.md`](./identity.md). - **The convention** behind non-coercion, the premise, and the notification is [`concurrency-convention.md`](./concurrency-convention.md) (§4 and §5). - **The mechanics**, the three coordination blocks underneath, are [`coordination.md`](./coordination.md). - **`join` and presence**, the participant half of the table above, are [`coordination.md`](./coordination.md) for the claim stream and [`react.md`](./react.md) for `useJoin`. --- Source: https://www.abloatai.com/api/docs/guarantees # Guarantees > Exactly what a confirmed write, a rejected stale write, and a held claim each promise. When an Ablo write succeeds, the server has confirmed it. Concurrency behavior depends on the write form you choose: plain writes are last-write-wins; functional updates, stale guards, and claims add protection when a write depends on earlier state. This page is the precise list of what each form promises. Claims don't lock. If another writer holds the row, `claim` waits for them, re-reads the fresh row, then hands it to you — so two writers serialize instead of clobbering. ## Confirmed Writes Awaiting a schema model write resolves only after authoritative confirmation and returns the updated row. ```ts const updated = await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' }, }); ``` If the call resolves, the write was accepted by the server. If it rejects, the typed error tells you exactly why — the most common reasons being failed authorization, a schema validation error, or a stale-state or claim conflict (each covered below). Schema model writes return the updated model row. ## Optimistic Local State Schema model writes update local state optimistically. This keeps UI and agent tools responsive while the commit is sent to the server. - The local model changes immediately, before the promise settles. - The promise always waits for authoritative confirmation. - If the server rejects the write, the SDK rolls back the optimistic change and raises a typed error. The server remains the source of truth. ## Stale-Write Protection Use `snapshot(...)` and `readAt` when a write depends on state the agent already read: ```ts const report = await ablo.weatherReports.get({ id: 'report_stockholm' }); if (!report) throw new Error('report missing'); await ablo.weatherReports.update({ id: report.id, data: { status: 'ready' }, reads: [report], }); ``` The returned row carries opaque evidence. If it changed after the read, the server rejects the write instead of applying stale reasoning. [Context](./context.md) collects the same exact-row evidence when an action depends on several awaited values. External values remain informational and do not acquire this guarantee. Two other dispositions exist. `overwrite` applies the write with no stale check at all. `notify` **holds** the write, so the row is left as it stands, and hands back a `StaleNotification` carrying the current value for the actor to reconcile and re-issue; the rest of the batch still commits. See [Concurrency Convention](./concurrency-convention.md) for the full taxonomy, what each disposition is checked against, and where the convention stops. A plain `update({ id, data })` carries no stale premise. If no one holds a claim on the target, it is last-write-wins. That is appropriate for independent values such as status flags, but not for a read-modify-write calculation. For the latter, use the functional update form or pass exact returned rows through `reads`. ## Claim Coordination > The guarantee, not the how-to. Methods, the claim-state object, and the `claim.queue` > live in [Coordination](./coordination.md). Claims are live coordination signals. They are not database locks. `ablo..claim({ id })` serializes on contention: if another human or agent already holds the row, the claim waits for them to finish, then re-reads the row before handing it back, so you proceed from fresh state. Reads stay open while a claim is held — `ablo..claim.state({ id })` returns the current claim state (or `null`) without ever blocking. A server read can pass `ifClaimed: 'fail'` to error out, when it should not return a row while someone else is mid-edit. Reads never block on a claim — to wait for a row to free up, `claim({ id })` it (the claim queues fairly behind the holder). By default, a held claim rejects writes from other participants to the claimed target. Contenders that call `claim` wait their turn; ordinary reads remain open. An explicit model conflict policy can choose another disposition for a participant kind. While you hold a claim, the matching `ablo..update({ id, ... })` is rejected with `AbloStaleContextError` if the row changed underneath you after your claim point. ## Agent Runs Agents should import the same schema as the app and write through `ablo..claim(...)` plus `ablo..update(...)`. ## Audit Trail Attribution is not a separate log you opt into. It rides on the change itself. Every broadcast delta names the actor, the authority it acted under, the credential that authorized it, and the approval stage it was in: ```ts { modelName: 'weatherReports', modelId: 'report_stockholm', actionType: 'U', actor: { kind: 'agent', id: 'weather-agent-v3' }, onBehalfOf: { kind: 'user', id: 'user_8f2a' }, capabilityId: '…', // the key the write was authorized by confirmationState: 'auto', // previewed | approved | required_human_approval createdAt: '2026-05-14T14:22:01.034Z', } ``` `actor` and `onBehalfOf` are derived from the credential, not from the call site, so an agent cannot name a different actor in its own write. `capabilityId` is non-null for every agent and system commit, so a write can always be traced to the key that made it, and from that key to the person it was issued to. The stored history goes one step further than recording. Audit rows are chained with a keyed hash, so the log is tamper-*evident*: `verify-chain` walks the chain and, if it breaks, names the sequence number and the hashes that disagree. No chain roots at an agent. The delegation root is always the person who set the work in motion. For agent work this is what answers, after the fact: what changed, who authorized it, which run did it, and whether a human was in the loop. See [Audit Log](./audit.md) for the stored row shape, the filters, verification, and export. ## Persistence Ablo defaults to in-memory persistence ('memory'), so nothing is written to disk unless you ask for it. Opt into a durable browser cache that survives reloads when you need it: ```ts const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, persistence: 'indexeddb', }); ``` Node, SSR, tests, and agents use in-memory persistence ('memory') automatically. Cache persistence and outbound-write recovery are separate concerns. Most clients need only the default memory cache: once the server confirms a write, the server is durable and the idempotency key makes a retry safe. A long-running worker that must also recover an unacknowledged write after its own process dies can opt into a durable write journal: ```ts const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, durableWrites: { store: workerWriteStore, namespace: 'report-worker', }, }); ``` The store can be backed by the worker's workflow state, SQLite, or another durable system. Actor identity is derived from authentication; `namespace` only separates workflow or deployment lanes sharing the same store. ## Storage Boundary Your rows live in your database; Ablo holds change history and coordination state. Writes land in your Postgres through a scoped writer role and are confirmed from its authoritative change feed. For a database that cannot grant replication, Ablo uses a signed Data Source endpoint instead. See [Connect Your Database](./data-sources.md). ## Writes Use `ablo..create/update/delete` for state changes. The server validates authorization, stale state, active claim conflicts, and idempotency before accepting the write. --- Source: https://www.abloatai.com/api/docs/how-it-works # How Ablo Works > You write through Ablo, Ablo writes to your Postgres, and the write-ahead log confirms it. You write through Ablo, and Ablo writes to your Postgres. That one sentence is the whole model — everything below explains what it means and how to use it. ```ts // You call Ablo. Ablo lands the change in your database and confirms it. await ablo.records.update({ id: 'record_42', data: { status: 'done' } }); // Reads come back live, kept current from your database. const record = ablo.records.local.get('record_42'); ``` ## The mental model: read this once Ablo is a **coordination layer in front of your Postgres**. Agents, background jobs, and the people alongside them all change the same application data through one API, and Ablo makes sure their writes don't clobber each other. - **Writes go through Ablo:** `ablo..create / update / delete` are authorized, made idempotent, and applied to your Postgres through a scoped writer role. A plain write is last-write-wins. Use a functional update, a explicit `reads: [returnedRow]`, or a claim when the new value depends on an earlier read. - **Your database confirms the write.** Ablo tails your write-ahead log (WAL). When the row it wrote shows up there, the receipt is promoted to `confirmed`. So your database, not Ablo, is the source of truth for row state — the WAL echo is how Ablo *confirms*, not how it writes. - **Reads are live.** Ablo serves current state and keeps every connected client up to date off that same stream. - **Ablo stores only the change history and coordination state.** Your rows live in your database. Ablo does not run application migrations; your migration tool stays in charge of tables and columns. That's the shape: **you write through Ablo → it lands in your Postgres → the WAL echo confirms it → everyone connected sees it live.** ## The primitives | Primitive | Plane | Purpose | |---|---|---| | `Schema` | State | Declares typed models the app and agents can read and write. | | `Model` | State | The generated `ablo.` model. Use `get`/`list` (async reads), `local.get`/`local.list`/`local.count` (the same verbs, synchronous and local-only), `create`, `update`, and `delete`. | | `Claim` | Coordination | Who is working on a target. Taken via `ablo..claim({ id })` and read via `ablo..claim.state({ id })`. Ephemeral, never persisted. | | `Commit` | Protocol | The durable write underneath model updates. Most users do not call it directly. | | `Receipt` | Protocol | The result of a lower-level commit. Awaiting a schema write waits for confirmation. | ## Where your data lives You point Ablo at a Postgres database, and that's where its rows live. Only *which* database differs by environment — the code is identical. - **Production:** your Postgres. `ablo connect` sets up a scoped writer role and logical replication; your rows live in your database, and Ablo writes to them through that role. - **Development branches and local dev:** a separate or local Postgres, or a branch of the one you already run. Same models, same code, a different database behind them. - **Before you connect one.** Ablo keeps state in its own log, so you can build the whole app today and point it at a real database when you're ready. Registering the database is the whole switch. There is no tier or flag to choose. ## Use it end to end ```bash # 1. Install and scaffold. npm install @abloatai/ablo npx ablo init # 2. Push your schema (the models your agents edit together). npx ablo push # 3. Connect your database — one command, admin credential used once and discarded. npx ablo connect apply --url postgres://admin:...@host:5432/db ``` ```bash # Your app's environment holds only the API key — never a connection string. ABLO_API_KEY=sk_... ``` ```ts // 4. Build the client once. import Ablo from '@abloatai/ablo'; import { schema } from './ablo/schema'; export const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY }); // 5. Write through Ablo. Local state changes immediately; await blocks until // the authoritative feed proves the row is there. await ablo.records.update({ id: 'record_42', data: { status: 'done' } }); // 6. Read — live, no fetch loop. const record = ablo.records.local.get('record_42'); // 7. Coordinate when more than one actor can touch a row. Hold a claim and Ablo // serializes writes on that key against everyone else; read after claiming, // then write. The lease releases automatically at the end of the scope. await using _hold = await ablo.records.claim('record_42'); const latest = ablo.records.local.get('record_42'); // read after claiming, not from memory await ablo.records.update({ id: 'record_42', data: { status: 'done' } }); ``` For the setup details, see [Connect Your Database](./data-sources.md). For the coordination loop, see [Coordination](./coordination.md). For what `queued` and `confirmed` guarantee, see [Guarantees](./guarantees.md). --- Source: https://www.abloatai.com/api/docs/idempotency # Idempotency > Make a retried write safe: the same key never applies the same change twice. An agent retries. A socket drops mid-commit, a worker restarts, a queue redelivers — and the write you already sent arrives again. An idempotency key is how Ablo tells a retry from a new intention. Every model write carries one. The SDK generates a key when you omit it, which makes an in-process retry safe automatically. It cannot make a retry across a process restart safe, because a new process generates a new key — so for anything that must survive a crash, supply your own. ```ts await ablo.records.update({ id: recordId, data: { status: 'done' }, idempotencyKey: `record:${recordId}:mark-done:v1`, }); ``` ## The one rule **Derive the key from the business event, not from the attempt.** A key built from `crypto.randomUUID()` at the call site is regenerated on every retry, so it protects nothing — each attempt looks like a new intention and the write lands twice. A key built from the thing that happened (`record:42:mark-done:v1`) is identical on every retry by construction, which is the whole point. The same rule stated as its failure: never derive a key from a timestamp, an attempt counter, or a random value. If two retries of one operation can produce different keys, you have no idempotency. ## How it works The key is not a lookup that happens before the write — it is the **execution lock on the write itself**. Ablo inserts a pending row keyed by the caller and the key inside the same transaction as the mutation, and a unique index makes that insert the lock: - **Insert wins:** this transaction owns the execution and runs the write. - **Insert conflicts:** someone else owns it. The second caller waits for the owner to finish and then replays its recorded result. - **Insert conflicts, different request:** the key was reused to mean something else. Rejected. Because the lock and the write share a transaction, there is no window in which a write has happened but its key has not been recorded. Keys are scoped to **the organization and the participant**, not globally. Two agents can use the same key string without colliding, and one agent can never replay another's result. ## The four outcomes | You send | Ablo does | |---|---| | A new key | Runs the write. | | The same key, the same request, already finished | Replays the recorded result. The write does not run again. | | The same key, the same request, still running | Waits for the in-flight attempt, then replays its result. If the original is still running after a short wait, rejects with `idempotency_conflict` (409): retry the same key. | | The same key, a **different** request | Rejects with `idempotency_conflict` (409). A key is bound to the request it first arrived with. | Both conflict cases return the same code, so tell them apart by what your own client did. If you retried an identical request, the original is still in flight — wait and retry the same key. If you changed the request, that is a client bug: use a new key. ## Failures are not replayed: they re-run This is where Ablo deliberately differs from most request/response APIs, and it is the behaviour most likely to surprise you. **Only successful writes are recorded.** A write that failed leaves no idempotency record, so retrying it with the same key **executes fresh** rather than replaying the error. That is the right default here because most failures are ones you can fix and legitimately want to re-attempt — a validation error, a stale premise, a claim held by someone else. Replaying the original error for 24 hours would strand the caller behind a decision that is no longer true. The consequence to hold onto: a retry after a failure is a real execution. If a write failed in a way that leaves you unsure whether it landed — a timeout, a dropped socket — do not assume the retry is a no-op. Retry with the **same key**: if the original did land, the recorded success replays; if it did not, the write runs now. That is exactly the case idempotency exists for. ## The window A recorded result is retained for **24 hours**, then expires. Within that window a repeated key replays. After it, the key is forgotten and reusing it starts a genuinely new write. Treat 24 hours as *how long a retry is guaranteed safe*, not as permanent deduplication. A nightly job that reuses yesterday's key will execute again. Writes routed to a registered data source are the exception: their intent is retained **permanently** rather than expiring, because letting that record lapse would make a reused key indistinguishable from old work against your database. A key whose retained intent has expired is rejected with `idempotency_key_expired` (409) rather than being silently re-executed. ## Route pinning A key is bound to the route its first attempt took. If an earlier attempt was applied through a direct data source and a retry arrives when the endpoint fallback is active, Ablo rejects it with `source_transport_pinned` (409) instead of switching. That refusal is deliberate: switching routes on a retry risks applying a write that the first route may already have committed. Restore the original route and retry the same key. ## When to retry | Situation | Do | |---|---| | Timeout or dropped connection, no response | Retry with the **same** key, with backoff. You get the recorded success, or the write runs now. | | `source_unreachable` (503) | Retry with the **same** key once connectivity recovers. The write stays pinned to its route. | | `replication_lag_timeout` (504) | The write may have materialized. Retry with the **same** key, or wait for source ingestion to catch up. | | `AbloStaleContextError` | Re-read the row, regenerate, then write under a **new** key: the new write is a new intention. | | `AbloClaimedError` | Someone else holds the row. Wait or yield; the key is unused, so reuse it when you retry. | | `idempotency_conflict` (409) after an identical retry | The original is still in flight. Wait, then retry the **same** key. | | `idempotency_conflict` (409) after changing the request | A client bug: a key is bound to the first request sent under it. Use a **new** key. | | `idempotency_key_too_long` (400) | The key exceeds 255 characters. A UUID or a short business string works. | ## Keys - Up to **255 characters**. Longer is rejected, not truncated. - Unique per logical operation, identical across every retry of that operation. - Generate a new one only when a genuinely new operation begins. - Never put secrets in a key — it is stored and appears in support diagnostics. ## Related - [Client Behavior](./client-behavior.md) — every write option, and which errors retry. - [Guarantees](./guarantees.md) — what `queued` and `confirmed` promise. - [Errors](./errors.md) — the full code registry, including every code named above. --- Source: https://www.abloatai.com/api/docs/identity # Identity & Sync Groups > Who is connecting, and which slice of state they are allowed to see. This is the doc the Quickstart skips: **who is connecting, and which slice of shared state do they get?** If you've wired `` and wondered where org / team / user actually come from — start here. ## Ablo does not do auth Ablo is not an identity provider. It has no login, no password store, no session of its own. You keep whatever you already use — Clerk, Auth0, NextAuth, WorkOS, your own session table. Ablo's job begins **after** you've authenticated the user: you hand Ablo the already-authenticated identity, and Ablo decides which **sync groups** that identity may read and write. ## Inspect the credential the application is actually using `ablo whoami` describes the developer running the CLI. Runtime code should inspect `ablo.identity`, which is the server-confirmed `EffectiveAuthority` of the credential attached to that client. It is never decoded or reconstructed locally. ```ts import { Ablo } from '@abloatai/ablo'; import { CapabilityError } from '@abloatai/ablo'; const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY }); await ablo.ready(); console.log(ablo.identity?.operations); console.log(ablo.identity?.syncGroups); ``` The HTTP and stateful clients expose the same value after `ready()`. A denied scoped operation throws `CapabilityError`; compare its `requiredCapability.scope` directly with `ablo.identity.operations`. ```ts try { await ablo.records.update({ id, data: { status: 'done' } }); } catch (error) { if (error instanceof CapabilityError) { console.error('missing grant', error.requiredCapability); } } ``` Do not broaden the credential in the client. A backend holding the project secret mints a replacement, least-privilege agent credential with the schema- typed grant: ```ts const session = await control.sessions.create({ agent: { id: agentId }, can: { records: ['read', 'update'] }, syncGroups: [syncGroup('workspace', workspaceId)], }); ``` Install `session.token` in the agent process and call `ready()` again. The next `ablo.identity` is the authority the server will enforce; no automatic grant escalation occurs. So the integration question is never "how do I log into Ablo?" It's: *"My app already knows this request is user `U` in org `O`. How do I tell Ablo, so it scopes their realtime data correctly?"* The rest of this doc answers exactly that. ## What a sync group is A **sync group** is a named channel of shared state — a string like `org:acme` or `workspace:abc123`. It is simultaneously: - **the unit of fan-out:** a confirmed write to a row publishes a delta to every participant subscribed to that row's sync group(s), and - **the unit of access:** a participant receives a row's deltas *only if* the row's sync group is in their allowed set. There is no built-in `org` / `team` / `user` concept in the engine. Those are *your* domain words. Ablo only knows sync-group strings. The mapping from "this is user U in org O" to "they may subscribe to `org:acme` and `user:U`" is something **you declare in your schema**. Here is that whole declaration in one runnable place, so the concepts below have code to attach to. ## Declare it, end to end The entire declaration surface is: `identityRoles` (who may see what), and on each model `scope` / `parent` / `grants` (which group a row fans out on), plus optional `syncGroups` at session-mint time (narrowing). Read the three blocks first — a human gets their `org` / `team` scope, an agent gets one `workspace` — then the sections after explain each. ```ts // 1. src/ablo/schema.ts — map identity → groups, and anchor each model to a group import { defineSchema, identityRole, relation, model, z } from '@abloatai/ablo/schema'; export const schema = defineSchema( { // A scope root: its rows form the group `workspace:` (kind from `groups.root`). // Tenant isolation defaults to a row-local `organization_id` column, so no // `policy` is needed here. workspaces: model( { title: z.string(), status: z.enum(['draft', 'published']) }, { groups: { root: 'workspace' } }, ), // A child: it has no group of its own; it inherits its workspace's group via the // `parent` edge. A write to a document reaches everyone viewing the workspace. records: model( { workspaceId: z.string() }, { relations: { workspace: relation.belongsTo('workspaces', 'workspaceId', { parent: true }) } }, ), }, { // Each role is pure data: a `kind` (the group prefix) and the identity // `source` field to read. No closures — so the schema stays JSON-serializable. identityRoles: [ identityRole({ kind: 'org', source: 'organizationId' }), identityRole({ kind: 'user', source: 'userId' }), identityRole({ kind: 'team', source: 'teamIds', multi: true }), ], }, ); ``` ```tsx // 2. app/providers.tsx — a HUMAN gets their full org / team scope. // teamIds is set on the client you build (Ablo({ schema, teamIds: user.teamIds })), // not passed to the provider; the provider just takes that client. {children} ``` ```ts // 3. an AGENT run inherits its user, narrowed to the entities in play. // You narrow at SESSION-MINT time: your backend calls `sessions.create` with the // agent's allowed `syncGroups`, built from each model's scope via the // `syncGroup(kind, id)` helper — never a hand-built `workspace:` string. The agent's // runtime then connects with the minted token. const session = await server.sessions.create({ agent: { id: agentId }, can: { Workspace: ['read', 'update'] }, syncGroups: [syncGroup('workspace', workspaceId)], // floor: just the workspace it's working on }); // the agent runtime authenticates with the minted token const ablo = Ablo({ schema, apiKey: session.token }); ``` That's the whole surface. The rest of this doc is the *why* behind each line. ## Two kinds of group: the whole mental model You just saw a human get `org` / `team` groups and an agent get one `workspace` group. That split is the model. Every sync group is named after one of two things: - **Membership groups:** named after *who you are*: `org:{id}`, `team:{id}`, `user:{id}`. Produced from **identity** (`identityRoles`, Half 1). They're standing and durable — they don't change as you work. - **Entity groups:** named after *a thing*: `archive:{id}`, `workspace:{id}`, `document:{id}`. Produced from a **row's id** (a model's entity scope, Half 2). They're granular — one per record — and any participant can be pointed at a specific set of them. Agents and people fill that same space differently, and you declare the two in different places. An agent's groups come from what it's working on right now, so you pass them in code when you start the run. A person's groups come from who they are, so you declare them once in the schema. | | Subscribed by | Declared where | Gets | | --- | --- | --- | --- | | **Human** | *who they are*: membership | **the schema** (`identityRoles`): a rule, written once | every `org` / `team` / `user` group their identity implies: their whole standing world | | **Agent** | *what it's been given*: entities | **code, at the spawn site**: chosen per run | a handful of entity groups: the archive it's in, the documents it has read: never beyond what its user's membership could reach | > **One line:** humans subscribe by who they are; agents subscribe by what > they've been given. That's why you never write per-user scope code, but you always choose an agent's groups at the dispatch site. A user's org/team/user don't change per request, so their scope is a **rule the schema derives automatically**. An agent's reach depends on *what it's working on*, which is only knowable at dispatch — so you pass its `syncGroups` **when your backend mints the agent session** (`sessions.create({ agent, can, syncGroups })`). The schema's only job for entities is to declare *that* a model is entity-scopable and *what its group is named* (`scope: 'workspace'` → `workspace:{id}`); it never declares *which* entities a given agent gets. (A human can opt into the same runtime narrowing — a page scoped to one workspace — but by default a human's scope is fully schema-derived.) So an agent doesn't need a `user:{id}` standing grant. It's a participant pointed at a few entity groups, bounded above by its triggering user's membership. That boundary is the whole safety story, and it's covered in [Agents are participants too](#agents-are-participants-too). ```txt your auth → identity { kind, userId|agentId, organizationId, teamIds } → identityRoles (schema) → allowed sync groups → participant receives deltas for rows in those groups ``` The identity is a **participant** — and a participant is either a human (`kind: 'user'`) or an agent (`kind: 'agent'`). Same shape, same path; see [Agents are participants too](#agents-are-participants-too) below. Everything in the next two sections applies to both. ## Your schema lives in a project; your users commit to it The default is simple: your schema lives in a **project**, you push it once, and every session you mint resolves against it. Your end-users **don't have Ablo accounts** — your server's `sk_` mints an `ek_` per user, and by default that session lands in your project's own org. All your users share one schema, one data tenant, and receive targeted realtime changes through sync-groups. Model `policy` declarations govern which rows they may read; sync-groups are delivery routing, not read authorization. That's the whole story for most apps. **Add-on — org-per-customer isolation.** If you need each customer to be its own hard tenant (separate row-level isolation, optionally a separate database) you'd otherwise have to re-push your schema into every customer's org. Instead, keep one project as the home of your schema. A cross-organization mint automatically uses the owning key's project for the session's *schema* while its *data* stays in the customer's org: ```ts const server = Ablo({ schema, apiKey: process.env.ABLO_API_KEY }); await server.sessions.create({ user: { id: userId }, organizationId, // DATA → this customer's RLS-isolated organization can: { records: ['read', 'update'] }, ttlSeconds: 3600, }); ``` Server-side, the model **shape** loads from your schema project but column enrichment and the tenant connection still target `organizationId` — so the shared schema only *describes* the shape; the data plane stays the customer's and can't cross-leak. `schemaProject: { organizationId, projectId }` remains available as an explicit override for migrations or advanced routing. Omit `organizationId` for the single-organization default above. Requires a dedicated `sk_` with `organization:act-as`; see [Customer Organizations](./customer-organizations.md). ## The two halves of scoping Delivery scoping is two declarations that meet in the middle. One describes the **participant** (what may I subscribe to?), the other describes each **row** (which group does this row belong to?). A participant receives a row's realtime changes when the row's sync groups intersect the participant's allowed set. That intersection does not itself authorize an HTTP read. A model's `policy` governs read access. Treat sync-groups as change routing and `policy` (plus the organization boundary beneath it) as authorization; declaring one never silently creates the other. ### Half 1 (`identityRoles`): identity → allowed groups Declared once, on the schema, via the `identityRole({ kind, source })` factory. Each role is **pure data**: a `kind` (the group's prefix — `org`, `user`, `team`) and the `source` — the identity field to read. The engine reads `source` off the identity *you* supply and mints `:` for each value, building the participant's allowed set. There is no hardcoded `org:` / `user:` anywhere in the engine — the kinds and sources are entirely yours. ```ts // src/ablo/schema.ts import { defineSchema, identityRole, model, z } from '@abloatai/ablo/schema'; export const schema = defineSchema( { workspaces: model({ title: z.string(), status: z.enum(['draft', 'published']), }), }, { identityRoles: [ identityRole({ kind: 'org', source: 'organizationId' }), identityRole({ kind: 'user', source: 'userId' }), // `multi: true` reads an array field — one `team:` group per id. identityRole({ kind: 'team', source: 'teamIds', multi: true }), ], }, ); ``` The identity these `source` fields read is what your app resolves from its own auth — Ablo never invents it. Roles are pure data (no closures) on purpose: a `Schema` stays JSON-serializable end to end, so the same declaration works in-process and on a hosted server that only ever sees the compiled JSON. > **Single field per role.** `source` reads one field. An agent doesn't need its > own role: it runs on behalf of a user and carries that user's `userId`, so the > `user:{id}` role above already covers it — see > [Agents are participants too](#agents-are-participants-too). ### Half 2 (per-model scope): row → group You never write a sync-group string for a row. You declare a model's *place* in the entity graph and the engine derives the groups its rows fan out on. Three declarations, in order of how often you reach for them: **`groups.root` — this model is a scope root.** Its rows form a group of their own. The kind comes from the model's `typename` by default, or pass a string to set it explicitly (use the string form when the wire kind differs from the typename, e.g. typename `EntryCollection` but group `workspace:`): ```ts workspaces: model({ title: z.string() }, { groups: { root: 'workspace' } }); // a workspace row → group `workspace:` ``` **`parent` — this row lives inside another entity.** Mark the `belongsTo` edge to its owner; the row inherits that owner's group. This is the Zanzibar/ReBAC *parent* relation — "access inherits from parent" — and it chains transitively (a block → its document → its workspace), so a write to any descendant reaches everyone viewing the root. A *reference* (a provenance/template pointer, not ownership) must **not** be marked `parent`, or the row would leak into an unrelated scope: ```ts records: model( { workspaceId: z.string(), sourceEntryId: z.string().optional() }, { // default policy: row-local organization_id relations: { workspace: relation.belongsTo('workspaces', 'workspaceId', { parent: true }), // ownership → inherit workspace: sourceEntry: relation.belongsTo('documents', 'sourceEntryId'), // reference → NOT routed }, }, ); ``` > **Declare the parent edge — don't infer it.** Optionality is not a proxy for > ownership: many `parent` FKs are optional (a root folder, an inbox record), and > some required FKs are mere references. Containment is a fact only you know, so > it's declared, exactly as it is in OpenFGA/Zanzibar. **`groups.grants` — a membership edge.** On a join model (e.g. `archiveMember`), it says "this row grants a *subject* access to a *scope root*." Both are relation names on the model. The server resolves it at connect time — for user `U`, it finds the scope-root groups `U` is a member of and adds them to `U`'s allowed set (Linear's `/sync/user_sync_groups`). Use this for sub-org sharing; plain org membership is already covered by the `org:` identity role. ```ts archiveMember: model( { userId: z.string(), archiveId: z.string() }, { relations: { member: relation.belongsTo('users', 'userId'), room: relation.belongsTo('archives', 'archiveId'), }, groups: { grants: { subject: 'member', scope: 'room' } }, }, ); ``` For the rare group keyed on a plain field rather than a relation (per-recipient inbox fan-out, say), there's a `groups: { roles: [entityRole({ kind, source })] }` escape hatch. For rows that inherit *tenancy* (not a sync group) through a foreign key without carrying `organization_id`, use `policy: { by: 'parent', fk, parent }` rather than opting out of isolation. The old `orgScoped: false` exposed the whole table cross-tenant, so `validate_schema` rejects the removed options as `tenancy-option-removed` errors and steers you to `policy: { by: 'parent' }` (FK inheritance) or, for genuinely global reference data, the explicit `policy: { by: 'none' }`. See `packages/transaction/src/schema/model.ts` for the full option set. ## How identity reaches Ablo: the proxy model This is the part the README's "authenticates with the signed-in user's session" glossed over. Concretely: 1. **Your `ABLO_API_KEY` lives only on your trusted server**, scoped to your account. It signs your app's relationship with Ablo. It must never reach a browser bundle. 2. **Your server authenticates the user with your own system.** That's the request that knows "this is user `U`, org `O`, teams `[...]`". 3. **Your server hands that authenticated identity to Ablo**, and the browser talks to the realtime plane as an already-scoped participant. The browser never holds the API key and cannot widen its own scope — the security boundary is the identity your **server** vouched for, not anything the client asserts. 4. **Ablo runs your `identityRoles` over that identity** to compute the allowed sync groups, and the participant subscribes to exactly that set. The Ablo web app (`apps/web`) is the reference implementation of this shape: its server resolves the signed-in user and active organization from its own auth, and the sync layer composes the participant's sync groups from that resolved identity — the API key stays server-side throughout. The generic, library-agnostic name for "my server tells Ablo which of my users is acting" is the `Ablo-Acting-User` request dimension; the web app realizes it through its own session, but the contract is the same: **identity is asserted by your server, never by the browser.** > **Why the proxy, not a client API key?** A browser is a hostile runtime. If > the client could name its own org or sync groups, any user could read another > tenant's data by editing a request. By keeping the API key server-side and > deriving scope from the identity your server already authenticated, the trust > boundary lands in the one place you control. Scope resolution and session > minting therefore stay server-side. ## Wiring the provider The identity your server resolved is carried by the client you build and the `userId` prop. In a Next.js app, resolve the user in a Server Component and pass it down. Build the client once (the schema, `teamIds`, and the `apiKey` resolver live here; entity narrowing rides the minted session's `syncGroups`), then hand it to the provider: ```ts // lib/ablo.ts import Ablo from '@abloatai/ablo'; import { schema } from '@/ablo/schema'; // Build the client from the identity your server already resolved. // teamIds → team sync groups via identityRoles. export function makeAblo(user: { teamIds: string[] }) { return Ablo({ schema, // The browser holds no secret — `authEndpoint` points at the route that // mints the short-lived session token, and the client keeps it fresh // before expiry. authEndpoint: '/api/ablo-session', teamIds: user.teamIds, }); } ``` ```tsx // app/providers.tsx 'use client'; import { useMemo } from 'react'; import { AbloProvider } from '@abloatai/ablo/react'; import { makeAblo } from '@/lib/ablo'; export function Providers({ children, user, // { id, teamIds } — resolved server-side from YOUR auth }: { children: React.ReactNode; user: { id: string; teamIds: string[] }; }) { const ablo = useMemo(() => makeAblo(user), [user.id]); return ( }> {children} ); } ``` What carries identity — and just as importantly, what does *not* set the boundary: | Where | Purpose | | ------------ | ------------------------------------------------------------------------------------------------ | | `userId` prop | App-level participant id, used for app-owned fields and read by your `identityRole` `source`. **Not** the security boundary: the server enforces scope from the authenticated request. | | `teamIds` (on the client) | Team ids expanded into team sync groups via your `identityRoles`. | | `syncGroups` (at session mint) | Optional. **Narrows** a minted session's subscription to a subset of what auth already allows: it can never widen it. Passed to `sessions.create({ user \| agent, syncGroups })`; build entries with `syncGroup(kind, id)`. Use it to scope an agent (or a focused page's session) to one entity, e.g. `[syncGroup('workspace', 'abc123')]`. | Because the server is the boundary, a client that changes `userId` to another user's id does not gain their data — the server resolves and enforces the real identity on the connection. These are how your app *tells* Ablo who it already authenticated, not how it *proves* it. ## Agents are participants too An agent and a human **authenticate through the exact same path** — same proxy, same `identityRoles`, same server-enforced boundary. An agent is a participant; the only data difference is that it carries `kind: 'agent'` and an `agentId` where a human carries `userId`. There is no separate identity model to learn. What differs is **authority, not identity** — and the distinction is the whole point. An agent always runs *on behalf of* the user who set it off, so its **ceiling is exactly that user's access**: the same conversations, messages, and models the triggering user can reach, and nothing that user couldn't. But within that ceiling it is **narrowed to the model instances it is touching, or has touched** — never the user's whole org. Scope is therefore an intersection: ```txt agent authority = (triggering user's allowed set) ← ceiling, inherited (on-behalf-of) ∩ (the model instances it touches) ← floor, least privilege per run ``` Concretely: each model an agent edits declares a `scope` ([Half 2](#half-2-per-model-scope-row--group)), so each row forms its own group. The agent subscribes only to the groups for the rows it touches. Declare an entity anchor on the models an agent operates on: ```ts // each scope-root model an agent edits forms a per-entity group records: model({ /* … */ }, { groups: { root: 'document' } }), workspaces: model({ /* … */ }, { groups: { root: 'workspace' } }), ``` Then a run subscribes only to the entity groups for the rows it works on — a subset of what its user could see: ```ts // agent run triggered by `user`, working on one document + one workspace. // Your backend mints the agent session narrowed to just the entities in play // (the floor). Build each group from the model's scope with `syncGroup(kind, id)`. const session = await server.sessions.create({ agent: { id: agentId }, can: { Document: ['read', 'update'], Workspace: ['read', 'update'] }, syncGroups: [syncGroup('document', recordId), syncGroup('workspace', workspaceId)], }); // identity (the ceiling) is inherited from the triggering user via your // session-mint logic; the agent runtime connects with the minted token. const ablo = Ablo({ schema, apiKey: session.token }); ``` As the run touches more entities, claim or read them and the client auto-enrolls in their entity groups — its set **accretes** to cover them; it never widens past the user's ceiling, and it carries no standing access to entities it isn't working on. The `identityRoles` need no agent-specific entry: the agent carries the triggering user's `userId`, so the same `user:{id}` role that scopes a human already scopes the agent. Nothing about the *identity* declaration branches on agent vs human. `kind` is what attribution uses — not access. `kind: 'agent'` plus `agentId` is connection metadata that tags every write with the executing agent **and** the user it ran on behalf of, so audit answers "who did this, and on whose behalf." It never appears in an `identityRole`, because it changes *who's accountable*, not *what's reachable*. Three rules make agent access safe, and they fall out of the model above rather than needing a separate agent permission system: - **Inherit the user, and no more:** the OAuth [on-behalf-of](https://workos.com/blog/oauth-on-behalf-of-ai-agents) model: the agent's reach is tied to the consenting user, never the org. - **Least privilege, just-in-time:** scoped to the record's entities, not standing org-wide access (the over-privilege pattern [OWASP's NHI Top 10](https://www.token.security/assets/the-ultimate-non-human-identity-security-guide) flags as the dominant agent risk). - **Dual-principal attribution:** record both the executing agent and the triggering human. Identity is 1:1 with a human participant; authority is narrowed to the work. That split is what lets Ablo keep *one model API for every actor* without ever granting an agent standing access to everything its user can see. The agent that runs the [Coordination](./coordination.md) `claim` loop is, to the scoping layer, that same participant — scoped to the row it claimed. ## Narrowing to specific entities A human gets their full membership automatically (`identityRoles`). There are three ways to narrow a participant to specific entities — a page on one workspace, or an agent pointed at the entities it's working on. You **never hand-write** `workspace:`; build groups from the model's `scope` (Half 2) with the typed `syncGroup(kind, id)` helper from `@abloatai/ablo/schema`. 1. **At session mint — `syncGroups`.** When your backend mints a session, pass the exact groups it may subscribe to. This is the floor for a delegated agent (and the way to scope a focused page's session): ```ts // an agent working across two workspaces and a document const session = await server.sessions.create({ agent: { id: agentId }, can: { Workspace: ['read', 'update'], Document: ['read'] }, syncGroups: [ syncGroup('workspace', collectionA), syncGroup('workspace', collectionB), syncGroup('document', docId), ], }); const ablo = Ablo({ schema, apiKey: session.token }); ``` 2. **Automatically, on read or claim.** Reading a row (`get`/ `claim.state`) auto-enrolls the client in that row's entity group (**read-interest**), and `claim`-ing it pins a **write-intent** subscription. So an agent's reachable set **accretes** as it works — no extra subscribe call. 3. **Explicitly, for presence — `join`.** To hold presence on a known set of rows and react to peers, use the WebSocket-only `ablo..join(ids, { ttl })` (it returns a participant handle with `.peers`). See [Coordination](./coordination.md). > **`groups.root` is the schema model option, not a client setting.** > `groups: { root: 'workspace' }` in `model(...)` declares a scope root > ([Half 2](#half-2-per-model-scope-row--group)) — it names the group > (`workspace:`) that the mechanisms above then subscribe to. > There is no `Ablo({ scope })` constructor option. The lifecycle filter on > [`list()`](./api.md#model-methods) is a separate axis named **`state`** > (`'live' | 'archived' | 'all'`, GitHub's open/closed/all), precisely so it > doesn't share the word. > **Requested groups never grant.** At connect, the server intersects the session's > `syncGroups` with what the identity is actually allowed (`requested ∩ allowed`). > So `syncGroups` only ever *narrows* within a participant's ceiling — an agent > can't reach a workspace its capability doesn't already permit, no matter what it > passes. Smaller bootstrap, less fan-out, same server-enforced boundary. ## How this compares, and the best practices it follows Ablo's identity model is not novel; it's the convergent answer every serious realtime / sync SDK arrived at. Knowing which industry pattern it *is* tells you how to reason about it. **Realtime authorization splits into two shapes.** Ablo is firmly in the first: - **Server derives scope from authenticated identity:** the server decides what a participant may read/write and the client cannot override it. This is Ablo's proxy model. It's the same shape as [Supabase Realtime's RLS-on-connect](https://supabase.com/docs/guides/realtime/authorization) (policies evaluated at subscribe, cached for the connection), [Liveblocks **ID tokens**](https://liveblocks.io/docs/authentication) ("Liveblocks checks the permissions for you" — recommended for production), and [ElectricSQL **proxy auth**](https://electric-sql.com/docs/guides/auth) (a reverse-proxy sets shape params server-side before forwarding). - **Client proposes, server authorizes the exact request:** the client names the room/shape and the server signs off, as in [Pusher's channel authorization endpoint](https://pusher.com/docs/channels/server_api/authorizing-users/), [ElectricSQL **gatekeeper auth**](https://github.com/electric-sql/electric/blob/main/examples/gatekeeper-auth/README.md), and Liveblocks **access tokens**. Ablo's session-mint `syncGroups` is the *narrowing* half of this — but it can only ever shrink the server-derived set, never grow it. The best practices Ablo inherits from that lineage: 1. **The secret never reaches the client.** Your `ABLO_API_KEY` lives only on a trusted server — exactly as [Ably mandates](https://ably.com/docs/auth/token) ("never use API keys in client-side code; they don't expire, so once compromised they grant indefinite access") and [PowerSync's flow](https://docs.powersync.com/installation/authentication-setup/custom) (app auth → backend mints a signed token → client connects with the token). 2. **Trusted vs untrusted claims is the whole security argument.** PowerSync draws the line precisely: [token parameters are trusted and usable for access control; client parameters are not](https://docs.powersync.com/usage/sync-rules/advanced-topics/client-parameters). In Ablo terms, the identity your server vouches for — and the session's `syncGroups`, minted server-side — are the *trusted* claims that set scope; the `userId` prop is *untrusted client input* — convenient for app-owned fields, but never the boundary. This is why changing `userId` in the browser grants nothing. 3. **Scope by a hierarchical naming convention, declared once.** Ablo's `kind:id` group naming (`org:…` / `team:…` from `identityRoles`, `workspace:…` from a model's `scope`) is the same idea as [Liveblocks' recommended room-id naming pattern](https://liveblocks.io/docs/authentication/access-token) (`org:*`, `org:group:*`) and [Ably's channel capabilities](https://ably.com/docs/auth/capabilities). Declaring the convention in one place — never composing scope strings in consumer code — is the practice all three enforce. 4. **Attribution and presence ride the authenticated identity.** Just as [Pusher attaches `channel_data` to presence at auth time](https://pusher.com/docs/channels/server_api/authorizing-users/), Ablo's participant identity (the one your server vouched for) is what powers presence and per-write attribution — not a value the client asserts after the fact. The one practice that differs by deployment: short-lived, auto-refreshed bearer tokens ([Ably](https://ably.com/docs/auth/token), [Supabase's `access_token` refresh](https://supabase.com/docs/guides/realtime/authorization)) are the right shape when an untrusted client holds a credential directly. Ablo's proxy model keeps the credential server-side instead, so token rotation is the server's concern, not the browser's — the same trade ElectricSQL's proxy pattern makes versus its gatekeeper tokens. ## See also - [Integration Guide](./integration-guide.md) — `identityRoles`, backing modes, and the full app path. - [React](./react.md) — the complete `` prop surface. - [API Keys](./api-keys.md) — server-side keys for the public API. --- Source: https://www.abloatai.com/api/docs/index # Ablo Docs > Collaboration infrastructure for AI agents: one API for agents, apps, and services to claim, change, and confirm the same rows. Two agents reach for the same row. One claims it, does slow work — an LLM call, a fetch, a chain of tools — and commits. The second is neither rejected nor allowed to clobber: it waits in line, is handed the row as it now stands, and proceeds. Contention becomes an ordering problem instead of a retry loop. ```ts // Take the row. Anyone else who wants it waits, then reads it fresh. await using claim = await ablo.reports.claim({ id: reportId }); await ablo.reports.update({ id: claim.data.id, data: { forecast: await generateForecast(claim.data) }, }); ``` Claims do not lock. A lock is held against a caller who may never come back; a claim is a durable lease with a wait-line behind it, so you can always ask who holds a row and who is queued for it. The write returns a receipt, and a write based on a row that has since changed is turned away rather than applied. ## What people build Many agents over one dataset. Claims put them in a line instead of a race. One agent claims, works, releases. The next picks up with the fresh row and a durable note about why. A revocable key bound to one project's models. Attribution comes from the credential, not the call site. Every write returns a receipt. Nothing is fire-and-forget, and stale writes are rejected. Trace any committed change back to the key that made it, and to the person who authorized that key. Add the `humans()` plugin and people get presence and live queries. A person's claim is just another holder the agent waits behind. ## Using Ablo `npx ablo init` scaffolds `ablo/schema.ts`, the typed client, and the type registration. Declare only the models agents coordinate over — your auth, billing, and everything else stay in your own migrations. ```bash npx ablo init npx ablo dev ``` `dev` gives the current Git branch an isolated Ablo branch, wires its temporary key, pushes the schema, and watches for changes. Until the server has your schema, a write to a new model fails with `server_execute_unknown_model`. See [Branch-first development](./branch-development.md). Ablo writes through a scoped role and confirms by tailing your write-ahead log. It runs no DDL and owns no schema — your migration tool stays in charge of the shape of your database. ```bash npx ablo connect ``` No database yet? Pass an `apiKey` only and Ablo keeps the rows in its own log, so you can build the whole system today and point it at Postgres when you are ready. You are writing the agent yourself — a worker, a job handler, a tool inside a model loop. Agents hold no socket; the credential is the identity. ```ts const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, transport: 'http' }); ``` Read with `list` / `get`, coordinate with `claim`, write with `create` / `update` / `delete`. See [Agents](./agents.md) for the loop and [API Reference](./api.md) for the shape. The agent is Claude, Cursor, or another MCP host, and you want it operating your data directly. The coordination server exposes the same claim-and-commit loop as tools. ```bash claude mcp add ablo -- npx -y @abloatai/mcp ``` See [Model Context Protocol](./mcp.md) — and read the surface table below before you pick, because Ablo publishes two MCP servers and only one of them is a data plane. ## Surfaces Every surface reaches the same coordinated state. Pick by who is calling. | Surface | Use it for | |---|---| | **SDK**: `@abloatai/ablo`, `transport: 'http'` | The agents themselves. Stateless, request/response, nothing held open. The main path. | | **Coordination MCP**: `@abloatai/mcp` | An agent living inside an MCP host that needs claim and commit as tools. A data plane. | | **`humans()`**: with `@abloatai/ablo/react` | The interfaces a person watches agent work arrive in: presence, live queries, a local copy. | | **CLI**: `ablo` | Scaffolding, schema push, connecting a database. Terminals and CI. | | **REST**: `/api/v1` | Runtimes with no SDK. | | **Integration-helper MCP**: hosted `/api/mcp` | Teaching a coding assistant the SDK while you build. Docs, lint, and scaffolds only. | The two MCP servers are not interchangeable. The coordination server changes your data; the integration-helper server serves documentation and has no per-model data tools at all. An agent that edits rows uses the SDK or the coordination server — never the helper. ### Where people fit The bare client is the coordination layer: commit, read, observe, claim. People are something you add to it. `humans()` is the plugin that declares the local, watchable copy — the offline store, live queries, presence, and the framework bindings — and it needs a duplex connection, so a stateless agent cannot install it and is told so at construction rather than left with a subscription that never delivers. There is no `agents()` plugin, and the absence is the point: agents are the default caller, not a special one. ## Concepts - [How Ablo Works](./how-it-works.md) — the mental model in one page: you write through Ablo, it lands in your Postgres, the write-ahead log confirms it. **Read this first.** - [Coordination](./coordination.md) — `claim`, `claim.state`, and `claim.queue`: who holds a row, and who is waiting. - [Concurrency Convention](./concurrency-convention.md) — the precise rule for guarded and unguarded writes. - [Guarantees](./guarantees.md) — what a confirmed write, a stale-write rejection, and a claim each promise. - [Idempotency](./idempotency.md) — make a retried write safe; what replays, what re-runs, and for how long. - [Schema Contract](./schema-contract.md) — one schema becomes typed clients, agent writes, React reads, and the push. - [Agents](./agents.md) — the stateless participant: wake, read, claim, commit, idle. - [Agent Messaging](./agent-messaging.md) — durable handoffs between agents, linked to the claim they discuss. - [Identity & Sync Groups](./identity.md) — who is connecting, and which slice of state they see. - [Change Propagation](./groups.md) — how one row's change reaches the actors that depend on it. - [Client Behavior](./client-behavior.md) — options, errors, retries, timeouts, and imports. ## Authority - [Projects](./projects.md) — one organization, many apps; each with its own schema, planes, and keys. - [API Keys](./api-keys.md) — the credential that carries an agent's identity and its scopes. - [Sessions](./sessions.md) — short-lived scoped credentials your backend mints. - [Customer Organizations](./customer-organizations.md) — serve many isolated customer organizations from one schema and backend. - [Audit Log](./audit.md) — trace any confirmed write back to the person behind it. - [Operating on Your Database](./operating-on-your-database.md) — which actions run freely, which to verify first, and which belong to a human. - [Session Settings](./session-settings.md) — point your row-level-security policies at Ablo's writes, by naming the settings they already read. ## Build - [Quickstart](./quickstart.md) — make your first coordinated write. - [Integration Guide](./integration-guide.md) — the canonical end-to-end integration. - [Integrations](./integrations.md) — long-running records, ingestion, and other application-edge runtimes. - [CLI & Migrations](./cli.md) — `init` / `connect` / `push` / `migrate` / `generate`. - [Connect Your Database](./data-sources.md) — where rows land when your own database is canonical. - [Deployment](./deployment.md) — the database, the keys, and the schema push that take an integration to production. - [React](./react.md) — provider, hooks, and reactive reads. - [Webhooks](./webhooks.md) — react to confirmed change from outside the SDK. - [Debugging & Logs](./debugging.md) — watch claims, queueing, and grants while you build. ## Reference - [API Reference](./api.md) — model-by-model method shape. - [Errors](./errors.md) — the code registry, its categories, and what to do about each. - [Upgrade Guide](./migration.md) — upgrade a pinned pre-1.0 SDK safely. - [Changelog](../CHANGELOG.md) — what shipped recently. ## Examples - [AI SDK Tool](./examples/ai-sdk-tool.md) — put Ablo inside a model's tool call. - [Agent + Human](./examples/agent-human.md) — yield when a person is holding the same report. - [Server Agent](./examples/server-agent.md) — a schema-backed worker. - [Existing Python Backend](./examples/existing-python-backend.md) — add coordination without replacing your API server. - [Next.js](./examples/nextjs.md) — app-router setup with React bindings. ## More - [README](../README.md) — product overview and first example. - [AGENTS.md](../AGENTS.md) — installation guidance for coding assistants. --- Source: https://www.abloatai.com/api/docs/integration-guide # Integration Guide > The canonical end-to-end integration, added to an existing product one model at a time. When several AI agents edit the same records in your app — alongside any people watching them work — they overwrite each other, and there is no good place to coordinate. Ablo gives them one shared, typed write path: the same `ablo..update(...)` call from an agent, a background worker, a server action, or a React component. This guide adds it to a product that already has a backend and a database, one model at a time. Three things hold no matter which actor is writing: - **One model API for every actor:** `ablo..update(...)` is what React components, server actions, background workers, and AI agents all call. No separate "agent SDK," no parallel mutation path. The attribution comes from the credential, not the call site. - **You never type `org:123` in client code.** The server derives what each caller can see from their authenticated identity, using the `identityRoles` you declare once in the schema. The client just names which model and id it wants. The `org:` / `user:` / `team:` (or your own `region:` / `customer:`) prefixes live in the schema, never in consumer code. - **Agents don't use your account API key.** Each agent run gets a short-lived credential scoped to just what that run can touch, verified per request and revocable instantly. (See the Agents section below for the actual calls.) ## The integration in one diagram The normal integration is one client: ```ts import Ablo from '@abloatai/ablo'; import { credentialEndpointSuccessSchema } from '@abloatai/ablo/auth'; import { defineSchema, model, z } from '@abloatai/ablo/schema'; ``` Declare the models Ablo coordinates, then read and write through `ablo.`. React, server actions, backend workers, and agents should all use that same model path. ```txt schema -> ablo..list(...) -> ablo..update(...) ``` Commits and receipts exist under the hood. Most apps do not create protocol objects by hand. ## Your Database Every schema model is backed by **your own database**. The SDK call shape is the same everywhere. In this guide — an app that already owns its backend and database — keep the database credentials inside your server runtime and connect out of band: run `npx ablo connect` to set up logical replication and a scoped writer role, or expose a signed Data Source endpoint when your database can't grant replication. Either way, you write through `ablo.`; Ablo lands each change in your Postgres and confirms it over the WAL. Application and agent code hold only `ABLO_API_KEY` — the client never sees a connection string. [Connect Your Database](./data-sources.md) is the single source of truth for both paths. ## Try the public sandbox demo Use the public `/sandbox` page to understand the state flow. It is a visual, deterministic demo; it does not call your API key or mutate hosted Ablo data. It is also built for coding agents: copy the sandbox prompt into Claude Code or Codex and ask it to wire one real model through the schema model API. Use `npx ablo dev` for real integration work. It derives an immutable branch from Git, inherits the parent schema, and writes a temporary branch credential to `.env.local`. Each developer or pull request gets independent schema, rows, claims, and logs. Use an explicit `sk_*` root credential only in the reviewed production deployment. When handing this to a coding agent, give it a concrete target: ```txt Add Ablo to this app for one model your agents edit. Run npx ablo dev and use its branch-bound key. Declare schema, add the Ablo client, replace one write with ablo..update(..., { readAt, onStale: 'reject' }), and add a smoke test for two concurrent writers. ``` ## 1. Declare A Schema Start with fields and relations. Keep load strategies, indexing hints, and read-only/mutable shortcuts out of the first version unless you already need them. ```ts // src/ablo/schema.ts import { defineSchema, model, z } from '@abloatai/ablo/schema'; export const schema = defineSchema( { weatherReports: model({ // Reserved fields (id, createdAt, updatedAt, organizationId, createdBy) // are SDK-provided automatically — never declare them. Declare only your // own fields. projectId: z.string(), location: z.string(), status: z.enum(['pending', 'ready']), assigneeId: z.string().nullable(), }), }, { // Identity-anchored sync-group roles. The server walks these to build each // participant's allowed subscription set from the resolved identity context. // `kind` is the group prefix; `source` is the identity field to read — both // consumer-controlled, no hardcoded `org:` / `user:` convention anywhere in // the engine. Pure data (no closures), so the schema stays JSON-serializable. // Omit `identityRoles` entirely if you don't need identity-derived scoping. identityRoles: [ identityRole({ kind: 'org', source: 'organizationId' }), identityRole({ kind: 'user', source: 'userId' }), ], } ); ``` ### Declaring scope on a model > **Canonical reference: [Identity & Sync Groups](./identity.md).** This is the > short version — `scope` (root), `parent` (containment), `grants` (membership), > and the model-form `scope` prop are all covered in depth there. Read it once; > this guide only shows the minimal shape inline. Per-row tenancy and per-entity sync-group anchors live on the `model(...)` options. The two halves compose: the identity roles above produce a participant's _allowed_ set; the per-model options below define how rows are filtered server-side and which sync-group each row fans out on. ```ts model( { /* fields */ }, { // Axis 1 — `policy`: who may READ a row (tenant isolation / RLS). A // row-local `organization_id` column is the default, so you omit this for // normal tables; set it only for the exceptions (parent-inherited / global). // Axis 2 — `groups`: which sync-group CHANNELS a row fans into. // Scope root: rows form the group `matter:`. Children point at it with // `relation.belongsTo('matters', 'matterId', { parent: true })` to inherit. groups: { root: 'matter' }, } ); ``` For rows that don't carry `organization_id` themselves but inherit tenancy via a foreign key, set `policy: { by: 'parent', fk: '', parent: '' }`. For genuinely global/reference data, `policy: { by: 'none' }`. ⚠ `by: 'none'` exposes the whole table cross-tenant, so it's an explicit, named branch — never a falsy flag. See `packages/transaction/src/schema/model.ts` for the full option set. ## 2. Create The Client Trusted runtimes can use `ABLO_API_KEY`. ```ts // src/ablo.ts import Ablo from '@abloatai/ablo'; import { schema } from './ablo/schema'; export const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, }); ``` Browser apps should use the React provider or a scoped session token, not a server API key in the bundle. Build the client first, then hand it to the provider — `AbloProvider` takes `{ client, userId?, onError?, fallback? }`, and nothing else (`schema`, `teamIds`, and `apiKey` all live on the client now). ```tsx // src/ablo-client.ts import Ablo from '@abloatai/ablo'; import { schema } from '@/ablo/schema'; // The browser never holds the API key. The client mints a short-lived token // from your session route (see below) and refreshes it before expiry. export const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session', }); ``` ```tsx // app/providers.tsx 'use client'; import { AbloProvider } from '@abloatai/ablo/react'; import { ablo } from '@/ablo-client'; export function Providers({ children }: { children: React.ReactNode }) { return {children}; } ``` The session route mints the scoped token server-side, where the API key lives: ```ts // app/api/ablo-session/route.ts import Ablo from '@abloatai/ablo'; import { schema } from '@/ablo/schema'; import { auth } from '@/auth'; export const runtime = 'nodejs'; const sync = Ablo({ schema, apiKey: process.env.ABLO_API_KEY }); export async function POST() { const session = await auth(); // your own auth — returns the signed-in user const { token, expiresAt } = await sync.sessions.create({ user: { id: session.userId }, can: { records: ['read', 'update'] }, }); return Response.json( credentialEndpointSuccessSchema.parse({ token, expiresAt, credentialKind: 'ephemeral', }), { headers: { 'Cache-Control': 'no-store' } }, ); } ``` ### Why two credential shapes `ABLO_API_KEY` is your long-lived account credential. It stays on trusted servers, never reaches a browser bundle, and signs server-to-server requests. It is the right credential for trusted runtimes (Next.js server actions, background workers, migration scripts) where the code reading it is yours. A browser is not that environment. The React provider exchanges your API key for a short-lived, narrowly scoped bearer token. The browser holds that scoped token; the API key never leaves the server. The exchange is the bridge between two credential shapes: ``` trusted runtime browser / agent ABLO_API_KEY ─exchange─► scoped token ────────► narrow scope, leased (long-lived, (short-lived, broad scope, per-actor scope, server only) revocable) ``` You never type that token into your app; the SDK mints a time-bounded, minimally scoped token when it needs one and refreshes before expiry. ## 3. Read State Reads come in two flavors, and you pick based on whether you can wait. `get({ id })` and `list({ where })` hit the server (and hydrate the local store) — they're async, so you `await` them. `local.get(id)`, `local.list({ where })`, and `local.count({ where })` read the already-synced local graph synchronously, so they're the ones you call in render — and the ones you use inside a `useAblo` selector, never the async `get`/`list`. Use `get` when the row may not be local yet — it fetches from the server and waits. ```ts await ablo.ready(); const report = await ablo.weatherReports.get({ id: 'report_stockholm' }); if (!report) throw new Error('report not found'); ``` Use `local.get`, `local.list`, and `local.count` for synchronous local-graph reads after data has synced. ```ts const report = ablo.weatherReports.local.get('report_stockholm'); const activeReports = ablo.weatherReports.local.list({ where: { projectId: 'proj_123' }, filter: (report) => report.status !== 'ready', orderBy: { updatedAt: 'desc' }, limit: 50, }); ``` In React, selector `useAblo` is the public read API: ```tsx 'use client'; import { useAblo } from '@abloatai/ablo/react'; export function ReportRow({ report: serverReport, }: { report: { id: string; location: string; status: string }; }) { const report = useAblo((ablo) => ablo.weatherReports.local.get(serverReport.id)) ?? serverReport; const active = useAblo((ablo) => ablo.weatherReports.claim.state({ id: serverReport.id })); return ; } ``` Use zero-argument `useAblo()` only in callbacks and effects: ```tsx const ablo = useAblo(); ``` ## 4. Write State For simple writes: ```ts await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' } }); ``` For writes based on state the user or agent already read, snapshot first and reject stale updates: ```ts const snap = ablo.snapshot({ weatherReports: 'report_stockholm' }); await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' }, readAt: snap.stamp, onStale: 'reject', }); ``` The local row changes optimistically at once. Awaiting the model write waits for authoritative confirmation; a rejection rolls the optimistic state back and throws a typed `AbloError`. ## 5. Multiplayer Is Automatic There is no separate multiplayer setup. If humans, server actions, and agents use the same schema client, they share the same stream: ```txt human UI -> ablo.weatherReports.update(...) agent -> ablo.weatherReports.update(...) server -> ablo.weatherReports.update(...) ``` Ablo coordinates those writes, fans out confirmed deltas, exposes active claims, and lets callers reject stale writes with `readAt`. A write that reaches your database some other way still reaches connected clients. A `psql` session, a cron job, an admin tool, a legacy endpoint: Ablo tails your write-ahead log, so a change it did not make is picked up and fanned out like any other, attributed to the data source rather than to an agent. What such a write does not get is the coordination. It never entered the commit chokepoint, so no claim was checked, no `readAt` was compared, and no idempotency key was honoured. It can land on top of a row an agent is holding. Route anything that must respect a claim through `ablo.`. On the [Data Source endpoint fallback](./data-sources.md) there is no replication stream to tail, and there the original caveat holds: a direct write stays invisible until your app reports it through Data Source events. ## 6. Existing API Backend This is the path for a product where buttons already call Python, Rails, Go, or Node endpoints. Keep your backend and database canonical. Add Ablo as the shared write path for the records that need multiplayer now and agent-safe writes later. ```txt Button -> ablo.weatherReports.update(...) -> Ablo -> signed Data Source request -> existing backend service -> app database -> Ablo realtime fanout ``` The migration can be gradual: 1. Declare schema for one model, such as `reports`. 2. Keep existing server loads for first paint. 3. Add `useAblo((ablo) => ablo.weatherReports.local.get(id)) ?? serverReport` for live rows. 4. Add one Data Source endpoint that calls the existing service layer. 5. Move one mutation button from `fetch('/api/reports/...')` to `ablo.weatherReports.update(...)`. 6. Add an outbox/events path for writes that still happen outside Ablo. 7. Let agents use the same `ablo.weatherReports.list(...)` and `ablo.weatherReports.update(...)`. For the full Python shape, see [Existing Python Backend](./examples/existing-python-backend.md). ## 7. Data Source Endpoint Use a Data Source when your app database remains the source of truth. Wire the route with `dataSourceNext` and an adapter — `prismaDataSource(prisma, schema)` or `drizzleDataSource(db, schema)`. You don't hand-write `commit`; the adapter owns transactional commit, idempotency, and reads. ```ts // app/api/ablo/source/route.ts import { dataSourceNext } from '@abloatai/ablo/source/next'; import { prismaDataSource } from '@abloatai/ablo/source'; import { schema } from '@/ablo/schema'; import { prisma } from '@/db'; export const runtime = 'nodejs'; export const { POST } = dataSourceNext({ schema, apiKey: process.env.ABLO_API_KEY!, adapter: prismaDataSource(prisma, schema), }); ``` With Drizzle, pass `drizzleDataSource(db, schema)` instead — the adapter takes your Drizzle `db` and the Ablo `schema` (not your table objects): ```ts import { drizzleDataSource } from '@abloatai/ablo/source/drizzle'; export const { POST } = dataSourceNext({ schema, apiKey: process.env.ABLO_API_KEY!, adapter: drizzleDataSource(db, schema), }); ``` Ablo needs your Data Source endpoint and API key. Your app stores one Ablo credential: ```bash ABLO_API_KEY=sk_... ``` The API key verifies Ablo's request. It is not a database credential. ## 8. Agents Agents should use the same model methods as the app when they can import the schema. An agent often reads a row, calls an LLM, then writes back — a slow gap during which a human might touch the same row. Wrap that work in a claim. Claims don't lock. If another writer holds the row, `claim` waits for them, re-reads the fresh row, then hands it to you — so two writers serialize instead of clobbering. A claim is a disposable handle (`await using`), not a callback: read the fresh row off `claim.data`, do your work, and the handle auto-releases when it leaves scope. ```ts await using claim = await ablo.weatherReports.claim({ id: reportId, description: 'forecasting', }); const claimed = claim.data; if (!claimed) return; await ablo.weatherReports.update({ id: claimed.id, data: { status: 'ready', forecast: await getForecast(claimed) }, }); ``` Use AI SDK for the model loop. Put Ablo inside the tool that persists the final change. ```ts const completeReport = tool({ description: 'Mark a weather report ready with a forecast', inputSchema: z.object({ reportId: z.string(), forecast: z.string(), }), execute: async ({ reportId, forecast }) => { const snap = ablo.snapshot({ weatherReports: reportId }); return ablo.weatherReports.update({ id: reportId, data: { status: 'ready', forecast }, readAt: snap.stamp, onStale: 'reject', }); }, }); ``` Keep agent writes on the same schema client surface as the app. ## One command changes an Ablo model and an ORM-only table Two independently committed calls are not one atomic command. If an Ablo write lands and a following Prisma/Drizzle transaction fails—or the reverse—the application must expect and repair the partial result. Calling that path “coordinated” does not extend Ablo’s claims, stale-read checks, attribution, or commit ordering into the ORM transaction. The supported atomic answer is to model every invariant-bearing row in the Ablo schema and submit the operations in one `commits.create` batch (the HTTP equivalent is `POST /api/v1/commits`). This applies on both database paths: - With direct logical replication, Ablo’s batch is one customer-database transaction. A separate ORM transaction is still separate. - With a signed Data Source endpoint, the adapter applies the Ablo batch, idempotency record, and outbox entry in one customer-database transaction. Unrelated ORM work outside that adapter is still separate. There is no general transactional callback that can safely splice arbitrary application SQL into the hosted direct-write path. If a table must remain ORM-only, treat the command as a saga: give both steps the same durable business operation id, make each step idempotent, record progress, retry unfinished steps, and define compensation for a result that cannot be completed. State that guarantee as eventual completion with repair—not atomicity. ## Optional Surface | Optional piece | Why it exists | | ----------------------------------------- | ----------------------------------------------------------------- | | `/react` | Live React selectors, provider lifecycle, presence, sync status. | | `/testing` | Test harnesses and deterministic mocks. | | `Data Source` | Keep your app database canonical. | | `persistence: 'indexeddb'` | Durable browser cache that survives reloads, for apps that need it. | | `durableWrites: { store, namespace? }` | Recover unacknowledged worker writes after a process restart. | | `claim` / `claim.state` / `claim.queue` | Show active work and coordinate before a write. | | `snapshot` + `readAt` | Reject writes based on stale state. | | `mutable`, `readOnly`, `field`, `indexed` | Advanced schema and read tuning. | The first integration should not need most of these. Start with schema and model methods, then add the optional pieces where the product actually needs them. ## Method Cheatsheet | Method | Use it for | | -------------------------------------- | -------------------------------------------------------------------------------- | | `get({ id })` | Async read of one row from the server (await it). | | `list({ where })` | Async read of many rows from the server (await it). | | `listAll({ where, maxPages?, signal? })` | Explicit bounded traversal of every matching page; filter before collecting. | | `local.get(id)` | Synchronous local read of one synced row (use in render). | | `local.list({ where })` | Synchronous local read of many synced rows. | | `local.count({ where })` | Synchronous local count of synced rows. | | `create({ data, id? })` | Create through the model client. | | `update({ id, data, ...opts })` | Update through the model client. | | `delete({ id, ...opts })` | Delete through the model client. | | `claim.state({ id })` | See who is currently working on a row (synchronous). | | `claim({ id, description?, ttl? })` | Acquire a disposable handle: wait for your turn, re-read, and hold the row. | Keep first integrations on the model methods above. Every mutation and server-read verb takes one options object; the synchronous `local.get(id)` stays positional. --- Source: https://www.abloatai.com/api/docs/integrations # Integrations > Compose Ablo's authoritative shared-state operations with the runtimes that > execute, schedule, or ingest work in your application. Integrations live at the application edge. Ablo continues to own typed reads and writes, idempotency, claims, and authoritative confirmation; the external runtime keeps owning the job it was designed for. [Context](./context.md) is the small composition point for values returned by those systems. It carries Ablo read evidence without turning an external result into authoritative application state. | Category | Integration | Status | Use it for | |---|---|---|---| | Long-running records | [Temporal](./integrations/temporal.md) | Available | Durable Workflows, Activity retries, timers, cancellation, and durable AI SDK calls | | Long-running records | [Inngest](./integrations/inngest.md) | Available | Event-driven durable functions, retriable steps, flow control, and checkpointed AI SDK calls | | Data ingestion | Connector runtimes | Planned | Bringing external data into Ablo-backed models without creating a second write authority | An integration gets its own guide when there is runnable application code and the boundary has been tested. A dedicated package comes later still: only repeated production integrations that reveal substantial reusable behavior justify adding another public runtime dependency. ## Long-running records Use [Temporal](./integrations/temporal.md) when work must survive process failure, retry Activities, wait on timers, or preserve a durable model loop. Temporal owns execution history. Each tool or Activity that touches shared state calls the branded Ablo model API at the application edge. The complete example lives in `examples/temporal-agent` and includes retry, replay, cancellation, claim release, and Workflow-bundle tests. Use [Inngest](./integrations/inngest.md) when durable work starts from events and should be expressed as independently retriable steps behind an application HTTP endpoint. Inngest owns event delivery, step checkpoints, and flow control. Each `step.run()` that touches shared state calls the same branded Ablo model API used elsewhere in the application. The complete example lives in `examples/inngest-agent` and includes confirmed-write response-loss replay, key/body conflict, checkpoint reuse, claim cleanup, and endpoint-discovery tests. ## Data ingestion Data ingestion is separate from the Inngest durable-execution integration. A connector or ingestion runtime belongs in this section when its public contract exists. The same ownership rule will apply: - the ingestion runtime owns connectors, polling, checkpoints, parsing, and backpressure; - Ablo owns validated model writes, idempotency, coordination, and confirmed outcomes; - the consuming application owns field mapping and business policy. Until that contract and a runnable example exist, this page records the category without publishing placeholder imports or configuration. --- Source: https://www.abloatai.com/api/docs/integrations/inngest # Inngest for long-running records > Run event-driven, retryable agent records with Inngest while Ablo makes each > shared-state effect typed, idempotent, and authoritative. Inngest and Ablo solve different parts of a durable agent system: | Concern | Owner | |---|---| | Events, step checkpoints, retries, sleeps, flow control, cancellation | Inngest | | Provider calls, prompts, and model output | AI SDK | | Typed reads and writes, mutation idempotency, claims, confirmation | Ablo | | Event names, function definitions, effect identity, business behavior | Your application | The short version is: > Inngest makes sure the job resumes. Ablo makes sure its changes are safe. ## Keep Ablo calls inside steps An Inngest function is ordinary application code, but external I/O belongs in a retriable `step.run()`. A successful step result is checkpointed and reused; a failed step is independently retried. ```ts import Ablo from '@abloatai/ablo'; import { Inngest, eventType } from 'inngest'; import { z } from 'zod'; import { schema } from './schema.js'; const inngest = new Inngest({ id: 'orders' }); const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, transport: 'http', }); const approvalRequested = eventType('orders/approval.requested', { schema: z.object({ operationId: z.string(), orderId: z.string(), approvalNote: z.string(), }), }); export const approveOrder = inngest.createFunction( { id: 'approve-order', triggers: [approvalRequested], retries: 4, idempotency: 'event.data.operationId', }, async ({ event, step }) => { const { operationId, orderId, approvalNote } = event.data; return step.run('write-approved-order', () => ablo.orders.update({ id: orderId, data: { status: 'approved', approvalNote }, idempotencyKey: `${operationId}:approve-order:${orderId}`, }), ); }, ); ``` There is no Inngest-specific Ablo transport. The step calls the same branded model API as a route handler, worker, or command-line process. ## Use both idempotency layers Send a globally scoped event ID and carry a stable business operation ID in the event: ```ts await inngest.send( approvalRequested.create( { operationId, orderId, approvalNote: 'Approved by the durable function.', }, { id: `orders-approval-requested-${operationId}`, }, ), ); ``` Inngest event IDs and function-level `idempotency` prevent duplicate function runs for Inngest's documented 24-hour window. They do not replace the Ablo mutation key. Ablo can confirm the write and the process can lose the response before Inngest checkpoints the step, causing the step callback to execute again. For every mutating step: 1. Accept a stable business operation ID in the triggering event. 2. Derive a separate key for each logical effect. 3. Reuse the key and mutation body for every retry. 4. Await every model write before a later step depends on its authoritative result. 5. Treat reuse of a key with another body as an application bug. 6. Keep the complete retry horizon within Ablo's documented [idempotency retention window](../idempotency.md). Never put Inngest's `attempt`, a timestamp, or a random value in the key. `runId` is suitable only when a newly created function run should represent a new effect. ## Compose AI SDK as durable steps Inngest can checkpoint AI SDK calls with `step.ai.wrap()`. Keep the model call and the Ablo mutation as separate steps: ```ts import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; const { text: approvalNote } = await step.ai.wrap( 'review-order', generateText, { model: openai('gpt-4o-mini'), system: 'Return only a concise approval note suitable for an audit trail.', prompt, }, ); await step.run('apply-order-review', () => ablo.orders.update({ id: orderId, data: { status: 'approved', approvalNote }, idempotencyKey: `${operationId}:apply-order-review:${orderId}`, }), ); ``` This separation means retrying a write does not spend model tokens again, and retrying a model call cannot partially mutate shared state. For a multi-turn agent loop, checkpoint every model invocation and wrap every tool execution in its own `step.run()`. The generic `@abloatai/ablo/ai-sdk` helpers may execute inside a step because Inngest does not impose Temporal's deterministic Workflow sandbox. Explicit model calls are still preferable in the first integration because they make the effect key and step boundary visible. ## Claims and cancellation A claim protects a short shared-state critical section, not an entire Inngest function. Acquire, fresh-read, checked-write, and release inside one `step.run()`: ```ts await step.run('apply-review-with-claim', async () => { const claim = await ablo.orders.claim({ id: orderId, description: 'applying an Inngest review', }); try { return await ablo.orders.update({ id: claim.data.id, data: { status: 'approved', approvalNote }, claim, idempotencyKey: `${operationId}:apply-review:${orderId}`, }); } finally { await claim.release(); } }); ``` Do not hold a claim across `step.sleep()`, `step.waitForEvent()`, or an AI inference step. Configure `cancelOn` for the function, but do not assume that function cancellation aborts an already running Ablo network request. Keep claims short, release in `finally`, and rely on bounded claim leases for process-loss recovery. ## Expose the application endpoint Mount Inngest's handler at the conventional `/api/inngest` path. For a plain Node application: ```ts import { createServer } from 'node:http'; import { serve } from 'inngest/node'; const handler = serve({ client: inngest, functions: [approveOrder], }); createServer((request, response) => { const path = new URL(request.url ?? '/', 'http://localhost').pathname; if (path === '/api/inngest') return handler(request, response); response.writeHead(404).end(); }).listen(3000); ``` Use the framework-specific `serve` adapter in an existing Next.js, Express, Hono, or other supported application. Start the local app and Inngest Dev Server: ```bash npm run dev npx --ignore-scripts=false inngest-cli@latest dev \ --no-discovery \ -u http://localhost:3000/api/inngest ``` Set `INNGEST_EVENT_KEY` and `INNGEST_SIGNING_KEY` in production. ## Test the failure boundary Use `@inngest/test`'s `InngestTestEngine` for function and step tests. A useful integration suite proves: - the Ablo write happens inside the expected named step; - a confirmed write followed by simulated response loss retries with the same key and body and commits once; - conflicting reuse of a key fails; - checkpointed step state bypasses the Ablo callback; - claims release after write failure; and - the `/api/inngest` endpoint exposes function metadata without eagerly creating an Ablo connection. The complete runnable suite lives in `examples/inngest-agent`. ## Package boundary Inngest remains an application dependency: ```text packages/ transaction/src/ai-sdk/ reusable model-backed AI SDK tools ablo/src/ai-sdk.ts @abloatai/ablo/ai-sdk examples/ inngest-agent/ events, functions, steps, endpoint, AI composition ``` Do not add Inngest to `packages/agent`. A dedicated `@abloatai/inngest` package is justified only after multiple real applications reveal substantial reusable behavior beyond a small function or step wrapper. ## References - [Inngest TypeScript SDK v4](https://www.inngest.com/docs/reference/typescript/v4/intro) - [Inngest steps](https://www.inngest.com/docs/reference/typescript/functions/step-run) - [Inngest idempotency](https://www.inngest.com/docs/guides/handling-idempotency) - [Inngest AI inference steps](https://www.inngest.com/docs/features/inngest-functions/steps-workflows/step-ai-orchestration) - [Testing Inngest functions](https://www.inngest.com/docs/reference/typescript/v4/testing) --- Source: https://www.abloatai.com/api/docs/integrations/temporal # Temporal for long-running records > Run long-lived, retryable agent records with Temporal while Ablo makes each > shared-state effect typed, idempotent, and authoritative. Temporal and Ablo solve different parts of a durable agent system: | Concern | Owner | |---|---| | Workflow history, replay, timers, retries, cancellation | Temporal | | Provider, messages, tools, approval, model loop | AI SDK | | Typed reads and writes, idempotency, claims, confirmation | Ablo | | Workflow names, record queues, retry policy, business behavior | Your application | The short version is: > Temporal makes sure the agent finishes. Ablo makes sure its changes are safe. ## Keep Ablo calls in Activities Temporal Workflow code must be deterministic. An Ablo model operation performs network I/O, so instantiate `Ablo()` and call model resources in an Activity: ```ts // activities/orders.ts import Ablo from '@abloatai/ablo'; import { schema } from '../schema.js'; const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, transport: 'http', }); export interface ApproveOrderInput { orderId: string; approvalNote: string; idempotencyKey: string; } export async function approveOrder(input: ApproveOrderInput) { return ablo.orders.update({ id: input.orderId, data: { status: 'approved', approvalNote: input.approvalNote, }, idempotencyKey: input.idempotencyKey, }); } ``` The Activity uses the same branded model method as a route handler, job, or command-line process. There is no Temporal-specific Ablo transport. Workflow code creates the stable logical effect identity and schedules the Activity: ```ts // workflows/approve-order.ts import { proxyActivities, workflowInfo } from '@temporalio/workflow'; import type * as activities from '../activities/orders.js'; const { approveOrder } = proxyActivities({ startToCloseTimeout: '1 minute', retry: { maximumAttempts: 5 }, }); export async function approveOrderWorkflow(orderId: string) { const { workflowId, runId } = workflowInfo(); return approveOrder({ orderId, approvalNote: 'Approved by the durable workflow.', idempotencyKey: `${workflowId}:${runId}:approve-order:${orderId}`, }); } ``` Temporal may retry an Activity after the Ablo write succeeded but before the result reached Temporal. Every attempt must therefore send the same `idempotencyKey` and the same mutation body. Do not put an Activity attempt number, timestamp, or random value in the key. Using the Workflow Run ID is appropriate when a new run should represent a new effect. If an effect must survive Continue-As-New or a newly started Workflow run, accept a stable business operation ID as Workflow input instead. ## Compose AI SDK tools through Activities Temporal's AI SDK integration can make the model interaction durable inside a Workflow. Any tool that touches Ablo still delegates to an Activity: ```ts import { temporalProvider } from '@temporalio/ai-sdk/workflow'; import { generateText, stepCountIs, tool } from 'ai'; import { z } from 'zod'; const result = await generateText({ model: temporalProvider.languageModel('gpt-4o-mini'), prompt, tools: { approveOrder: tool({ description: 'Approve the order after reviewing it.', inputSchema: z.object({ approvalNote: z.string() }), execute: ({ approvalNote }) => approveOrder({ orderId, approvalNote, idempotencyKey: `${effectPrefix}:approve-order:${orderId}`, }), }), }, stopWhen: stepCountIs(5), }); ``` Register Temporal's `AiSdkPlugin` on the Worker and pin all `@temporalio/*` packages to one compatible version set. The integration is experimental, so replay-test Workflow histories before upgrading it. The import above matches the example's pinned `1.21.1` release. Follow the matching Temporal upgrade guide when changing that version set. Do not import `@abloatai/ablo/ai-sdk` into Workflow code. Its `readTool`, `createTool`, `updateTool`, and `deleteTool` helpers execute Ablo model operations directly and are intended for ordinary Node.js AI SDK loops. In a Temporal Workflow, define the tool at the application edge and make its `execute` function call a proxied Activity. ## Idempotency rules For every mutating Activity: 1. Create the key in deterministic Workflow code or accept a stable operation ID in the Workflow input. 2. Reuse the key and mutation body for every retry of the same logical effect. 3. Await each model write before later Workflow steps depend on the authoritative database result. 4. Treat reuse of a key with a different body as an application bug. 5. Keep the Temporal retry horizon within Ablo's documented [idempotency retention window](../idempotency.md). This gives effective-once composition for the advertised retention window. It does not make an external side effect physically execute only once; Temporal Activities are intentionally retryable. ## Claims and cancellation A claim protects a short shared-state operation, not an entire Workflow history. Keep claim acquisition, the fresh read, the checked write, and release inside one Activity: ```ts import { Context } from '@temporalio/activity'; export async function applyReview(orderId: string, note: string) { await using claim = await ablo.orders.claim({ id: orderId, description: 'applying review', signal: Context.current().cancellationSignal, }); return ablo.orders.update({ id: claim.data.id, data: { approvalNote: note }, claim, }); } ``` For long model reasoning, reason in the Workflow and use a short Activity to re-read and apply the result. Do not return from an Activity while assuming a held lease will remain valid. ## Complete example The repository's [`examples/temporal-agent`](../../../../examples/temporal-agent/README.md) contains a Workflow, Activities, Worker, client, durable AI SDK tool, and a simulated lost-response retry. It is a standalone application on purpose: Temporal stays out of Ablo's core packages and out of `packages/agent`. A dedicated `@abloatai/temporal` package should be introduced only after multiple production integrations reveal substantial, stable behavior that cannot be expressed clearly at this application boundary. --- Source: https://www.abloatai.com/api/docs/llms.txt # Ablo Ablo is collaboration infrastructure for AI agents: one API for agents, apps, and services to claim, change, and confirm the same rows. Here is the problem it solves. Two agents reach for `report_stockholm` at once. One claims the row, does slow work (an LLM call, a fetch, a chain of tools), and commits. The second is neither rejected nor allowed to clobber: it waits in line, is handed the row as it now stands, and proceeds. Claims don't lock. If another writer holds the row, `claim` waits for them, re-reads the fresh row, then hands it to you — so writers serialize instead of colliding. A person editing that row is simply another holder; the agent waits behind them the same way. And a claim is as narrow as its target: name a `path`, a `range`, or a `field` and two claims on non-overlapping parts of the same row are both granted — region locking within one row, with no queueing between regions. 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. ## When to use Ablo 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. These are the jobs it is right for: - **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. - **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. - **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. - **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. - **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. - **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. These are the jobs it is WRONG for, and saying so saves you a wasted integration: - **One writer, no concurrency.** Use Prisma, Drizzle, or SQL. Ablo's coordination costs a round trip and buys you nothing here. - **Chat history, traces, or evals.** Append-only telemetry has no contention to resolve. Write it wherever you write logs. - **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. - **Vector search, embeddings, or retrieval.** Different problem. Ablo coordinates the rows; your vector store indexes them. - **A file, blob, or artifact store.** Coordinate the ROW that points at the object; put the bytes in object storage. How to call it, shortest path first: install `@abloatai/ablo`, run `npx ablo init --yes --framework `, and construct `Ablo({ schema, apiKey: process.env.ABLO_API_KEY })`. Read with `ablo..get({ id })` / `.list({ where })`, write with `ablo..update({ id, data, readAt })`, and wrap anything slow in `await using claim = await ablo..claim({ id })`. The sections below cover the rest; "Start here" is the first thing to run. ## Machine-readable surfaces Everything below is public, needs no credential, and is served from `https://www.abloatai.com`: - `/llms.txt` — this file. - `/llms-full.txt` — the entire published documentation corpus in one fetch. - `/developers` — every developer surface named on one page: the SDK, the API reference, the OpenAPI document, the MCP server, the CLI. - `/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. - `/mcp` — the integration-helper MCP server over Streamable HTTP. POST your JSON-RPC here; a GET returns a descriptor rather than the protocol. - `/.well-known/mcp.json` — that server's manifest, in the MCP registry's `server.json` format. - `/api/docs/` — any documentation page as plain Markdown, for a client that fetches URLs rather than speaking MCP. - `/sitemap.xml` — every indexable page on the domain. Every page on that host serves a Markdown representation from its own URL: send `Accept: text/markdown` (q-values are honoured), or append `.md` to the path if your client cannot set the header. Responses carry `Vary: Accept`, a client that will accept neither `text/html` nor `text/markdown` gets a `406` listing what is available, and a path that does not exist answers a real `404` — never a `200` carrying a sign-in page. ## Versioning and deprecation 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. 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`. 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. ## Surfaces: pick by who is calling Every surface reaches the same coordinated state. They are not interchangeable. - SDK, `@abloatai/ablo` with `transport: 'http'` — THE AGENTS THEMSELVES. Stateless, request/response, nothing held open; the credential is the identity. This is the main path — default to it. - 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. - `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. - CLI, `ablo` — scaffolding, schema push, connecting a database. Terminals and CI. Agents must run it non-interactively (see the CLI section below). - REST, `/api/v1` — runtimes with no SDK. - 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. The two MCP servers do different jobs and are the most common mix-up: to change application rows use the SDK or the COORDINATION server; the integration-helper server cannot do it at all. Do not hand-roll HTTP against `/api/v1` when a typed SDK exists for the runtime you are in. ## Start here First action when integrating into an app: run `npx ablo init --yes --framework `. Agents have no TTY — `--yes` is REQUIRED or it HANGS. It scaffolds `ablo/schema.ts`, the `Ablo({ schema, apiKey })` client, and (for Next.js) the browser provider + session route. All on the current API. Edit the generated files rather than hand-writing from this doc. Connecting a database is a SEPARATE step with one path — logical replication via `npx ablo connect` (see Storage Boundary); the signed Data Source endpoint is the fallback when database credentials must stay inside the app. 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 project-scoped branch-management key locally; an agent must NOT run it. You never copy a runtime key by hand: the next step mints one for the development branch, writes it into `.env.local`, and gitignores it. Then PREPARE THE BRANCH AND PUSH — this is the step everything depends on. The server keeps its OWN copy of the schema. Run `npx ablo dev --no-watch --branch `: it ensures an isolated child branch, exchanges the CLI key for an expiring branch-only `sk_` key, writes that key to gitignored `.env.local`, pushes `ablo/schema.ts`, and exits. 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. Plain `dev` watches forever; never run it bare in an agent. `push` is the lower-level one-shot command for an already selected credential, including a reviewed production deployment. Branches replace manual environment-key juggling. Runtime code always reads ONE `ABLO_API_KEY`; `ablo dev` wires the Git branch's expiring value. Do not create permanent `ABLO_STAGING_KEY` / `ABLO_DEV_KEY` selectors. `npx ablo whoami` strictly confirms the current key's project + immutable branch; `npx ablo whoami --key-env ` explicitly inspects a named process/`.env.local`/`.env` value for CI, migration, or stranded-plane recovery without putting the secret in argv. `status` is the broad readiness report; `whoami` is the narrow identity answer. ## Projects (one org, many apps) Each app gets its own PROJECT inside the org — its own production root + development/preview child branches, each with branch-bound schema, rows, database registration, claims, log, and credentials (the Neon/Supabase shape). `npx ablo init` creates one automatically (slug from package.json name; `--project ` to choose, `--no-project` for the org default). A key belongs to exactly ONE project and branch, fixed at mint; everything it mints inherits that scope. Touching another project's models fails typed: `project_scope_denied` (403) — the fix is a key minted for THAT project, never `ablo push`. Manage: `npx ablo projects list|create |use `; `npx ablo status` shows readiness and `npx ablo whoami` strictly confirms the credential target. Wire: `GET/POST /api/v1/projects` (management authority; duplicate slug → `project_slug_taken` 409). ## Use this API ```ts import Ablo from '@abloatai/ablo'; import { defineSchema, model, z } from '@abloatai/ablo/schema'; TYPES: the project registers its schema ONCE via declaration merging — `npx ablo init` scaffolds `ablo/register.ts` (a regular `.ts` module beside schema.ts, NOT a hand-authored `.d.ts`): `import type { schema } from './schema'; declare module '@abloatai/ablo' { interface Register { Schema: typeof schema } }`. The top-level `import type` makes `declare module` MERGE (augment) the SDK's Register interface rather than collide — same shape TanStack Router uses in src/router.tsx; any `.ts` file in tsconfig include works, never imported. Then model types are one parameter: `type Task = Model<'tasks'>` (import type { Model } from '@abloatai/ablo/schema'); the two-parameter `Model` form is only for projects carrying multiple schemas. Never hand-write model interfaces — derive from the schema. To NAME the client type (function param, context value), infer from the value: `type Sync = typeof sync` — same idiom as tRPC `typeof appRouter` / Drizzle `typeof db`; it resolves the typed overload at the call site. Do NOT use `ReturnType` (collapses to the untyped last overload) and do NOT import a bespoke client-type generic — there is none. const schema = defineSchema({ weatherReports: model({ location: z.string(), status: z.enum(['pending', 'ready']), forecast: z.string().optional(), }), }); const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY }); const report = await ablo.weatherReports.get({ id: 'report_stockholm' }); if (!report) throw new Error('Row not found'); // Claim the row (waits if someone else holds it), read the fresh copy off // `claim.data`, write, then auto-release at the end of this scope (`await using`). await using claim = await ablo.weatherReports.claim({ id: 'report_stockholm' }); const updated = await ablo.weatherReports.update({ id: claim.data.id, data: { status: 'ready', forecast: await getForecast(claim.data) }, }); ``` That is the normal app path: declare models in a schema, then use `ablo..get({ id })`, `ablo..create(...)`, `ablo..update(...)`, and `ablo..delete(...)`. Every verb takes a single options object. Treat the schema as the integration contract. It drives typed model clients, React selectors, server and agent writes, Data Source request/response shape, hosted schema push, and schema-version gating. Do not invent a parallel string-keyed write path for rows that belong to a schema model. For full integrations, use `integration-guide` as the canonical doc. It covers the same model API across your own Data Source-backed app databases, React selectors, multiplayer, and future agent workers. Reads come in two flavors, and you pick by whether you can wait. `get({ id })` (one row) and `list({ where })` (many) are async — they answer from the local graph and fall back to the server, so await them. `local.get(id)`, `local.list({ where })`, and `local.count({ where })` are the same reads narrowed to what has already synced: nothing to await, reactive in render. There is no second verb to learn — `local.` is the only difference. The query reads accept `where`, `filter`, `orderBy`, `limit`, `offset`, and `state`; state defaults to `'live'`, with `'archived'` and `'all'` to include retired rows. `where` takes operators, not only equality: an array value is an `IN` (`{ status: ['draft', 'review'] }`), and tuple form spells the rest out (`[['title', 'ILIKE', '%storm%'], ['createdAt', '>=', cutoff]]`). Clauses combine with AND; for OR, run two reads and union them. `list` returns a page, not always the whole collection. The result is an array, so it maps and iterates as usual, and it carries `hasMore` and `nextCursor` beside the rows. Pass `nextCursor` back as `cursor`, keeping `where` and `orderBy` the same, to walk the rest. Check `hasMore` before treating a result as complete. Workers import the same app schema and select `transport: 'http'`. The transport changes; the typed `ablo.` contract does not. There is no public schema-less or string-keyed model client. React reads should use selector `useAblo`: `useAblo((ablo) => ablo.weatherReports.local.get(id))` (synchronous local read, reactive in render). Use zero-argument `useAblo()` only when a component needs the client for an event handler or effect. The selector form above is the complete reactive surface — every read goes through it. ## Multiplayer Multiplayer is not a separate mode. When human UI, server actions, and agents use the same schema client and write through `ablo.`, Ablo coordinates the shared model stream: confirmed deltas fan out to subscribers, active claims are visible through `claim.state({ id })`, and stale writes can be rejected with `readAt`. If an app writes directly to its own database outside Ablo, that write bypasses coordination until the app reports it through Data Source events. ## Change propagation 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..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. ## Nouns - `Model client` is the typed `ablo.` object generated from schema. - `Claim` holds a model row while slow work runs; `claim.state({ id })` observes it. - `Commit` is the durable protocol write behind `ablo..update(...)`. - `Receipt` confirms the commit. ## Claimed Behavior Reads never silently block. Schema reads stay open while a row is claimed. Typed reads through `ablo..get({ id })` return the row by default, even while it is claimed. Pass `ifClaimed: 'fail'` to throw `AbloClaimedError`, and inspect active coordination separately through `ablo..claim.state({ id })`. A claim carries app metadata verbatim in `target.meta`. Declare its shape in the SAME register block as the schema — `interface Register { Schema: typeof schema; ClaimMeta: { blocks: string[] } }` — and it types both what you write (`claim({ id, meta })`) and every read (`claim.state`, `claim.queue`, the held claim), so no read needs a `typeof` guard. The per-call `claim.state({ id })` still works and is for a program carrying more than one metadata shape. Reads never block on a claim. To wait for a row to free up, `claim({ id })` it — the claim queues fairly behind the current holder and is granted when the row frees. Use `ifClaimed: 'fail'` when you'd rather refuse to read a claimed row. ## Guarantees 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`. Claims coordinate writers; they do not block readers. Most users should stay on schema-backed reads/writes and `claim(...)`; manual protocol bookkeeping is not part of the happy path. All SDK errors extend `AbloError`. Important classes: `AbloClaimedError`, `AbloStaleContextError`, `AbloAuthenticationError`, `AbloPermissionError`, `AbloRateLimitError`, `AbloIdempotencyError`, `AbloConnectionError`, `AbloValidationError`, and `AbloServerError`. ## Schema Scope A schema is model fields and relations. Advanced schema helpers such as `mutable`, `readOnly`, `field`, `indexed`, queries, and load strategies exist for offline/cache/indexing-heavy apps; reach for them only after the basic field/relation schema is working. ## Storage Boundary YOU WRITE THROUGH ABLO; ABLO WRITES TO YOUR POSTGRES. In production every schema model is backed by YOUR OWN database. You call `ablo..create/update/delete`; the write enters Ablo's commit chokepoint (claims, ordering, idempotency enforced there) and Ablo applies it to your Postgres through a scoped writer role (`ablo_writer`: `SELECT, INSERT, UPDATE, DELETE`, `NOSUPERUSER NOBYPASSRLS`, `row_security on` — no DDL, no ownership, no schema migration). Ablo then TAILS your write-ahead log (logical replication) to CONFIRM the write landed (`queued` → `confirmed`) and to serve live reads. Your rows live in your database; Ablo holds only the ordered transaction log. Connect in ONE command: `npx ablo connect apply --url ` — it creates the writer + `REPLICATION` roles, the publication, turns on logical decoding, and registers with Ablo; the admin credential is used once and discarded. Your app then holds only `ABLO_API_KEY` — never a connection string. WAL is how Ablo CONFIRMS and READS, NOT the write path — you write THROUGH Ablo. RLS GOVERNS ABLO'S WRITES. The writer role is `NOSUPERUSER NOBYPASSRLS`, and before every write Ablo `SET LOCAL`s a tenant context on the connection (`app.current_org_id`, `app.current_project_id`, `app.current_environment`, participant id/kind) with `row_security = on` — so your row-level security enforces on Ablo's writes. If your policies read a DIFFERENTLY-NAMED GUC (e.g. an `app.current_app_org_id` your app sets per-connection), declare a tenant-context mapping in your schema so Ablo forwards its context into that GUC (ADR 0011) — do NOT carve a policy exception for the writer role. A role that can bypass RLS (e.g. via Neon's `neon_superuser` inheritance) is rejected with `database_role_cannot_enforce_rls`. FALLBACK (only when Ablo can't dial the database, e.g. a private VPC with no inbound route): a signed Data Source endpoint that hands Ablo an ORM `adapter` (Drizzle default; Prisma and Kysely supported) — Ablo writes and confirms through it, and it owns the transaction, exactly-once idempotency, and outbox in ONE pass; your credentials never leave your infrastructure. The constructor is `Ablo({ schema, apiKey })` — the app holds an API key and nothing else; no connection string ever lives in the app. The database is connected out of band with `ablo connect`: writes land in your Postgres through Ablo's server-side commit path using the scoped writer role `ablo connect` creates, and that role is always NON-superuser / NON-BYPASSRLS (see RLS above). ```ts // app/api/ablo/source/route.ts import { dataSourceNext } from '@abloatai/ablo/source/next'; import { drizzleDataSource } from '@abloatai/ablo/source/drizzle'; import { schema } from '@/ablo/schema'; import { db } from '@/db'; export const runtime = 'nodejs'; // the route touches your database export const { POST } = dataSourceNext({ schema, apiKey: process.env.ABLO_API_KEY!, adapter: drizzleDataSource(db, schema), // or prismaDataSource(prisma, schema) / kyselyDataSource(db, schema) }); ``` Connect a database with `npx ablo connect` (logical replication — the one path above). The signed Data Source endpoint (code above) is the fallback when app database credentials must stay private; scaffold it with `npx ablo init --storage endpoint` — Ablo only calls the endpoint. ## Public demo and development branches Public `/sandbox` is a deterministic visual demo. It should teach shared state, claims, stale-write rejection, receipts, and deltas, but it does not use a real API key. It also exposes a Claude Code / Codex handoff prompt. Prefer that shape when an agent is asked to "make Ablo work" in an existing app. Authenticated development branches are real isolated environments. `ablo dev` derives one from Git and mints an expiring `sk_*` key bound to its immutable branch id. A child CAN host rows without your own database, so you can try Ablo with `apiKey` only. (On the production root, your own Postgres is the system of record.) Branch schemas and rows are isolated from siblings and production. There is no shared-sandbox escape hatch and no different production key class: the persisted branch binding distinguishes child from root. For coding agents, the development success path is: pick one shared model, declare schema, prepare an explicit branch with `ablo dev --no-watch --branch `, create the Ablo client, replace one direct mutation with a typed `ablo..update(...)`, use selector `useAblo` for live reads, and add a two-writer stale/claim smoke test. ## Production A PLANE is what a credential acts on: `production` is the root branch and development/preview branches are children. Rows, the registered database, and the active schema artifact are all PER PLANE. Every `sk_` has an immutable persisted branch binding; its spelling does not distinguish root from child, so app code never passes an environment. A child copies its parent's active schema at creation and owns its artifact after that. A CHILD push does NOT reach production. Production gets models only when the reviewed deployment pushes to the root. Going live is three things, each done with a root-bound `sk_` key: register the production database (`ablo connect apply` — the DIRECT host, never a pooler; a pooler refuses in the words of a wrong password), push the schema AHEAD of the code that needs it, and hold the right credential per runtime (server/serverless `sk_`; browser `pk_` read-only or an `authEndpoint` minting `ek_`). `ablo login` stores an `mk_` management credential and CANNOT read/write application data or push the production schema — a production push needs a root-bound `sk_` in `ABLO_API_KEY`. Gate a deploy on `npx ablo status --json` having an EMPTY `blockers` array; each blocker carries a `problem` and the one `fix`. Your agents do NOT each hold a database connection — they talk to Ablo, and Ablo holds at most 4 connections per plane (`application_name = 'ablo-direct-writer'`) however many callers write behind them, so size the database for that number and not for your agent count. Read `deployment` for the full path. ## Public Surface Import from these public paths only: - `@abloatai/ablo` — headless HTTP `Ablo`, errors, typed model clients, claims, durable observation, and `dataSource`. - `@abloatai/ablo/client` — reactive WebSocket client, presence, and the human-facing materializer. - `@abloatai/ablo/schema` — schema DSL. - `@abloatai/ablo/react` — React provider and hooks. - `@abloatai/ablo/source` — `dataSource`, the `DataSourceAdapter` spine, `prismaDataSource`. For a customer-canonical Data Source endpoint. - `@abloatai/ablo/source/next` — `dataSourceNext` (Next.js App Router `{ POST }`). - `@abloatai/ablo/source/drizzle` — `drizzleDataSource`. - `@abloatai/ablo/source/kysely` — `kyselyDataSource`. - `@abloatai/ablo/source/conformance` — `runDataSourceTests` to prove a custom adapter/handler. - `@abloatai/ablo/ai-sdk` — thin AI SDK tools over typed model reads, creates, coordinated updates, and claimed deletes. Do not teach `/api`, `/agent`, `/core`, `/realtime`, or internal subpaths. (`/source` and `/ai-sdk` are public.) ## CLI: agents run it NON-INTERACTIVELY `ablo init` and other prompts need a TTY; an agent/CI run has none and will HANG. Always: - `npx ablo init --yes` (flags: `--framework`, `--auth`, `--storage replication|endpoint`, `--no-agent`, `--no-pull`, `--no-install`, `--no-login`). Generates `ablo/schema.ts` + the `Ablo({ schema, apiKey })` client. `--storage replication` (the default) pairs with `ablo connect`; `--storage endpoint` also scaffolds the `ablo/data-source.ts` fallback endpoint. - `npx ablo connect` connects your database via logical replication — the read path (prints the `wal_level=logical` + publication + `REPLICATION`-role SQL); `npx ablo connect register` registers it, `npx ablo connect check` validates the registered database from Ablo's own side and needs only `ABLO_API_KEY` (no database credential in your environment). `npx ablo connect apply` does the whole setup from a one-time admin connection and leaves your app holding only `ABLO_API_KEY`. - Key: see "Start here" — env → `.env.local` → ask the human to `npx ablo login`; never run `login` yourself, never copy keys by hand (`ablo dev` mints and wires the temporary branch key). - Adopt an existing DB: `npx ablo pull prisma [path]` / `npx ablo pull drizzle `. - `npx ablo dev --no-watch --branch ` ensures an isolated branch, mints and wires its temporary key, pushes once, and exits. Plain `dev` watches forever. `npx ablo branch check ` prints lifecycle, schema/parent compatibility, datasource identity, and readiness. `npx ablo push` pushes with the already selected key and does NOT choose a branch for you. A PRODUCTION push (`sk_` key) needs `--yes`: with no TTY it REFUSES rather than deploying unattended, and interactively it demands the destination project typed by name. Destructive steps (dropped model/field, narrowed enum, lossy cast) need `--force`; a new required field on a populated table needs `--backfill model.field=value`. `npx ablo logs --no-follow` exits instead of tailing forever. `npx ablo push`/`status`/`pull`/`check`/`generate` are one-shot. - `npx ablo docs` lists every documentation page; `npx ablo docs ` prints one as markdown. These pages ship INSIDE the installed package, so they describe the version in `node_modules` and need no network. Prefer them over a docs URL whenever the project pins a version: a website always describes the newest release, so on an older pin it will hand you a call your package does not have (`retrieve`/`list` replaced `get`/`getAll`/`getCount` in 0.35.0). One-shot, safe to run unattended. ## Documentation Canonical docs to read before integrating, in this order. Read each with `npx ablo docs ` when the project pins a version; the links describe the newest release. - [Quickstart](https://docs.abloatai.com/quickstart) - [Branch-first development](https://docs.abloatai.com/branch-development) - [Schema Contract](https://docs.abloatai.com/schema-contract) - [Integration Guide](https://docs.abloatai.com/integration-guide) - [Deployment](https://docs.abloatai.com/deployment) - [Guarantees](https://docs.abloatai.com/guarantees) - [Client Behavior](https://docs.abloatai.com/client-behavior) - [Connect Your Database](https://docs.abloatai.com/data-sources) - [API](https://docs.abloatai.com/api) - Examples: [Existing Python Backend](https://docs.abloatai.com/examples/existing-python-backend), [AI SDK Tool](https://docs.abloatai.com/examples/ai-sdk-tool), [Server Agent](https://docs.abloatai.com/examples/server-agent) - [Upgrade Guide](https://docs.abloatai.com/migration): when upgrading an existing integration; every breaking change, what to change, and which version introduced it. - [Session Settings](https://docs.abloatai.com/session-settings): when the customer's database has row-level-security policies; the identity context Ablo sets before every write, and how to map it to the setting names those policies read. - [Every page, one line each](https://docs.abloatai.com/llms.txt), or [the full docs as one file](https://docs.abloatai.com/llms-full.txt). --- Source: https://www.abloatai.com/api/docs/mcp # Model Context Protocol > Two MCP servers for two different jobs — one of them is a data plane, one is not. Ablo publishes **two** MCP servers for two different jobs. Don't confuse them: | Server | Purpose | Auth | Tools | |---|---|---|---| | **Coordination** (`@abloatai/mcp`) | Manage your Ablo the way the CLI does, and let an agent safely read & mutate application data | API key (`sk_…` / `rk_…`) | projects, schema, logs, usage: plus `get` / `list` / `create` / `update` / `delete` / `claim` / `release` over your rows | | **Integration-helper** (hosted `/api/mcp`) | Help an AI coding assistant write SDK integration code that compiles | none (public docs) | doc search, export surface, schema lint, scaffold | The coordination server manages your account **and is the data plane** — it is how an agent changes state. The integration-helper server only serves docs, schema lint, and scaffolds; it does **not** read or write application data (there are no per-model data tools on it). Pick by what you're doing: shipping an agent that edits rows → coordination; teaching your IDE assistant the SDK → helper. ## Coordination server (`@abloatai/mcp`) The coordination server does two jobs: it manages your Ablo the way the `ablo` CLI does, and it renders the model-scoped API (`/api/v1/models/...`) as tools — the same surface as `ablo..create/update/claim`. An agent connects with your API key and gets one safe loop: **claim → read → commit → release.** Install over stdio; set your key in the host's MCP env: ```bash claude mcp add ablo -- npx -y @abloatai/mcp # env: ABLO_API_KEY=sk_… (ABLO_API_URL optional; defaults to the hosted API) ``` ### Managing your Ablo | Tool | Mirrors | Does | |---|---|---| | `get_schema` | `ablo status` | the models this key can address, and its environment + project | | `list_projects` | `ablo projects list` | the org's projects (needs `sk_`) | | `create_project` | `ablo projects create` | create one (needs `sk_`) | | `tail_logs` | `ablo logs` | recent commits and the actor behind each | | `get_usage` |: | usage in daily buckets | There are no key-management tools. A mint returns the plaintext once — only a hash is kept — so no tool can hand it back later, and returning it at mint time would write a live secret into the agent's context and the conversation transcript, where it outlives any revocation. Listing and revoking will arrive once a grant identifies the caller as a person rather than a key. Manage keys with `ablo login` or the dashboard. `ablo init`, `push`, `pull`, and `generate` have no tools: they read and write files in your repo, which the server cannot see. Run those in a shell — then call `get_schema` to see the result. ### Reading and changing rows Each tool mirrors an SDK verb, scoped to a model + id. Model names come from `get_schema`: | Tool | Mirrors | Does | |---|---|---| | `get_model` | `ablo..local.get(id)` | read latest state + active claims | | `list_records` | `ablo..list({…})` | cursor-paginated list with filters | | `create_model` | `ablo..create({ data })` | guarded create | | `update_model` | `ablo..update({ id, … })` | guarded update | | `delete_model` | `ablo..delete({ id })` | guarded delete | | `claim_model` | `ablo..claim({ id })` | acquire / queue a coordination lease | | `release_claim` |: | release the lease so others proceed | The agent-facing contract — the safe loop, the "derive idempotency keys from the business event" rule, and the error-code playbook — ships as a loadable skill at `@abloatai/mcp/skill.md`. ## Integration-helper server If you're integrating `@abloatai/ablo` with the help of an AI coding assistant (Claude Code, Cursor, Windsurf, Codex), you don't want it guessing at the API. This hosted server lets the assistant search the real docs, inspect the actual export surface, lint your schema, and scaffold a starter — so the code it writes uses APIs that exist. It serves docs only and returns nothing org-specific; data access happens through the SDK or the coordination server above, never here. > The `@abloatai/ablo` npm package itself bundles neither server — it has > no `@modelcontextprotocol/sdk` dependency. The helper is a feature of Ablo's > hosted app, mounted at `/api/mcp`; the coordination server is the separate > `@abloatai/mcp` package. ### Install Point your assistant at the hosted endpoint — no auth, no token: ```bash claude mcp add --transport http ablo https:///api/mcp ``` The endpoint is identical for every client — only the config surface differs: - **Claude Code:** run the `claude mcp add` command above; verify with `/mcp list`, remove with `claude mcp remove ablo`. - **Cursor:** add the server to `~/.cursor/mcp.json` (macOS / Linux), then restart. - **Windsurf:** add the same JSON via Settings → Cascade → MCP, then restart. Cursor and Windsurf use the same config shape: ```json { "mcpServers": { "ablo": { "transport": "http", "url": "https:///api/mcp" } } } ``` Each client then lists the Ablo tools (`search_ablo_docs`, `get_recipe`, `get_api_surface`, `validate_schema`, `scaffold_app`) in its MCP panel. ### What it exposes #### Tools | Tool | What it does | |---|---| | `search_ablo_docs` | Keyword search across the docs corpus. Returns ranked matches with excerpts. Follow up with `get_recipe` on the top hit. | | `get_recipe` | Returns the full markdown of one doc by name (e.g. `readme`, `quickstart`, `schema-contract`, `integration-guide`, `api`, `guarantees`). | | `get_api_surface` | Returns the structured export list for an SDK subpath (`@abloatai/ablo`, `./react`, `./schema`, `./testing`, …). Call with no argument to list every subpath. | | `validate_schema` | Lints `defineSchema` source against the DSL rules (camelCase fields, lowercase model keys, `scope`/`grants` sync groups, valid `load` strategies, no legacy builders) and returns a structured issue list. Runs no code. | | `scaffold_app` | Emits a starter file tree for a schema-first integration: `next`, `node-agent`, or `plain`, with a `data-source` (your own database) endpoint. | #### Resources Every doc file is addressable at `ablo://docs/{name}`, so a client can list the corpus and fetch individual files on demand instead of loading everything into context. #### Prompts Reusable, parameterised templates that drive an end-to-end flow: - `integrate-ablo` — wire the SDK into an existing project. - `add-agent` — add an agent worker that coordinates via claims and conflict-safe writes. - `define-schema` — design a Zod-first schema from a description, then run `validate_schema` before committing. ### Transport and limits The endpoint uses the stateless Streamable HTTP transport (`POST /api/mcp`; `GET` returns 405 — SSE is not supported in stateless mode). A fresh server is built per request, which suits serverless and horizontally-scaled deployments. There is **no authentication**: the server only serves docs, schema lint, and scaffolds, so there's nothing org-scoped to protect. Abuse is bounded by IP-based rate limiting — 120 requests per minute per IP. Rate-limit headers are echoed on every response. ### Where it lives - Route handler: `apps/sync-web/src/app/api/mcp/route.ts` - Server setup: `apps/sync-web/src/lib/mcp-ablo/server.ts` (`createSyncEngineMcpServer`) - Tools: `apps/sync-web/src/lib/mcp-ablo/tools/` --- Source: https://www.abloatai.com/api/docs/migration # Upgrade Guide > A safe workflow for upgrading the pre-1.0 SDK. Ablo is pre-1.0, so a minor release may contain a breaking API change. Patch releases remain compatible within the same minor line. ## Upgrade safely 1. Pin the version you run instead of depending on a floating range. 2. Read the changelog entries between your installed and target versions. 3. Use the documentation bundled with the target package while changing code. 4. Run type-checks and tests before updating a production branch. 5. Push schema changes deliberately after reviewing the generated diff. ```bash npm install @abloatai/ablo@0.48 npx ablo docs npx ablo docs api npx ablo check ``` `npx ablo docs` is version-matched to the installed package. Prefer it during an upgrade: the hosted website documents the newest release, which may expose a method your pinned package does not yet contain. ## What to review Pay particular attention when a release changes: - model method signatures or return values; - claim acquisition, contention, or release behavior; - credential scope or session minting; - schema serialization and push validation; - database connection or Data Source setup; - error codes your application handles explicitly. The release changelog names required edits next to the feature that changed. Avoid branching on credential prefixes or undocumented internals; use exported types and server-confirmed identity instead. ## Schema and database safety An SDK upgrade and a database migration are separate operations. - `ablo push` updates Ablo's typed schema contract. It does not run application DDL or drop your tables. - `ablo check` compares the contract with the connected database without changing it. - Your ORM or migration tool remains responsible for tables, columns, constraints, and application data migrations. When both need to change, deploy the database migration in a backwards-compatible form first, push the compatible Ablo schema, then remove old application paths. ## If an upgrade fails Use the typed error code and request ID rather than matching message text. The [Errors](./errors.md) reference gives the recovery step for each public error. If a failure only occurs on the new minor version, keep the previous pinned version in production while reproducing it against an isolated branch. See the release changelog for version-specific changes and [Deployment](./deployment.md) for the production rollout sequence. --- Source: https://www.abloatai.com/api/docs/operating-on-your-database # Operating on Your Database > Which actions are read-only, which writes need a guard, and which changes belong to a human. Ablo sits over your database as a coordination layer, not an owner. It reads your Postgres replication stream and routes model writes into your own tables. The hosted service does not run application DDL, migrations, or drops. That boundary keeps model operations attributable while your migration tool remains responsible for the database shape. The habit that makes it easy is to look before you act. One command shows you the real shape of your database measured against your schema, and changes nothing: ```bash npx ablo check # read-only — reports which columns fit your models and which don't ``` When a question is about the live database — does this column exist, is it nullable, will this write fit — you can usually answer it by observing rather than reasoning in the dark. ## The floor: what Ablo never does These hold on every database Ablo connects to, and they are what bound the blast radius of anything you do through the model API: - It **never runs DDL or migrations** on your database, and never drops a table or column. Schema changes to your own tables are always your application's action, run with your admin credential — never Ablo's. - It **never owns your rows.** Canonical data stays in your tables; Ablo hosts only the transaction log and the coordination state. - Every model write is **checked against active claims and recorded.** A plain write is last-write-wins when no claim applies. A functional update, held claim, or explicit `readAt` guard protects a write that depends on an earlier value. Ablo prevents a non-holder from writing through another participant's claim by default. It does not infer that every plain update is a read-modify-write operation; use the guarded forms when lost-update protection matters. ## Three kinds of action Sort any action you're about to take into one of these, and the right move follows. **Normal application operations.** Reads (`get`, `list`), `ablo check`, and `ablo pull` observe and never change anything. Previews — `--show-sql`, `--dry-run` — print the exact SQL a command would run without executing it. Model writes through `ablo..create` / `update` are authorized, checked against active claims, optimistic locally, rolled back locally if the server rejects them, and recorded. Review their data effects as you would any application write. **Verify first — needs one look at the live database.** Routing an existing table's writes through a model requires the model to match the table's real columns. Run `ablo check`: it names the columns that fit and the ones that don't, so a `NOT NULL` column your model doesn't set shows up as a line in the report rather than a surprise at commit time. Decide the model shape from what `check` tells you, then proceed. Nothing here is risky — it just reads better after you've seen the ground truth. **Hand to a human — database administration.** Raw DDL on the live database — `ALTER TABLE … OWNER TO`, adding or dropping a column, changing a constraint — changes the database itself, so it belongs to a person with their hand on it. So does a `connect` cutover run with its confirmation skipped (`--yes`): the prompt exists because the step provisions real roles and reconciles publication on a live database, and on a shared or production database that confirmation is the human's to give. Removing a model from your pushed schema also deserves review because clients will lose that typed API surface. ## The one action that isn't what it looks like Deleting a model from `ablo/schema.ts` removes it from the API contract the next time you push the schema. Existing clients can no longer access that model through Ablo. It does **not** drop the underlying table or its rows; database DDL remains your migration tool's responsibility. Review client usage first, then remove the model and push the schema as a normal application change. ## The verification loop Most of the uncertainty in working on a live database dissolves into a few read-only checks: - `ablo check` — does the live database match the schema? Reports the exact column-by-column fit. Read-only. - `ablo pull` — what is actually in the database, expressed as a schema. Read-only, like `prisma db pull`. - `--show-sql` / `--dry-run` on `connect` and `migrate` — the exact statements, printed and unexecuted, so you approve the SQL before it runs. - Read the row and its claim state before you write — `get` / `list`, and `ablo..claim.state({ id })` for who is already working on it. The pattern underneath all of it is steady: observe first, use guarded writes when a change depends on prior state, and review DDL and cutovers separately because they change the database itself. ## See also - [Connect Your Database](./data-sources.md) — the one path a database joins Ablo. - [Guarantees](./guarantees.md) — what a confirmed write, a stale check, and a claim promise. - [CLI & Migrations](./cli.md) — `check`, `pull`, `migrate`, and their read-only / preview flags. - [Schema Contract](./schema-contract.md) — how the schema drives push, and why it is a desired-state declaration. --- Source: https://www.abloatai.com/api/docs/projects # Projects > One organization, many apps — each with its own schema, data planes, and keys. A **project** is the isolation unit inside your organization — the shape you know from Neon or Supabase. Each app you build gets its own project, and each project gets its own root and child branches, schemas, data planes, and API keys. Two teams in one org can ship two apps that never see each other's models, keys, or rows. ```text organization ├── project: default ← every org has one; pre-project apps live here │ ├── production root │ ├── development/preview branches │ └── branch-bound schema, data plane, and credentials └── project: my-app ← npx ablo init creates this for a new app ├── production root ├── development/preview branches └── branch-bound schema, data plane, and credentials ``` ## The default project Every organization has a **default** project. If you never create another one, everything works exactly as if projects didn't exist — your keys, schema pushes, and registered databases all belong to it. Keys minted before projects existed are default-project keys automatically. ## Keys belong to exactly one project A key's project is fixed at mint and can never be changed or overridden — the same discipline as its immutable branch binding. Everything a runtime key mints inherits its project: the short-lived session credentials (`ek_`), agent keys (`rk_`), everything. There is no way to "switch projects" with an existing key; you use a key minted for the project you mean. Schema pushes, database registrations, reads, and writes all act on the **key's** project: - `npx ablo push` activates the schema for the pushing key's project — it can never demote another project's schema. - Registering a database (`DATABASE_URL`) attaches it to the key's project and immutable branch. - A write or read against a model that belongs to **another** project in your org fails with a typed `project_scope_denied` — never a silent empty result, and never the misleading "unknown model, run ablo push". ## Branches belong to a project Production is the protected root branch; development and preview branches are children. Each branch has its own rows, active schema artifact, claims, log, credentials, and optional registered database. A child copies the parent's active schema when created and owns its history afterward, so a feature-branch push cannot change a sibling or production. `ablo dev` derives a child from Git and mints an expiring `sk_` credential bound to its immutable branch id. Production runtimes use `sk_` bound to the root. There is no shared sandbox schema and no runtime environment switch. ## CLI `npx ablo init` creates a project for your app automatically (slug derived from your `package.json` name; `--project ` to choose, `--no-project` to stay on the org default). Manage projects any time: ```bash npx ablo projects list # all projects (default first) npx ablo projects create my-app # create one (--name "Display Name") npx ablo projects use my-app # set the ACTIVE project locally npx ablo projects use default # back to the org default npx ablo status # shows the active project ``` The active project is a local targeting preference in your CLI config: new management and branch credentials you mint pick it up. It never changes what an existing key can reach — project and branch scope are decided server-side at mint. Use `npx ablo whoami` to confirm the exact target of the active credential. ## API Projects are a control-plane resource authenticated with a management (`mk_`) key: ```bash curl https://api.abloatai.com/api/v1/projects \ -H "Authorization: Bearer $ABLO_API_KEY" # list curl https://api.abloatai.com/api/v1/projects \ -H "Authorization: Bearer $ABLO_API_KEY" \ -H "content-type: application/json" \ -d '{"slug": "my-app", "name": "My App"}' # create ``` A create with a taken slug fails with `project_slug_taken` (409). Reads of another org's project ids 404 — never confirm existence across orgs. ## Errors | Code | Status | Meaning | |------|--------|---------| | `project_scope_denied` | 403 | The model/resource belongs to another project in your org: use a key minted for that project. | | `project_slug_taken` | 409 | A project with this slug already exists in the organization. | --- Source: https://www.abloatai.com/api/docs/quickstart # Quickstart > Make your first coordinated write, on the Postgres you already have. Build with Ablo on **the Postgres you already have**. You declare a small Ablo schema for the models your agents edit together, connect Ablo to your database (`ablo connect`), and read and write every one of those models through `ablo.`. You write through Ablo; it lands the change in your Postgres and confirms it by tailing your write-ahead log (WAL). Your rows live in your database, which stays the system of record. Ablo writes rows but **runs no DDL and owns no schema** — your migration tool stays in charge of the shape of your database. > No database yet? Pass an `apiKey` only and Ablo keeps your rows in its own log, > so you can build the whole app today. Point it at a > separate or local Postgres for a development branch, or at your production > database when you're ready. ## 1. Install and initialize ```bash npm install @abloatai/ablo npx ablo init ``` `ablo init` scaffolds your project (next step shows what it creates) and ends by signing you in — one browser click, and a project-scoped `mk_` management credential is saved locally. Later, `npx ablo dev` (step 4) prepares an isolated branch and writes its temporary `ABLO_API_KEY` into `.env.local`, so the SDK follows your Git branch with no manual copy-paste. `npx ablo login` also exists standalone. In CI, set project management access explicitly: ```bash export ABLO_MANAGEMENT_KEY=mk_... ``` Every runtime call needs a branch-bound API key. `ablo dev` exchanges the stored management credential for an expiring `sk_*` key bound to the current development branch. Production runtimes use `sk_*`. In production a key points at the database *you* own; on a development branch you can skip the database entirely and let Ablo host the rows (apiKey only). There is no keyless mode — a key is always required. (The public `/sandbox` page is a separate hosted demo, not your app.) You do not create a separate named variable per development environment. Application code always reads `ABLO_API_KEY`; `ablo dev` wires the value for the current branch. Run `npx ablo whoami` whenever you want the server-confirmed project and branch for that value. See [API Keys](./api-keys.md) for the full project → branch → credential model. ## 2. Your Ablo schema (init scaffolded it) The schema is the contract — it generates `ablo.` methods for app code, server actions, agents, and React reads. Declare **only the synced models** Ablo coordinates; your auth, billing, and other tables stay in your own Drizzle schema, owned by your own migrations. ```ts // ablo/schema.ts import { defineSchema, model, z } from '@abloatai/ablo/schema'; export const schema = defineSchema({ weatherReports: model({ location: z.string(), status: z.enum(['pending', 'ready']), forecast: z.string().optional(), }), }); ``` **Reserved fields** — `id`, `createdAt`, `updatedAt`, `organizationId`, and `createdBy` are provided by the SDK automatically. Don't declare them in your `model(...)` fields; declare only your own. The schema is registered once (init scaffolds `ablo/register.ts` for you), and every type is one parameter away — no `typeof schema` re-stating, anywhere: ```ts // ablo/register.ts — scaffolded by `npx ablo init`, sits beside ablo/schema.ts import type { schema } from './schema'; declare module '@abloatai/ablo' { interface Register { Schema: typeof schema } } export {}; ``` It's a regular `.ts` module, not a hand-authored `.d.ts`. The top-level `import type { schema }` makes the `declare module` block *merge* into (augment) the SDK's `Register` interface instead of colliding with it — the same shape [TanStack Router uses in `src/router.tsx`](https://tanstack.com/router/latest/docs/framework/react/guide/type-safety). Any `.ts` file in your `tsconfig` `include` works; it never needs to be imported. ```ts import type { Model } from '@abloatai/ablo/schema'; type WeatherReport = Model<'weatherReports'>; // fully typed from YOUR schema ``` The same block is where you name the metadata your claims carry. Add a `ClaimMeta` key and every `claim.state`, `claim.queue`, and held claim reads `target.meta` as that shape: ```ts declare module '@abloatai/ablo' { interface Register { Schema: typeof schema; ClaimMeta: { blocks: string[] }; } } const holder = ablo.weatherReports.claim.state({ id }); holder?.target.meta?.blocks.length; // typed, no guard ``` (The same `Register` binding types every hook and client — it's the TanStack-Router pattern: declare the source of truth once, everything infers from it.) When you need to name the client type — to pass it to a function or store it in a context — **infer it from the value**: `type Sync = typeof sync`. That's the same idiom as tRPC's `typeof appRouter` and Drizzle's `typeof db`; it resolves the typed overload at the call site. Avoid `ReturnType`, which collapses to the untyped client. ## 3. Connect your database with `ablo connect` `ablo connect` sets your database up so Ablo can write your rows (a scoped DML role) and read them back to confirm (logical replication). It writes rows through that role but runs no DDL and owns no schema — your migration tool stays in charge. You run `ablo connect` once, out of band — it provisions the roles and hands them to Ablo. From then on Ablo does the connecting; your app never opens a database connection. ```bash # Point it at an admin connection once — it does the whole ceremony: creates the # roles + publication, turns on logical decoding where it can, registers both # scoped roles with Ablo, and proves it by reading back. Nothing lands in your .env. npx ablo connect apply --url postgres://admin:...@host:5432/db # ...or print the SQL and run it yourself, then register: # npx ablo connect # prints the publication + two scoped roles # npx ablo connect check # validates the database is ready # npx ablo connect register # hands the two scoped roles to Ablo ``` `ablo connect apply` generates two roles and their passwords — an `ablo_replicator` role (`REPLICATION` + `SELECT`, for reads and confirmation) and an `ablo_writer` role (scoped row DML, for writes) — and registers both connection strings with Ablo's control plane, encrypted. Ablo's runtime uses them to read and write your database. The admin credential you pass to `--url` is used on this machine only and never persisted. The role passwords are generated for you and never printed — rotate them any time with `ablo connect rotate`. Your **app** holds only the API key — never a connection string: ```bash # .env — server runtime only, never the browser ABLO_API_KEY=sk_... ``` ```ts // ablo/client.ts import Ablo from '@abloatai/ablo'; import { schema } from './schema'; export const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, }); ``` The full setup, the honest footprint (publication + slot + the `REPLICATION` and writer roles + the `wal_level` restart + slot/WAL retention Ablo monitors), and the Preview status are in [Connect Your Database](./data-sources.md). ## 4. Start the branch development loop ```bash npx ablo dev # prepare this Git branch, push, and watch ablo/schema.ts ``` `ablo dev` discovers your Git branch, ensures a matching isolated Ablo branch, mints an eight-hour branch credential, writes it to gitignored `.env.local`, uploads the schema *definition*, and watches for schema edits. Model names, fields, and types tell Ablo which models to coordinate. Skipping the push makes every write to a new or changed model fail with `server_execute_unknown_model`. Use `npx ablo dev --no-watch` when you only need to prepare and push once. Use `npx ablo push` as the lower-level one-shot command when you deliberately want to push with the currently active key, including a reviewed production deploy. The complete mental model and CI examples are in [Branch-first development](./branch-development.md). Now map those models to your real Postgres tables. **Your migration tool owns the tables** — Ablo reads them, it does not create or migrate them: - Run `npx ablo pull` to import the shape of your existing tables (created by Prisma, Drizzle, or hand-written migrations) into your schema, or `npx ablo check` to verify your schema and the live tables agree. Keep managing the tables with your own migration tool; Ablo syncs the subset of models you declared and reports the rest as "ignored / owned by you." > **Starting from an empty database?** `npx ablo migrate` creates the tables > your schema needs. Once they exist, your own migration tool stays in charge > of them — Ablo adopts whatever shape you evolve. No Ablo server runs locally. The `ablo dev` process only watches your schema; your app talks to Ablo's hosted API, and the rows live in your database. ## 5. Write through the model The rows land in your Postgres; every connected client sees them live. ```ts import { ablo } from './ablo/client'; await ablo.ready(); const created = await ablo.weatherReports.create({ data: { location: 'Stockholm', status: 'pending' }, }); const updated = await ablo.weatherReports.update({ id: created.id, data: { status: 'ready', forecast: 'Light rain, 13C' }, }); console.log({ id: updated.id, status: updated.status }); // { id: '...', status: 'ready' } ``` Read a single row back with `get({ id })`. It resolves to the row, or to `undefined` when no row has that id — so narrow it once, then the fields are fully typed: ```ts const report = await ablo.weatherReports.get({ id: created.id }); if (!report) throw new Error(`weatherReports ${created.id} not found`); console.log(report.status); // 'ready' ``` ## Add coordination for slow work When AI or background work will touch an existing row for more than a quick write, coordinate through `claim({ id })`. It claims the row and hands a handle back; `claim.state({ id })` reads who is currently working on it without blocking; and you write the usual way with `ablo..update({ id, data })`. Claims don't lock. If another writer holds the row, `claim` waits for them, re-reads the fresh row, then hands it to you — so two writers serialize instead of clobbering. Normal reads still work while the claim is held. If a server read should not return a row while someone else is mid-edit, pass `ifClaimed: 'fail'` to error out instead. Reads never block on a claim — to wait for a row to free up, `claim({ id })` it (the claim queues fairly behind the holder). Bind the handle with `await using` and the claim releases itself when the scope exits — on success or on a throw, so a failing agent call never leaves the row locked. ```ts // Claim the row so other participants serialize behind us while we work. await using handle = await ablo.weatherReports.claim({ id: 'weather_stockholm', description: 'checking_weather', ttl: '2m', }); // Your existing weather tool or agent call. While this runs, other clients // see that weather_stockholm is being checked. const weather = await weatherAgent.getWeather(handle.data.location); await ablo.weatherReports.update({ id: handle.data.id, data: { status: 'ready', forecast: weather.summary, }, }); // scope exit releases the claim — no manual release, even if the work threw ``` Ablo does not fetch the weather. If another participant already holds the row, `claim` waits for them to finish, re-reads, and then hands you the fresh row. While you hold the claim, `update({ id, data })` rejects with `AbloStaleContextError` if someone else changed the row first — so you never overwrite work you didn't see. ## Multiplayer and claimed work There is no separate multiplayer mode. Use the same schema client for human UI, server actions, and agents; Ablo fans out confirmed writes and keeps active claims visible on the same model row. `claim.state({ id })` tells you when another human or agent is active on the same row. For schema clients, `claim({ id })` waits fairly, re-reads, and then lets you write through the model. ```ts const active = ablo.weatherReports.claim.state({ id: 'weather_stockholm' }); if (active) { console.log(`${active.heldBy} is ${active.reason}`); } await using handle = await ablo.weatherReports.claim({ id: 'weather_stockholm' }); await ablo.weatherReports.update({ id: handle.data.id, data: { status: 'ready' } }); ``` Use `contention: { mode: 'skip' }` when work should be skipped instead of queued behind an active holder. Add `onStatus` inside that object when the attempt should also update telemetry or UI. ## Next steps Keep using the schema client for app and agent writes. - [Integration Guide](./integration-guide.md) explains the full app, React, Data Source, multiplayer, and agent path. - [Schema Contract](./schema-contract.md) explains what the schema drives across SDK, React, agents, Data Source, and schema push. - [Guarantees](./guarantees.md) explains what confirmed writes and stale checks mean. - [Client Behavior](./client-behavior.md) covers errors, retries, and public imports. - [Connect Your Database](./data-sources.md) covers the logical-replication connect path end to end — `ablo connect`, the honest footprint, and the WAL runtime's Preview status. - [AI SDK Tool](./examples/ai-sdk-tool.md) shows the same write path inside a tool call. --- Source: https://www.abloatai.com/api/docs/react # React > Provider, hooks, and reactive reads for the interfaces people watch agent work arrive in. The React bindings for `@abloatai/ablo`. Use them when you want live data on the client without writing fetch + WebSocket plumbing yourself. For the full app structure, including server loads, existing backends, and agents, start with [Integration Guide](/docs/integration-guide). ## Installation The React bindings ship with the main package — no extra install. ```ts import { useAblo } from '@abloatai/ablo/react'; ``` ## Building the client You build the Ablo client once — that's where the schema, the session endpoint, and connection config live — then hand it to the provider. The provider takes the already-built `client`; it no longer takes `schema`, `url`, `apiKey`, etc. as props. Construct the client once, then pass that instance to the provider. ```ts // lib/ablo.ts import Ablo from '@abloatai/ablo'; import { createAbloReact } from '@abloatai/ablo/react'; import { schema } from '@/ablo/schema'; // The browser never holds your API key. It mints a short-lived session token // from your own server route (see Identity below). export const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session', }); // The typed binding: capture the schema once, and every component imports // born-typed hooks from this file — `useAblo()` takes no type arguments, // and a selector's `ablo` parameter knows your models. export const { AbloProvider, useAblo } = createAbloReact(schema); ``` Import `AbloProvider` and `useAblo` from `lib/ablo` rather than from the package, and the schema generic never appears at a call site again — the same one-binding-file convention as tRPC's `createTRPCReact` or react-redux's typed hooks. ## AbloProvider Mount it once near the root of your tree. It owns the connection, the local pool, and the engine lifecycle; everything below it reads with `useAblo`. ```tsx 'use client'; import { AbloProvider } from '@abloatai/ablo/react'; import { ablo } from '@/lib/ablo'; export function Providers({ children, user, // resolved server-side from YOUR auth }: { children: React.ReactNode; user: { id: string }; }) { return ( }> {children} ); } ``` `client` is the only required prop. The rest are situational: | Prop | Default | Purpose | | ----------- | ---------------- | --------------------------------------------------------------------------------------------------------- | | `client` |: | **Required.** The `Ablo({ schema, apiKey })` instance. It carries the schema and connection config. | | `userId` | resolved from auth | App participant id for app-owned fields and your `identityRoles`. Not the security boundary. | | `fallback` | neutral spinner | Rendered during the *first* bootstrap only. Pass a branded skeleton, `null`, or `'passthrough'`. | | `onError` |: | Engine / WebSocket / bootstrap errors. Wire to Sentry / Datadog. | Everything that used to be a provider prop — `schema`, `url`, `apiKey`, `teamIds`, `syncGroups`, `persistence`, `bootstrapMode` — now lives on the `Ablo({ ... })` client you build before mounting the provider. Where the identity comes from, and why the API key never reaches the browser, is the whole of [Identity & Sync Groups](./identity.md) — read that if it isn't obvious how org / team / user map to what a participant can see. ## useAblo: model client ```tsx 'use client'; import { useAblo } from '@abloatai/ablo/react'; export function ReportView({ report: serverReport }: { report: { id: string; location: string } }) { const report = useAblo((ablo) => ablo.weatherReports.local.get(serverReport.id)) ?? serverReport; const active = useAblo((ablo) => ablo.weatherReports.claim.state({ id: serverReport.id })); const claimed = Boolean(active); return
{report.location}
; } ``` The hook: 1. Uses the same `ablo..local.get(id)` / `.local.list()` methods you'd call anywhere else in the SDK — the hook just makes them reactive. 2. Tracks the model fields read by the selector and re-renders when confirmed deltas arrive. 3. Lets Server Component data stay outside the hook: use `?? serverReport` when a parent already loaded the row. 4. Works for coordination state too, such as `ablo.weatherReports.claim.state({ id })`. Use the zero-argument form only when you need the full client for callbacks, effects, or writes: ```tsx const abloClient = useAblo(); ``` Prefer selector reads like `useAblo((ablo) => ablo..local.get(id))`. Older hooks also accept a string model name; prefer the selector form shown above. For collections, keep the selector on the model client too: ```tsx const reports = useAblo((ablo) => ablo.weatherReports.local.list({ where: { projectId }, filter: (report) => report.status !== 'ready', state: 'live', }), ); ``` ## Server Load ```tsx const report = await ablo.weatherReports.get({ id }); ``` Use `get` in Server Components when the row may not be in the local pool yet — it hydrates from the local store and the server, and returns a Promise, so `await` it. (Server reads come in two shapes: `get({ id })` for one row and `list({ where })` for many; both are async. The synchronous local reads are the `local` reads, used in render below.) ## Writes For Server Actions and route handlers, call the SDK directly: ```ts import { ablo } from '@/lib/ablo'; const snap = ablo.snapshot({ weatherReports: id }); await ablo.weatherReports.update({ id, data: patch, readAt: snap.stamp, onStale: 'reject', }); ``` For client event handlers, get the provider-owned client and call the same model client: ```tsx const ablo = useAblo(); async function markReady() { if (!ablo) return; const snap = ablo.snapshot({ weatherReports: id }); await ablo.weatherReports.update({ id, data: { status: 'ready' }, readAt: snap.stamp, onStale: 'reject', }); } ``` The selector form is for render-time reads. The zero-argument form is for imperative work after an event or effect. See [API reference](/docs/api) for the full options surface. ## useJoin: scoped presence + read interest `useJoin` is the React form of `ablo..join`. It joins multiplayer for a scope on the engine's existing socket (one TCP connection, N logical sub-syncgroup participants) and returns the reactive participant facade. Use it when a mount should both *see* who else is on an entity and, optionally, declare write interest in it. ```tsx 'use client'; import { useJoin } from '@abloatai/ablo/react'; export function CollectionPresence({ workspaceId }: { workspaceId: string }) { const { peers, claims, status } = useJoin({ scope: { entryCollections: workspaceId }, claim: true, // I intend to write — pin the scope + let peers observe the claim hydrate: true, // backfill the workspace's current rows if not already loaded }); if (status !== 'joined') return connecting…; return {peers.length} other{peers.length === 1 ? '' : 's'} here; } ``` Options (`UseJoinOptions`): | Option | Default | Effect | | --- | --- | --- | | `scope` |: | Model-form scope (`{ entryCollections: id }`), resolved through the schema. Omit for engine-wide. | | `claim` | `false` | Acquire a write-claim on the scope (sent so peers observe it; pins the scope so it never warm-drops while held). A viewer is not a claimant: leave `false` for read-only. | | `hydrate` | `false` | Backfill the scope's current rows into the pool once on enter, then keep them fresh via the live tail. Set `true` for deep-linked / never-opened entities. Single-flight; soft-fails. | | `ttlSeconds` |: | Lease TTL for the scope claim. | | `paused` | `false` | Tear down and don't re-join while true. | Returns (`UseJoinReturn`): `{ participant, peers, claims, status, error }`. `peers` is everyone else on the scope's sync groups; `claims` is their active write-claims; `status` is the join lifecycle. Auto-cleans up on unmount or when `paused` flips true. ## usePeers: read-only presence `usePeers` is a *pure reader* of the presence stream already flowing on the connection. Unlike `useJoin`, it does **not** enter/leave a scope (no `update_subscription`, no warm-TTL churn) — so reading it never changes what the connection is subscribed to. ```tsx 'use client'; import { usePeers } from '@abloatai/ablo/react'; export function CursorBroadcaster({ workspaceId }: { workspaceId: string }) { const peers = usePeers({ entryCollections: workspaceId }); const alone = !peers.some((p) => p.participantKind === 'user'); // suppress live-cursor broadcasts while alone } ``` Pass `scope` to narrow to a sync group's peers, or omit it for everyone on the engine's groups. Returns `ReadonlyArray`, where each `Peer` carries `participantKind` (`'user' | 'agent' | 'system'`), `participantId`, optional `label`, `syncGroups`, `activity`, `lastActive`, and optional `activeClaims`. Reach for `usePeers` (not a second `useJoin`) when some **other** mount already owns the scope's read interest — scope `leave` is not reference-counted, so a second `useJoin` on the same scope would warm-drop the owner's subscription on unmount. ## Next.js The Next.js [App Router landing](./examples/nextjs.md) walks through Server Components + Server Actions + `useAblo` together. --- Source: https://www.abloatai.com/api/docs/README.md

Ablo

Collaboration infrastructure for AI agents.

Docs  |  Quickstart  |  API  |  GitHub

npm license node >=24 types included

--- Ablo is collaboration infrastructure for AI agents: one API for agents, apps, and services to claim, change, and confirm the same rows. Every write goes through it, so authority, idempotency, conflicts, ordering, and confirmation are enforced in one place. Your Postgres remains the source of truth. ## Why Ablo Software used to have one writer: a human clicking through an application. AI applications now have humans, agents, workflows, and services acting concurrently. Databases keep transactions consistent. They do not coordinate autonomous work that reads now, reasons for thirty seconds, and writes later. Humans handle this naturally. We see that somebody is editing, agree on who takes which part, wait our turn, and look again before continuing. Ablo gives software actors those same capabilities: bounded authority, shared ownership, fresh context, safe handoffs, and an attributed record of what happened. ## Start ```sh npm install @abloatai/ablo npx ablo init npx ablo dev ``` `ablo dev` prepares an isolated Ablo branch for your Git branch, writes its temporary credential to gitignored `.env.local`, pushes the schema, and watches for changes. Read and write through one typed API: ```ts const order = await ablo.orders.get({ id: orderId }); if (!order) throw new Error('Order not found'); await ablo.orders.update({ id: order.id, data: { status: 'approved' }, }); ``` `confirmed` means the authoritative database reported the change back. The same commit can be retried safely if the caller loses its connection. When work takes thirty seconds instead of one request, coordinate before the agent starts reasoning: ```ts await using claim = await ablo.orders.claim({ id: orderId }); const priced = await pricingAgent(claim.data); await ablo.orders.update({ id: claim.data.id, data: { total: priced.total, status: 'repriced' }, claim, }); ``` Another actor touching the same work waits fairly and receives fresh state when its turn begins. If the agent fails, the claim releases automatically. If its context became stale, the write is rejected instead of silently overwriting work it never saw. If you use AI SDK, expose the same operation as a typed model tool: ```ts import { updateTool } from '@abloatai/ablo/ai-sdk'; const approveOrder = updateTool(ablo.orders, { description: 'Approve an order after reviewing it.', inputSchema: z.object({ orderId: z.string() }), id: ({ orderId }) => orderId, apply: () => ({ status: 'approved' }), }); ``` Ablo supplies `readTool`, `createTool`, `updateTool`, and `deleteTool` over the same authoritative resources. AI SDK keeps ownership of the model loop and tool execution. For a model call that needs several reads plus application-owned retrieval or memory, [`context()`](./docs/context.md) awaits the selected values and carries the exact Ablo rows into the write's `reads` option. It does not add search, memory, or a model runtime. Use `@abloatai/ablo` for agents and backend code, `@abloatai/ablo/client` for live applications, and `@abloatai/ablo/react` for React. All entrypoints share the same schema, authority, commits, claims, and ordered changes. Read the [Quickstart](https://docs.abloatai.com/quickstart), browse [docs.abloatai.com](https://docs.abloatai.com), or run `npx ablo docs`. ## Navigating the source This repository preserves the package ownership boundaries instead of flattening the implementation into `packages/ablo`: - `packages/ablo` is the branded public facade. Its files mostly re-export the package that owns each API. - `packages/transaction` owns the shared model-operation contracts and the stateless HTTP implementation. - `packages/humans` owns the reactive WebSocket/local/React implementation. That means searching only inside `packages/ablo/src` will not find the implementation of `create`, `update`, `delete`, or `claim`. Read the **[source code map](./CODEMAP.md)** for a verb-by-verb ownership table and guided call traces for both the default and reactive clients. ## Contributing Ablo is free and open source. You can help by [opening an issue](https://github.com/Abloatai/ablo/issues), [suggesting a feature](https://github.com/Abloatai/ablo/issues/new), or [contributing code](https://github.com/Abloatai/ablo/pulls). Please report vulnerabilities privately through [GitHub Security Advisories](https://github.com/Abloatai/ablo/security/advisories/new). ## License Apache License 2.0. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE). --- Source: https://www.abloatai.com/api/docs/schema-contract # Schema Contract > One schema becomes typed clients, agent writes, interface reads, and the hosted push. Ablo's schema is the integration contract. Define it once, pass it to `Ablo(...)`, and every actor gets the same typed model surface: ```txt defineSchema(...) -> ablo..create/get/update/claim(...) ``` That one object drives: - typed model clients in trusted server runtimes, - React selectors through `useAblo((ablo) => ablo..local.get(id))`, - agent and background-worker writes, - Data Source request/response shape when your database stays canonical, - hosted schema push, migration planning, and schema-version gating. ## Minimal shape ```ts import Ablo from '@abloatai/ablo'; import { defineSchema, model, z } from '@abloatai/ablo/schema'; export const schema = defineSchema({ weatherReports: model({ location: z.string(), status: z.enum(['pending', 'ready']), forecast: z.string().optional(), }), }); export const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, }); await ablo.ready(); const report = await ablo.weatherReports.create({ data: { location: 'Stockholm', status: 'pending', }, }); ``` The model key (`weatherReports`) becomes the client namespace (`ablo.weatherReports`). The Zod fields become the create/update/read type contract. You should not create a parallel string-keyed write path for the same data. ### Reserved fields The SDK provides these on every row automatically — do **not** declare them in your `model(...)` fields: - `id` - `createdAt` - `updatedAt` - `organizationId` - `createdBy` Declare only your own fields; the reserved ones are still present on the row and readable, you just don't author them. ## Reads and writes Use async reads when the row may not be local: ```ts const report = await ablo.weatherReports.get({ id: reportId }); const ready = await ablo.weatherReports.list({ where: { status: 'ready' } }); ``` Use synchronous local reads in render after data has synced: ```ts const report = ablo.weatherReports.local.get(reportId); const pending = ablo.weatherReports.local.list({ where: { status: 'pending' } }); ``` Use model writes for every actor: ```ts await ablo.weatherReports.update({ id: reportId, data: { status: 'ready' } }); ``` ## Coordination Agents and background jobs often read, call a tool or model, then write later. Wrap that slow span in `claim`: ```ts const handle = await ablo.weatherReports.claim({ id: reportId }); const forecast = await getForecast(handle.data.location); await ablo.weatherReports.update({ id: handle.data.id, data: { status: 'ready', forecast } }); await handle.release(); ``` If another writer already holds the row, `claim` waits, re-reads, and hands you the fresh row. Reads stay open; only acting on the row serializes. ## Storage boundary Every schema model is backed by your own database, and you write to it through `ablo.`. There are three start states, all covered in [Connect Your Database](./data-sources.md) (the single source of truth): a development branch with no database yet (`apiKey` only — Ablo keeps that branch's rows in its own log), `npx ablo connect` (a scoped writer role plus logical replication, so Ablo writes your rows and confirms them over the WAL), or a signed Data Source endpoint when your database can't grant replication. Your database connects out of band, so the client holds only `ABLO_API_KEY` — never a connection string. Browser code goes through `` or a scoped session route, never a raw API key. ## Rules of thumb - Start with fields and relations before load/index tuning. - Import one schema into app code, server actions, agents, and Data Source routes. - Keep direct database writes out of the coordinated path unless they are reported back through Data Source events. - Use `claim` for slow read -> think -> write spans. - Use `readAt` + `onStale: 'reject'` when a write must fail if the row changed after it was read. For the shortest runnable path, start with [Quickstart](./quickstart.md). For a production app, continue with [Integration Guide](./integration-guide.md). --- Source: https://www.abloatai.com/api/docs/session-settings # Session Settings > Point your row-level-security policies at Ablo's writes, by naming the Postgres session settings they already read. Ablo writes your rows through a scoped role with `row_security` on and `NOBYPASSRLS` set, so your policies govern its writes the same way they govern your own application's. They only govern well if they can see who the write is for. Before each write, Ablo sets its own identity context on the transaction — and if your policies read settings under names you chose, `sessionSettings` maps one to the other: ```ts import { defineSchema, model, z } from '@abloatai/ablo/schema'; export const schema = defineSchema( { invoices: model({ total: z.number(), reference: z.string(), }), }, { sessionSettings: { 'app.current_org': 'orgId' }, }, ); ``` A policy written against `current_setting('app.current_org')` now applies to Ablo's write, unchanged: ```sql CREATE POLICY tenant_isolation ON invoices USING (organization_id = current_setting('app.current_org', true)); ``` The key is the setting name **your** policies read. The value names which piece of Ablo's authenticated identity fills it. ## What Ablo sets on its own Every direct write runs inside a transaction that begins by setting this context, whether or not you map anything: | Setting | Carries | | --- | --- | | `app.current_org_id` | The organization the credential acts for | | `app.current_project_id` | The project | | `app.current_environment` | The key's test/live trust class (`sandbox`/`production` on this compatibility surface) | | `app.current_sandbox_id` | Legacy compatibility coordinate for older test planes; not a branch selector | | `app.current_participant_id` | The participant making the write | | `app.current_participant_kind` | Whether that participant is a person, an agent, or the system | | `app.current_user_id` | The person on whose behalf the write is made | | `app.current_subject_groups` | The subject groups the caller belongs to, as a JSON array of `group:value` strings | If your policies read these names directly, you need no mapping at all — this page is for the case where they read different ones. For a mutable model with row-local tenancy, Ablo also puts the authenticated organization into the CREATE operation as `organizationId`; the source adapter maps that field to the model's tenancy column (normally `organization_id`). Callers do not supply it, and the column does not need a SQL default. A default from `app.current_org_id` can be useful as defense in depth, but it is not a substitute for the tenant policy and you should never loosen RLS to make an Ablo write pass. `app.current_subject_groups` is the one a model with a `subject` rule reads. A subject rule names a field and a group — `subject: { field: 'teamId', group: 'team' }` — and Ablo provisions a policy asking whether the array contains `team:` followed by that row's value. The setting is always present, and it is `[]` when the caller belongs to no group, so a policy on a pooled connection reads an empty membership as an empty membership rather than inheriting what the previous transaction left behind. `app.current_user_id` is worth reading twice, because it has three states rather than two. It carries a person's id when a person is behind the write. It carries `*` when a backend credential is acting as the organization itself, which is the authority a server-side key holds. And it carries the empty string when no identity could be established — written explicitly rather than left alone, so a policy on a pooled connection reads absence as absence and denies, instead of inheriting whatever the previous transaction left behind. ## What a mapping may name The value side is a closed set, and every member is resolved by Ablo from the authenticated key and the plane: `orgId` · `projectId` · `environment` · `sandboxId` · `participantId` · `participantKind` `environment` and `sandboxId` retain their published names for compatibility with existing RLS policies. Current development isolation comes from the credential's immutable branch binding; callers do not choose a sandbox or branch through session settings. Because none of them come from the caller, a mapping can forward the tenant identity Ablo already trusts, but cannot widen what a writer sees. A setting name takes exactly one source — the name is the key — so naming the same setting twice is unrepresentable rather than something to resolve later. ## What a mapping may not name The settings in the table above, along with `row_security`, `search_path`, `statement_timeout`, and `lock_timeout`, are reserved. `defineSchema` rejects a mapping onto any of them, and the engine refuses one at write time too. The reason is worth stating plainly: those settings are how Ablo bounds its own write. A schema that could reassign them could relax the scoping under which Ablo writes, which would put the boundary inside the thing being bounded. Point your policies at a name you own — `app.current_org` rather than `app.current_org_id` — and map that. ## Reads served from the log One arrangement cannot honour a policy that names a person. When a plane is served from its retained log rather than from its tables, every row carries the organization and the project, but not the owner — so a rule about who owns a row has nothing to act on. Rather than fold those rows and return a plausible answer, such a read is declined whole with `user_scope_not_enforced`: a member reading a colleague's private records would otherwise be indistinguishable from a member reading their own. Reads made by a credential acting for the organization are unaffected, as is every plane served from its tables. ## Related - [Data Sources](./data-sources.md) — the writer role's privileges, and what it can and cannot do to your database. - [Operating on Your Database](./operating-on-your-database.md) — which actions are read-only, which are reversible, and which belong to a human. --- Source: https://www.abloatai.com/api/docs/sessions # Sessions > Short-lived scoped credentials your backend mints for a browser or an agent. A **session** is a short-lived credential your backend mints with its `sk_` and hands to one actor — a signed-in **person's browser** or a scoped **agent**. It's the same primitive in both cases (backend-minted, short-lived, scoped); the only difference is the subject and how much authority it carries. One resource mints both: ```ts Your backend (sk_) // A logged-in person's browser session — only the operations this UI needs. const userSession = await ablo.sessions.create({ user: { id: currentUser.id }, can: { records: ['read', 'update'], workspaces: ['read'] }, }); // Recommended agent path — returns a ready, scoped client. const agent = await ablo.agents.create({ name: 'record-writer', can: { records: ['read', 'update'], workspaces: ['read'] }, }); ``` `sessions.create({ user, can })` mints an `ek_` (ephemeral key). `agents.create({ can })` mints and manages an `rk_` (restricted key). Use the lower-level `sessions.create({ agent, can })` only when another runtime needs the raw agent token. It exists because of one rule: **the browser can never hold a secret.** Your `sk_` lives on the server; the browser only ever holds a minted session token (which already names your org). So the per-actor credential is minted server-side, scoped, and expires in minutes. ## Why Ablo doesn't authenticate your users — you do, however you like (your own sessions, an IdP, anything). Ablo authenticates your **project** (the `sk_` that minted the session) and trusts the identity you asserted at mint time. The session token *is* that assertion: "this connection is acting as `U`, in org `O`, until it expires." ## End-user sessions (`ek_`) For a logged-in person using your app. Mint on a backend route that has already authenticated the user: ```ts Your backend route (session-authed) import { credentialEndpointSuccessSchema } from '@abloatai/ablo/auth'; const { token, expiresAt } = await ablo.sessions.create({ user: { id: currentUser.id }, // who the session acts as can: { records: ['read', 'update'] }, // syncGroups: [...], // optional; defaults to the user's org + user }); return Response.json( credentialEndpointSuccessSchema.parse({ token, expiresAt, credentialKind: 'ephemeral', }), { headers: { 'Cache-Control': 'no-store' } }, ); ``` `can` is required. The mint fails closed when the grant is absent or empty. Point a browser `Ablo` client's `authEndpoint` at that route, and pass the **instance** to [``](/react). The client fetches the token, opens the connection, and re-mints before expiry — your app writes no token plumbing: ```tsx 'use client'; import Ablo from '@abloatai/ablo'; import { AbloProvider } from '@abloatai/ablo/react'; import { schema } from '@/ablo.schema'; const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session', }); export function Providers({ children }: { children: React.ReactNode }) { return {children}; } ``` The client owns auth, the credential lifecycle, and the connection; the provider is only the thin reactive binding over it. Build the client **once** at module scope — a new instance per render tears down the socket. Need custom headers or a body on the exchange? `authEndpoint` also accepts an async resolver that returns the token. ## Agents (`rk_`) For a non-human actor — an agent or automation that should only do **specific** operations. The `can` map is the permission boundary, and it's **typed against your schema** — the model keys are your schema's models, so a typo is a compile error, not a silent over-grant: ```ts const agent = await ablo.agents.create({ name: 'record-writer', can: { records: ['update'] }, // typed off the schema — no magic strings ttlSeconds: 600, }); await agent.records.update({ id, data }); await agent.dispose(); ``` The returned client refreshes its own short-lived credential. A write grant automatically includes the corresponding read, so `can: { records: ['update'] }` is enforced as `record.update` plus `record.read`. Operations are `'read' | 'create' | 'update' | 'delete'`. For a reusable grant, use TypeScript's `satisfies`. It checks the object against the schema while preserving its narrow literals—there is no string parser or second permission model: ```ts import type { CapabilityGrant } from '@abloatai/ablo/auth'; import { schema } from './ablo.schema'; const recordWriterCan = { records: ['update'], } satisfies CapabilityGrant; const agent = await ablo.agents.create({ can: recordWriterCan }); ``` `documents` instead of `records`, or `'write'` instead of `'update'`, is a compile error. At runtime the SDK parses the same grant with the schema-bound Zod contract before minting, and the server validates it again against the active pushed schema. Use `sessions.create({ agent, can })` when a separate runtime, MCP process, or protocol integration needs the raw token. For an agent running in the current server process, prefer `agents.create({ can })`. ## Mint Only a **secret key** (`sk_`) can mint a session — never another session token. The `sk_` is the trust anchor; minting is your backend vouching for the actor. | Param | For | Meaning | |---|---|---| | `user` / `agent` | both | The actor. `id` becomes the token's `participantId`. Pass exactly one. | | `can` | both | Required non-empty per-model operation allowlist, typed off the schema. | | `organizationId` | user | Mint into a customer organization instead of the key's own. Requires `organization:act-as`. | | `schemaProject` | user | Override the schema project for a cross-org mint. Usually omitted because the owning key's project is the default. | | `syncGroups` | both | Narrow the session below its default scope. Omit to inherit. | | `ttlSeconds` | both | Lifetime in seconds. Defaults to `900` (15m). | | `userMeta` | both | Opaque identity blob echoed back to the client. | ## Lifecycle Sessions are **short-lived by design** (~15 minutes) and, for browsers, **auto-refreshed** — the provider re-mints ahead of expiry, so a session never drops at the boundary. Signing out stops refresh and the old token expires on its own. Revoke immediately when a token is exposed or an actor loses access: ```ts await ablo.sessions.revoke({ id: session.id }); ``` Agent sessions can rotate with overlap so a worker can adopt the replacement before the previous token expires: ```ts const replacement = await ablo.sessions.rotate({ id: session.id, graceSeconds: 300, ttlSeconds: 900, }); ``` Browser `ek_` sessions rotate through `authEndpoint`; do not distribute rotated browser tokens manually. ### Offline & sign-out The short session token is **not** your user's login — it's a minutes-long credential layered on top of whatever long-lived auth your `authEndpoint` already enforces (your own session cookie, an IdP, etc.). The provider keeps those two lifetimes separate, which means: - **Going offline never signs the user out.** The provider keeps working from its local cache and treats a failed re-mint (no network, a timeout, a `5xx` from your endpoint) as **transient** — it retries, and re-mints the instant connectivity or tab focus returns. The user stays signed in for as long as your underlying session is valid, however brief or long the network drop. - **The user is signed out only when the underlying session is genuinely gone** — your `authEndpoint` responds `401` with the canonical `{ error: { code: 'session_expired' } }` body. An unrelated `401` or `403` is a policy/configuration failure, not proof that the login ended. This mirrors the OAuth refresh-token rule (Okta/Auth0/Authgear): only a rejection of the *long-lived* credential ends the session — a network failure never does. Use `credentialEndpointSuccessSchema` and `credentialEndpointErrorSchema` from `@abloatai/ablo/auth` on the route. Return `session_expired` only when the application login is actually gone, and let network/`5xx` failures surface as errors. ## Scope A user session carries the user's **base** sync-groups (`org:`/`user:`/`team:`), derived from the identity you minted it for. **Dynamic, relation-driven membership** (e.g. a `archive:` the user was just added to) is resolved **server-side at connect** and unioned on top — so scope stays live, not frozen at mint time. Pass `syncGroups` only when you want to *narrow* below the default. ## Your schema, your users (the default) Your schema lives in a **project** — you push it once (`npx ablo push`) and every session you mint resolves against it. The flow for serving end-users: 1. **Push your schema** to your project. 2. **Mint an `ek_` per user:** `sessions.create({ user: { id }, can })`. Your users commit to that one schema with only the operations in `can`. **Your users do not have Ablo accounts.** You authenticate them however you already do; your server's `sk_` mints the `ek_`. By default the session lands in your project's own org, so all your users share one schema and one data tenant, isolated from each other by sync-groups. For most apps (the Cursor shape) that's the whole story — nothing below is needed. ## Org-per-customer isolation (the add-on) Some apps need each customer to be its **own** tenant — a hard data boundary (separate row-level isolation, optionally a separate database), not just per-user scoping. The law-firm shape (Legora): every firm is its own org, many users inside it. Choose the boundary before minting sessions: | Customer model | Isolation guarantee | Use when | |---|---|---| | One Ablo organization, customer scope roots | Every model's declared `policy` | Cross-customer access is intentional or every model explicitly partitions by the customer root | | One Ablo organization per customer | Structural organization filtering and RLS on every row | Customers must be isolated even when a model has no customer policy | Sync-group routing controls which changes are delivered; it does not grant or deny reads. Do not use scope roots as a tenant security boundary unless every model declares the matching policy. If that invariant is difficult to audit, use one organization per customer. For the complete key, backend-route, browser, lifecycle, and troubleshooting flow, see [Customer Organizations](./customer-organizations.md). The problem that creates: if each customer is a separate org, a naïve setup would make you re-push your schema into every new customer's org. You don't have to. Keep **one** project as the home of your schema. When its key mints into another organization, Ablo automatically resolves the session's *schema* from that key's project while its *data* stays in the customer's own org: ```ts const ablo = Ablo({ schema, apiKey: process.env.ABLO_PLATFORM_KEY }); const { token } = await ablo.sessions.create({ user: { id: userId }, organizationId, // DATA → this customer's isolated org can: { records: ['read', 'update'] }, ttlSeconds: 3600, }); ``` For migrations or advanced routing, `sessions.create` also accepts an explicit `schemaProject: { organizationId, projectId }` override. Server-side the split is clean: the model **shape** loads from your schema project, but column enrichment and the tenant connection target the customer's `organizationId` — so the shared schema only *describes* the shape; the data plane (connection + row-level isolation) stays the customer's. A shared schema can't leak data across orgs. This requires a dedicated `sk_` carrying the `organization:act-as` scope — only a trusted cross-organization key can mint a session into another org. Omit `organizationId` and you get the default above: one project, one schema, all your users in the key's own organization. ## Security The whole safety argument is the short TTL: a session token leaked from a browser (XSS) is valid for minutes, scoped to one actor's data, and can't mint anything or touch the control plane. Contrast `sk_`, which would be a full org compromise — which is exactly why it never leaves your server. ## User vs. agent sessions | | User session (`ek_`) | Agent session (`rk_`) | |---|---|---| | For | a **person** in the browser | an **agent** / automation | | Authority | narrow (explicit `can` allowlist) | narrow (explicit `can` allowlist) | | Mint | `ablo.sessions.create({ user: { id }, can })` | `ablo.sessions.create({ agent: { id }, can })` | | Lives where | the user's **browser** | the agent runtime | --- Source: https://www.abloatai.com/api/docs/webhooks # Webhooks > Stream the committed transaction log to your own systems as signed events. Ablo keeps an ordered transaction log of every committed change and coordinates the writers — agents, and the people alongside them — that produce it. Your rows live in your own database; Ablo holds only the log. **Webhooks stream that log to your systems as signed events:** every committed change is POSTed to an endpoint in your app, and your handler decides what to do with it. You call Ablo to make changes through the client, and Ablo calls your webhook with each committed change. Webhooks are the *push* way to keep a store in step with the log — your own database, a warehouse, a search index, a background job. The *direct* alternative is `ablo connect`, where Ablo reads your write-ahead log and writes back through a scoped role. Either way your handler owns the write: the webhook path gives Ablo no database credentials at all. ## The loop ![Your app writes to Ablo's ordered log — the source of truth, which holds the log and coordination only, never your rows. The log fans out two ways: realtime sync to live clients for an instant, optimistic UI, and a signed event to your webhook route, which writes the durable copy into your own database.](/the-loop.svg) There are two ways data flows out of Ablo, and they're for different jobs: | | reaches | use it for | |---|---|---| | **Realtime** (`useAblo`, WSS) | your live UI | instant, optimistic rendering | | **Webhooks** (this page) | your database | a durable copy you own: analytics, backups, server logic | Most apps use both: the realtime stream for the UI, the webhook stream to keep their database in sync. This page is the webhook stream. Every event carries `syncId`, a monotonic log position, so you can both dedupe **and** apply in order. Ablo guarantees the order because it owns the log. ## The event object Every delivery is a batch of events. Each event: | field | meaning | |---|---| | `type` | `"."` with the model name lowercased, e.g. `record.updated` | | `model` | the model name exactly as declared in your schema: the table to write | | `objectId` | the changed row's id | | `data` | the post-change row, or `null` on delete | | `syncId` | monotonic log position: **dedupe and order by this** | | `id` | `String(syncId)`: the event id | | `createdAt` | ISO commit timestamp | ```ts import type { AbloWebhookEvent } from '@abloatai/ablo/webhooks'; ``` ## 1. Create a handler `npx ablo init` scaffolds this for you at `app/api/ablo/[...all]/route.ts`. If your project uses Prisma it's a **working generic mirror** — one upsert/delete for every model, no per-model code. Otherwise it's a neutral route with a single place to plug your database in. ```ts app/api/ablo/[...all]/route.ts import { Webhook } from 'svix'; // any Standard Webhooks library import type { AbloWebhookEvent } from '@abloatai/ablo/webhooks'; import { PrismaClient } from '@prisma/client'; const wh = new Webhook(process.env.ABLO_WEBHOOK_SECRET!); const prisma = new PrismaClient(); type ModelDelegate = { upsert(a: { where: { id: string }; create: Record; update: Record }): Promise; delete(a: { where: { id: string } }): Promise; }; export async function POST(req: Request): Promise { const body = await req.text(); // RAW body — required to verify let batch: { data: AbloWebhookEvent[] }; try { batch = wh.verify(body, Object.fromEntries(req.headers)) as { data: AbloWebhookEvent[] }; } catch { return new Response('invalid signature', { status: 400 }); } const delegates = prisma as unknown as Record; for (const event of [...batch.data].sort((a, b) => a.syncId - b.syncId)) { const model = delegates[event.model]; if (!model) continue; // a model you don't mirror — skip if (event.data === null) { await model.delete({ where: { id: event.objectId } }).catch(() => {}); } else { await model.upsert({ where: { id: event.objectId }, create: event.data, update: event.data }); } } return new Response(null, { status: 200 }); // 2xx = delivered } ``` You only edit this if your tables **diverge** from Ablo's schema (renamed columns, extra side effects) — add a `case` for that model before the generic mirror. If your tables match Ablo's, you never touch it. Return a `2xx` quickly. Any other status is a failure and is retried. Do heavy work asynchronously after responding. ## 2. Test locally `npx ablo dev` prepares an isolated schema branch; it does not forward webhook deliveries. Register an HTTPS endpoint that Ablo can reach. For local handler development, expose your app with the HTTPS tunnel your team already trusts, then register that temporary URL and remove the endpoint when you finish. ## 3. Register your endpoint For a deployed URL, register it once. Ablo mints the signing secret and returns it a single time — the CLI writes it straight into your `.env.local`. ```bash npx ablo webhooks create https://yourapp.com/api/ablo/[...all] # ✓ Registered we_… → https://yourapp.com/api/ablo/[...all] # ✓ Wrote ABLO_WEBHOOK_SECRET to .env.local (shown once) ``` Scope which models fire and label the endpoint at creation with `--events` and `--description` (both optional; events default to `*` = every model): ```bash npx ablo webhooks create https://yourapp.com/api/ablo/[...all] \ --events record,project --description "prod mirror" ``` Manage and inspect endpoints: ```bash npx ablo webhooks list # endpoints + delivery health (status, cursor, last error) npx ablo webhooks roll # mint a fresh signing secret npx ablo webhooks enable # re-enable a disabled endpoint npx ablo webhooks rm # remove an endpoint ``` Or call the API directly — the org is derived from your secret key: ```bash curl https://api.abloatai.com/api/v1/webhook_endpoints \ -H "authorization: Bearer $ABLO_API_KEY" \ -H "content-type: application/json" \ -d '{ "url": "https://yourapp.com/api/ablo/[...all]" }' # → { "id": "we_…", "secret": "whsec_…", "status": "enabled", ... } ``` ## 4. Verify the signature Ablo signs every request with the [Standard Webhooks](https://www.standardwebhooks.com) scheme (the spec Svix authored). Verify with any compatible library — `svix` or `standardwebhooks` — using the secret from registration. Ablo ships no verification code of its own; you use the open library. ```ts const wh = new Webhook(process.env.ABLO_WEBHOOK_SECRET!); const event = wh.verify(rawBody, Object.fromEntries(req.headers)); ``` Verification checks three headers — `webhook-id`, `webhook-timestamp`, `webhook-signature` — and rejects a timestamp outside a 5-minute window (replay protection). Always verify against the **raw** request body. ## Event delivery Ablo delivers from a per-endpoint **cursor** over the log, advancing only on a `2xx`. A failed delivery leaves the cursor in place, so the same events are re-sent until they land — at-least-once, in order. ```mermaid sequenceDiagram participant L as Ablo log participant W as Delivery worker participant E as Your endpoint W->>L: read events after cursor L-->>W: events (ordered by syncId) W->>E: POST signed batch alt 2xx E-->>W: 200 W->>L: advance cursor else non-2xx / timeout E-->>W: error Note over W,E: retry with backoff
5s → 5m → … → 10h (8 attempts)
then auto-disable end ``` | behavior | how it works | |---|---| | **Ordering** | Every event carries `syncId`, a monotonic log position. Apply in `syncId` order: Ablo guarantees the order because it owns the log. | | **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. | | **Auto-disable** | After the retries exhaust, the endpoint is marked `disabled` and delivery stops until you `ablo webhooks enable `. | | **Replay** | Nothing is lost on failure: the log *is* the durable buffer, and delivery resumes from the endpoint's cursor once it's healthy. | ## Best practices - **Dedupe by `syncId`.** Skip any `syncId` you've already stored. Delivery is at-least-once, so the same event can arrive twice after a retry. - **Apply in `syncId` order.** It's the log position; sort each batch by it. - **Return `2xx` fast.** Acknowledge first, then do slow work asynchronously. - **Subscribe to only what you need.** Set `enabledEvents` to the models you mirror (`['*']` is the default = all). - **Roll secrets periodically:** `ablo webhooks roll ` mints a new secret; Standard Webhooks supports a rotation window so in-flight events still verify. - **Verify the raw body.** Frameworks that re-serialize JSON will break the signature — verify the bytes you received. ## Event types The verb is derived from the change: | type | when | |---|---| | `.created` | a row was inserted | | `.updated` | a row was updated | | `.deleted` | a row was deleted (`data` is `null`) | | `.archived` | a row was soft-archived | | `.unarchived` | a soft-archived row was restored | Internal coordination changes (permissions, sync groups) carry no webhook — only your data models produce events.