Skip to content
AbloAblo Docs
Esc
↑↓navigate↵open⌘Jpreview
On this page

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.

Three things to know before the method list. get observes; read declares. Both fetch one current row, but only the exact object returned by read({ id }) can be passed as ifUnchanged or carried in a mutation’s reads array. If it changed, that mutation does not land. A scoped read returns undefined for both missing and hidden ids. get({ id }) and list({ where }) are ordinary queries with no stale guard. Local reads do not fetch. Put local. in front of a query 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.read({ id: 'report_stockholm' });
if (!report) throw new Error('Row not found');

await ablo.weatherReports.update({
  id: 'report_stockholm',
  data: { status: 'ready' },
  ifUnchanged: report,
});

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 }) fetches one row without declaring a decision dependency.
  • ablo.weatherReports.read({ id }) fetches one guardable decision input.
  • ablo.weatherReports.list({ where }) fetches an observational collection.
  • 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.
  • ablo.weatherReports.claim({ id, description }) acquires a durable write lease; the HTTP form is awaited.
  • ablo.weatherReports.presence(id) reads the live session projection for one row on a reactive client.
  • ablo.weatherReports.events.send(id, name, payload) sends a transient row-scoped event on a reactive client.
  • ablo.weatherReports.events.subscribe(id, name, handler) subscribes to that transient event and returns a disposer.

local. narrows a query to what has already synced. get({ id }), read({ 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 to observe one current row.
read({ id }) Promise<CapturedRow<T> | undefined> A later mutation is based on this row.
list({ where }) Promise<ModelList<T>> You need to observe a collection.
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.
claim({ id, description }) Promise<HeldClaim<T>> Slow or expensive work must exclude another writer.
claim.state({ id }) Promise<Claim | null> on HTTP You need the current holder without acquiring the row.
claim.list({ id }) Promise<{ object: 'list'; data: Claim[] }> on HTTP You need every disjoint holder on the row.
claim.queue({ id }) Promise<ClaimQueueView> on HTTP You need the durable wait line.
claim.release({ id }) Promise<void> on HTTP You need to release a claim early.
claim.reorder({ id, order }) Promise<void> on HTTP A privileged coordinator needs to reorder the wait line.
presence(id?) readonly PresenceSession[] on reactive clients You need the sessions currently active on a model or row.
events.send(id, name, payload) void on reactive clients You need to send a cursor, selection, or other transient signal.
events.subscribe(id, name, handler) () => void on reactive clients You need transient signals for one row until cleanup.

get, read, list, create, update, delete, and claim 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.

Live presence and model events

Presence and events belong to the reactive WebSocket client. They are not on the stateless HTTP client used by server-side agents and workers.

Use presence for who is active on a row. In React, usePresence also owns the mounted component’s read activity and cleanup:

const viewers = usePresence((ablo) => ablo.chats, chatId);

Use model events for short-lived UI detail that should not become a database field:

const stop = ablo.files.events.subscribe(fileId, 'cursor', (cursor, context) => {
  renderCursor(context.sender.presenceSessionId, cursor);
});

ablo.files.events.send(fileId, 'cursor', { line: 12, column: 4 });
stop();

The model and row determine the sync group. Ablo excludes the sending connection, validates the wire envelope, and supplies authenticated context.sender and context.sentAt; identity does not belong in the payload. Event names are strings and payloads are objects. Payloads are not inferred from the schema today.

Delivery is lossy: sending while disconnected is dropped, and events are not replayed after reconnect. Cursor and live selection fit this contract. Persist anything that must be recovered as ordinary model data.

Switching clients

CapturedRow<T> marks an authoritative read in the public types. It does not encode the identity of each client instance: TypeScript can accept a foreign captured row, and spreading a row can preserve its static type while losing its runtime evidence. Helpers that return captured rows must also make their owning client clear. Prefer passing the writing client into the helper.

When moving from a server client to an attributed or scoped-session client, carry the row ID across the boundary and read again through the writing client. Validate authority and the proposed action against that new result. A previous server-side authorization decision may no longer apply; rereading is not itself an application permission check.

// writingClient is the already-created attributed/scoped client.
const conversation = await writingClient.conversations.read({ id: conversationId });
if (!conversation) throw new Error('Conversation unavailable');
await assertCanReply(actor, conversation); // Your application's authorization.
const reply = buildReply(conversation); // Recompute from the new evidence.

await writingClient.commits.create({
  reads: [conversation],
  operations: [{ action: 'create', model: 'messages', id: messageId, data: reply }],
});

A known foreign row fails locally with read_evidence_client_mismatch before the write is submitted. param is reads or ifUnchanged; details contains model, sourceClient, and targetClient. Client labels are opaque and local to the running SDK module, not account, actor, session, or credential identifiers. They contain no row contents or credentials. Cloned, serialized, and uncaptured rows still fail with write_options_invalid; ownership cannot be recovered from those values. Do not cast a row or manufacture canonical dependencies to bypass the ownership check.

For regression tests, instantiate two real SDK clients and mock their transport, not read or commits.create. Read with client A, submit that exact row through client B, assert the dedicated code and no write request/frame, then reread and revalidate through B and assert its commit carries the new evidence. Also test copied rows and the application’s denied-authority path. Two clients with the same credentials are still distinct owners. Preserve the SDK error code and parameter in application diagnostics instead of reducing every failure to an unexplained HTTP 500.

Atomic commits

Captured rows are bound to the client instance that read them. Use that same client for the commit. When switching to an attributed or scoped client, reread and revalidate authority and the decision first. TypeScript’s CapturedRow<T> brand does not distinguish instances; foreign rows reject locally with read_evidence_client_mismatch. See Switching clients for the transition example and regression-test pattern.

Use one ablo.commits.create when several Ablo model writes must all land or none may land. Put every operation in operations and every exact row returned by read that influenced the batch in the top-level reads array.

import { AbloStaleContextError } from '@abloatai/ablo';

const task = await ablo.tasks.read({ id: taskId });
if (!task) throw new Error('task not found');

try {
  await ablo.commits.create({
    operations: [
      {
        action: 'update', model: 'tasks',
        id: task.id,
        data: { status: 'done' },
      },
      {
        action: 'create', model: 'tasks',
        id: markerId,
        data: { title: 'atomic marker', status: 'done' },
      },
    ],
    reads: [task],
  });
} catch (error) {
  if (error instanceof AbloStaleContextError && error.code === 'stale_context') {
    console.log(error.code);
  } else {
    throw error;
  }
}

The server checks the premises and applies the operations in one transaction. If any premise is stale or any operation fails, no operation lands. Independent model calls are not an atomic batch. External effects and application-owned Postgres writes cannot join this commit; keep those in their existing transaction or outbox.

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.

Guarded Writes

Use read when an update or delete depends on the row’s current state, then pass that exact row as ifUnchanged:

const report = await ablo.weatherReports.read({ id: 'report_stockholm' });
if (!report) throw new Error('report not found');

await ablo.weatherReports.update({
  id: report.id,
  data: { status: 'ready' },
  ifUnchanged: report,
});

Reactive local state changes optimistically at call time; awaiting the model write waits for authoritative confirmation.

If the row changed after read, the write rejects with AbloStaleContextError. Ablo retains only model, id, and the read watermark as evidence; it does not record the row contents. A write without ifUnchanged or reads is an intentional unconditional assignment. Use reads for additional rows that also influenced the write.

Write options:

Option Purpose
ifUnchanged Exact target row returned by read; guards an update or delete.
reads Exact rows returned by read that the mutation depends on.
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, and ablo.<model>.claim.release({ id }) releases it early. These reads are synchronous on the stateful client and awaited server calls on the HTTP client. The full coordination surface is claim.state({ id }) / claim.list({ 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 and returns the current claim state object (or null). It reads the stateful client’s local cache synchronously; the HTTP client returns a promise because it asks the server. 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 = await 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' },
  claim: handle,
});
await handle.release();

Writes go through the normal model mutation and pass the held handle as claim. That explicit handle carries commit-time fencing. If the row changed underneath you since you took the claim, the update rejects with AbloStaleContextError, 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>.read({ 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. type is the class-name discriminator, such as AbloStaleContextError; code is the wire condition, such as stale_context. Use instanceof in-process and type after serialization.

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.

Was this page helpful?