Context is essential for AI agents, but I think shared state is the next problem
Ablo is a typed sync engine for shared app state that humans, server code, and AI agents edit concurrently. It offers real-time updates, prevents silent overwrites, coordinates agent work with claims, and integrates with React. This article covers core features, quick start, read/write operations, agent coordination, React integration, identity sync groups, HTTP writes, and database connection.
Notifications You must be signed in to change notification settings
Fork 0
Star 6
BranchesTags
Open more actions menu
Folders and files
NameName
Last commit message
Last commit date
Latest commit
History
1 Commit
1 Commit
.github/workflows
.github/workflows
docs
docs
examples
examples
scripts
scripts
src
src
.gitignore
.gitignore
.npmignore
.npmignore
CHANGELOG.md
CHANGELOG.md
LICENSE
LICENSE
NOTICE
NOTICE
README.md
README.md
llms-full.txt
llms-full.txt
llms.txt
llms.txt
package.json
package.json
tsconfig.build.json
tsconfig.build.json
tsconfig.json
tsconfig.json
tsconfig.test.json
tsconfig.test.json
Repository files navigation
Ablo is a typed sync engine for shared app state — the kind that humans, server code, and AI agents all edit at once.
Reach for it when those edits need to show up everywhere in real time, not silently overwrite each other, expose who's working on what, and leave a record of who changed what.
schema -> ablo..create/retrieve/update/claim(...)
Why Ablo
Real-time by default. Every create / update / delete fans out confirmed deltas to all subscribers — humans and agents — with no separate "multiplayer mode" to switch on.
No silent clobbers. Writes are guarded against stale reads, and claim holds a row across a slow read → LLM → write gap so concurrent edits queue instead of overwriting.
Built for agents. See who's mid-edit (claimState / queue), coordinate a fair line, and ship an llms.txt so coding agents integrate from the real API.
Typed end to end. Your Zod schema produces typed model proxies (ablo..update(...)), optimistic local reads, and reactive React hooks.
Bring your own auth and database. Ablo scopes realtime data to sync groups from your existing identity, and can leave your database as the source of truth via a Data Source.
Built for: collaborative editors, AI agent workflows, internal tools, and any app where multiple actors mutate shared state and everyone must see it live.
Set up
npm install @abloatai/ablo
Keys & runtime. Ablo needs Node 22+ and TypeScript 5+. Grab an sk_test_* key for a sandbox (export ABLO_API_KEY=sk_test_...); keep keys in trusted server runtimes only. In the browser, authenticates with the signed-in user's session — never the raw key.
Then wire it by hand — the Quick Start below is the shape to copy. For production (React, an existing backend, Data Source, agents), the Integration Guide is the deeper map.
Prefer to let an agent wire it? The package ships an llms.txt — a precise map of the API — so Claude Code or Cursor integrates from the real surface instead of guessing:
Read node_modules/@abloatai/ablo/llms.txt, then add an Ablo schema, a , and my first create / retrieve / update.
Quick Start
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, });
await ablo.ready();
const created = await ablo.weatherReports.create({ location: 'Stockholm', status: 'pending', });
const updated = await ablo.weatherReports.update(created.id, { status: 'ready', forecast: 'Light rain, 13C', });
console.log({ id: updated.id, status: updated.status });
await ablo.dispose();
Expected output:
{ id: '...', status: 'ready' }
Pass schema to get typed models like ablo.weatherReports.update(...).
Reading
retrieve(id) returns one row from the local cache — synchronous, no round-trip. list(...) filters and sorts what's already synced; it's also synchronous, and reactive under useAblo/subscribe. load(...) fetches from the server when a row may not be local yet.
ablo.weatherReports.retrieve('report_stockholm');
const pending = ablo.weatherReports.list({ where: { status: 'pending' }, orderBy: { location: 'asc' }, limit: 20, });
const ready = await ablo.weatherReports.load({ where: { status: 'ready' }, type: 'complete', });
An array value in where means IN. On load, type: 'complete' waits for the server; 'unknown' returns what's local now and refreshes in the background.
Writing
create / update apply optimistically and resolve to the row. Two options matter day to day:
Option Values What it does
wait 'queued' | 'confirmed' 'confirmed' resolves only after the server acks the write; 'queued' resolves as soon as it's locally queued (fire-and-forget).
idempotencyKey string Auto-generated per call. Override only when you own the retry boundary (e.g. a job id) so a re-run dedupes server-side.
await ablo.weatherReports.update(id, { status: 'ready' }, { wait: 'confirmed' });
To guard a write against a row that changed under you, pass readAt + onStale — see Coordinating long agent work.
Coordinating long agent work
An agent reads a row, thinks for 30s, writes back — and clobbers whatever changed meanwhile, or worse, acts on stale state. claim holds the row across that gap:
await ablo.weatherReports.claim('report_stockholm', async (report) => { const forecast = await weatherAgent.getWeather(report.location); await ablo.weatherReports.update(report.id, { forecast, status: 'ready' }); });
If someone else holds the row, claim() waits in a fair queue, then re-reads — so report is the current row, never a stale snapshot. Reads stay open by default; only acting on the row serializes. The claim releases when the callback returns or throws.
See who's mid-edit before you act — decide to wait, or skip:
ablo.weatherReports.claimState('report_stockholm'); ablo.weatherReports.queue('report_stockholm');
await ablo.weatherReports.claim(id, async (report) => { /* do the held work */ }, { wait: false });
await ablo.weatherReports.claim(id, async (report) => { /* do the held work */ }, { maxQueueDepth: 2 });
claimState returns the holder (or null); queue returns the line waiting behind it. wait: false skips rather than waiting when the row is held; maxQueueDepth: 2 bails when two or more are already ahead.
Default reads keep working while a row is claimed. Server reads that need claimed semantics can opt in with ifClaimed: 'return' | 'wait' | 'fail'.
Even an unclaimed write can't land on stale reasoning — the commit is guarded:
try { await ablo.weatherReports.update(id, { status: 'ready' }, { readAt, onStale: 'reject' }); } catch (e) { if (e instanceof AbloStaleContextError) { /* row moved under you — re-read, retry */ } }
Prefer the callback form for ordinary held work. Manual scoped claims are available for wider lifetimes, but callback claims are the docs default.
See Coordination for the full claim / claimState / queue / release reference.
React
In a React app it's the same ablo. API — just mounted through a provider and read with hooks, from @abloatai/ablo/react. Wrap your tree once; everything inside is live.
import { AbloProvider, useAblo } from '@abloatai/ablo/react'; import { schema } from './ablo/schema';
function App() { return (
); }
function Report({ id }: { id: string }) { const report = useAblo((ablo) => ablo.weatherReports.retrieve(id)); const ablo = useAblo();
if (!report) return null;
return (
); }
The useAblo(selector) read re-renders whenever the row changes — whether you, a teammate, or an agent changed it. The write is the same optimistic, fan-out method as the server example above.
owns the connection — no API key in the browser. That's the whole loop: read with useAblo(selector), write with ablo., and every other client (human or agent) on that row sees it in real time. See React for the full prop surface (userId, teamIds, syncGroups, fallback, bootstrapMode) and status hooks.
Identity & Sync Groups
Ablo is not an auth provider — you keep your own (Clerk, Auth0, NextAuth, whatever). Ablo's job starts after you've authenticated a request: you tell it who is connecting, and it scopes their realtime data to the right sync groups (named channels like org:acme or deck:abc123 that are both the unit of fan-out and the unit of access).
The model is a proxy: your ABLO_API_KEY stays on your trusted server, your server resolves the signed-in user (org / team / user) from your own auth, and the browser connects as an already-scoped participant — it never holds the key and can't widen its own scope. Your schema's identityRoles map that identity to sync-group strings.
userId / teamIds come from your auth, resolved server-side:
If it isn't obvious where org / team / user come from in the Quick Start above, that's because they come from your app — see Identity & Sync Groups for the full picture: what a sync group is, the two halves of scoping (identityRoles + per-model orgScoped / syncGroupFormat), and how identity reaches Ablo without an API key in the browser.
Multiplayer
There is no separate multiplayer mode. When human UI, server actions, and agent workers share the same schema and write through ablo., they all see each other's changes in real time — that's the default, not a feature you turn on.
ablo..create/update/delete fan out confirmed deltas to subscribers.
useAblo(...) gives React clients the live row, kept current automatically.
ablo..claim(id) / claimState(id) / queue(id) let humans and agents coordinate (and observe) active work on a row — and the line waiting behind it — before a write lands.
Always write through Ablo — either the SDK model methods (ablo..create/update/delete) or the HTTP write endpoint below. If you write straight to your own database instead, those changes won't reach connected clients.
HTTP Writes
Use the SDK when you are in JavaScript and want typed models or realtime. Use the HTTP endpoint when a server-to-server caller needs to write without opening a WebSocket:
curl https://api.abloatai.com/v1/commits \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "operations": [ { "action": "update", "model": "weatherReports", "id": "report_stockholm", "data": { "status": "ready" } } ] }'
{ "object": "commit_receipt", "status": "confirmed", "serverTxId": "tx_…", "lastSyncId": 1042, "ops": 1 }
Connect Your Database
Every schema model has a backing store. By default, Ablo stores rows for the models you declare, so ablo.weatherReports.create(...) and ablo.weatherReports.update(...) write to Ablo-managed state.
If your existing database stays the source of truth, connect it as a Data Source: Ablo sends signed commit requests to an endpoint you host, and your app writes its own database. Your DATABASE_URL stays in your app — Ablo only ever sees the API key.
See Connect Your Database for the integration shape.
Configuration
Ablo({ ... }) takes one required option and a couple of transport overrides:
Option Type Default Purpose
schema Schema — (required) Typed model proxies (ablo..*)
apiKey string | ApiKeySetter | null process.env.ABLO_API_KEY Server key — a string, or an async function for rotation
baseURL string wss://mesh.ablo.finance Point at a self-hosted or staging mesh
Keep apiKey in trusted server runtimes. In the browser, authenticates with the signed-in user's session; the raw-key path is gated behind dangerouslyAllowBrowser for server-proxy setups only. Self-hosted deployments can pass authToken instead of apiKey. Advanced hooks (custom fetch, logging, observability) live in Client Behavior.
Errors
Every SDK error extends AbloError and carries a requestId for support. Discriminate with instanceof or the type string — the string form also survives worker / postMessage boundaries, where instanceof does not:
try { await ablo.weatherReports.update(id, { status: 'ready' }, { readAt, onStale: 'reject' }); } catch (e) { if (e instanceof AbloStaleContextError) { /* row moved under you — re-read, retry */ } if ((e as AbloError).type === 'AbloClaimedError') { /* another participa
[truncated for AI cost control]