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, 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.<model> 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:
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.
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<T | undefined> |
You need one row, hydrating from local store and server. |
list({ where }) |
Promise<ModelList<T>> |
You need to hydrate a collection from local store and server. |
listAll({ where, maxPages?, signal? }) |
Promise<T[]> |
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<T> |
You want to create through the schema model. |
update({ id, data, ...options }) |
Promise<T> |
You want to update through the schema model. |
delete({ id, ...options }) |
Promise<void> |
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:
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:
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.
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:
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:
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:
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.<model>.claim({ id }) takes the claim and returns a handle,
ablo.<model>.claim.state({ id }) reads who currently holds it (synchronous, never
blocks), and ablo.<model>.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, 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).
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.<model>.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.<model>.list(...), ablo.<model>.claim({ id }), and
ablo.<model>.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.<model>.…. Non-JS callers (or curl) use it
directly. The table below shows the shape with {model} as a placeholder; the
OpenAPI spec 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.<model>.create({ data }) |
POST /api/v1/models/{model} |
ablo.<model>.list({ where }) |
GET /api/v1/models/{model} |
ablo.<model>.get({ id }) |
GET /api/v1/models/{model}/{id} |
ablo.<model>.update({ id, data }) |
PATCH /api/v1/models/{model}/{id} |
ablo.<model>.delete({ id }) |
DELETE /api/v1/models/{model}/{id} |
ablo.<model>.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 (@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 for retry and timeout guidance.