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.<model>.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.<model>.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:123in client code. The server derives what each caller can see from their authenticated identity, using theidentityRolesyou declare once in the schema. The client just names which model and id it wants. Theorg:/user:/team:(or your ownregion:/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:
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.<model>. React, server actions, backend workers, and agents should all use
that same model path.
schema -> ablo.<model>.list(...) -> ablo.<model>.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.<model>; 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
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:
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.<model>.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.
// 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. This is the short version —
scope(root),parent(containment),grants(membership), and the model-formscopeprop 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.
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:<id>`. 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: '<fk>', parent: '<parentTable>' }.
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.
// src/client/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).
// 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',
});
// app/providers.tsx
'use client';
import { AbloProvider } from '@abloatai/ablo/react';
import { ablo } from '@/ablo-client';
export function Providers({ children }: { children: React.ReactNode }) {
return <AbloProvider client={ablo}>{children}</AbloProvider>;
}
The session route mints the scoped token server-side, where the API key lives:
// 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.
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.
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:
'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 <button disabled={Boolean(active) || report.status === 'ready'}>{report.location}</button>;
}
Use zero-argument useAblo() only in callbacks and effects:
const ablo = useAblo();
4. Write State
For simple writes:
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:
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:
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.<model>.
On the Data Source endpoint fallback 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.
Button
-> ablo.weatherReports.update(...)
-> Ablo
-> signed Data Source request
-> existing backend service
-> app database
-> Ablo realtime fanout
The migration can be gradual:
- Declare schema for one model, such as
reports. - Keep existing server loads for first paint.
- Add
useAblo((ablo) => ablo.weatherReports.local.get(id)) ?? serverReportfor live rows. - Add one Data Source endpoint that calls the existing service layer.
- Move one mutation button from
fetch('/api/reports/...')toablo.weatherReports.update(...). - Add an outbox/events path for writes that still happen outside Ablo.
- Let agents use the same
ablo.weatherReports.list(...)andablo.weatherReports.update(...).
For the full Python shape, see Existing Python Backend.
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.
// 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):
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:
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.
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.
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.