ablo.<model>.update(...) call for a React
component, a server action, a background worker, or an agent — and reconciles the
edits. This guide adds it to a product that already has a backend and 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:ablo.<model>. React, server actions, backend workers, and agents should all use
that same model path.
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 — keepDATABASE_URL inside your app and expose a signed Data Source endpoint: Ablo
coordinates each write and your app commits it to your Postgres. Do not pass
databaseUrl to Ablo(...) here; application and agent code use ABLO_API_KEY.
If instead you want Ablo to connect to your Postgres directly, pass databaseUrl
(a live, server-only option) to Ablo(...). That and the sandbox-only apiKey
shape are the other two start states — Connect Your Database
is the single source of truth for all three.
Test With Sandboxes
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 the authenticated org dashboard sandbox for real integration work. The
default sandbox is the equivalent of Stripe test mode:
- it is scoped to the organization,
- it has an isolated sync group prefix,
- it mints
sk_test_*keys, - it can be reset without touching live state,
- additional sandboxes can start blank or from copied live configuration.
sk_test_* while wiring your app,
agents, and Data Source endpoint; move to sk_live_* only when the same schema
and write path are ready for production.
When handing this to a coding agent, give it a concrete target:
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.Declaring scope on a model
Canonical reference: Identity & Sync Groups. This is the short version —Per-row tenancy and per-entity sync-group anchors live on thescope(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.
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.
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/sync-engine/src/schema/model.ts for the full option set.
2. Create The Client
Trusted runtimes can useABLO_API_KEY.
AbloProvider takes { client, userId?, onError?, fallback? }, and
nothing else (schema, teamIds, and apiKey all live on the
client now).
Why two credential shapes
ABLO_API_KEY is your long-lived account credential. Treat it like a
Stripe secret key: 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:
3. Read State
Reads come in two flavors, and you pick based on whether you can wait.retrieve({ id }) and list({ where }) hit the server (and hydrate the local
store) — they’re async, so you await them. get(id) (positional),
getAll({ where }), and getCount({ 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 retrieve/list.
Use retrieve when the row may not be local yet — it fetches from the server
and waits.
get, getAll, and getCount for synchronous local-graph reads after
data has synced.
useAblo is the public read API:
useAblo() only in callbacks and effects:
4. Write State
For simple writes:wait: 'confirmed' resolves after the server accepts the write. Rejections roll
back optimistic local state and throw 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:readAt.
Direct writes to your own database bypass that stream until your app reports the
change 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.- Declare schema for one model, such as
reports. - Keep existing server loads for first paint.
- Add
useAblo((ablo) => ablo.weatherReports.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(...).
7. Data Source Endpoint
Use a Data Source when your app database remains the source of truth. Wire the route withdataSourceNext and an adapter — prismaDataSource(prisma, schema)
or drizzleDataSource(db, schema). You don’t hand-write commit; the adapter
owns transactional commit, idempotency, and reads.
drizzleDataSource(db, schema) instead — the adapter takes
your Drizzle db and the Ablo schema (not your table objects):
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.
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. |
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. |
Method Cheatsheet
| Method | Use it for |
|---|---|
retrieve({ id }) | Async read of one row from the server (await it). |
list({ where }) | Async read of many rows from the server (await it). |
get(id) | Synchronous local read of one synced row (positional id; use in render). |
getAll({ where }) | Synchronous local read of many synced rows. |
getCount({ 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, reason?, ttl? }) | Acquire a disposable handle: wait for your turn, re-read, and hold the row. |
get(id) stays
positional.