Skip to main content
Coordinate long-running work on a row so humans and agents don’t clobber each other. Most writes need none of this — ablo.<model>.update({ id, data }) is optimistic and the server rejects it if the row moved. Reach for claim only when you’ll hold a row across a slow gap (read → LLM call → write). 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. The wait is a server-side FIFO queue: a second claimer blocks until promoted to the head of the line — it does not fail and does not poll. Reads stay open: reading a claimed row is allowed unless the caller explicitly asks for claimed gating. A claim carries a TTL so a crashed holder is auto-released and the queue advances. This reference opens with the model — the one answer to “how do two agents not clobber each other” — then covers the claim state object, the SDK methods (claim · claim.state · claim.queue · claim.release · writing under a claim), and the errors you can catch.

The model — three layers, one decision

Ablo has exactly three coordination layers. They are not three competing answers to the same question — they stack, and only one of them is a decision you make:
layerkindwhat it doesenforces?
Presence (claim.state, observers)observationBroadcasts who is working where, live. Renders cursors / “agent X is editing.”No. Advisory only — it never blocks or rejects a write.
Claim (claim/claim.queue/claim.release)pessimisticReserves a row for one participant. Foreign writers are rejected server-side; contenders join a fair FIFO queue.Yes, between participants — mutual exclusion.
Stale-context (readAt + onStale)optimistic (LWW)On commit, rejects a write whose snapshot is older than the row’s latest delta. Last-writer-wins detection.Yes, against time — lost-update detection.
The one decision: do you hold the row across a slow gap (read → LLM call → write)?
  • No (the common case — a single quick update): do nothing. ablo.<model>.update is optimistically guarded by stale-context already; it rejects with AbloStaleContextError if the row moved under you. This is the default and needs no ceremony.
  • Yes (you’ll reason for seconds while holding the row): claim it. The claim excludes other participants for the duration, queues contenders fairly, and — see below — your own writes under it stay stale-guarded too.
How they compose (what wins): If you don’t hold the row, claims win — a non-holder writing to a claimed row is rejected (AbloClaimedError) regardless of readAt. If you do hold it, your own writes are still stale-checked — a row that moved between your snapshot and your write still rejects with AbloStaleContextError. With no claim held, the stale check is the only protection, and it’s automatic, which is why the no-claim path is safe by default. Presence (claim.state) never decides anything — read it to render, act on the errors. The two checks are independent: one rejects writes from people who don’t hold the claim, the other rejects writes based on a stale snapshot, and the SDK adds the stale-check for you when you write under a claim, so you don’t pass anything extra.

The claim state object

The claim state object is the live record that a participant is coordinating work on a model row. It’s what claim.state() returns and what observers render.
fieldtypedescription
idstringThe claim id (distinct from the target row id).
statusClaimStatus'active' | 'queued' | 'committed' | 'expired' | 'canceled'. active = the holder; queued = waiting in line behind it. The other three are terminal states you only see on a claim you just finished — committed (released after a successful write), expired (TTL lapsed), canceled (released early).
targetEntityRefWhat is being coordinated ({ model, id, field? }).
actionstringHuman-readable phase — 'editing', 'writing', 'reviewing'.
heldBystringParticipant holding (or waiting on) it (e.g. 'agent:forecaster').
participantKind'user' | 'agent' | 'system'Who’s behind it — a human (user), an AI (agent), or automated infrastructure (system).
positionnumber?0-based place in the FIFO line — present only when status: 'queued' (0 = next behind the holder).
createdAtstring?Ms-epoch the holder opened it. Optional — derived shapes may omit it.
expiresAtstringMs-epoch the server reclaims it if the holder goes silent. Renewed automatically while the holder’s connection stays alive — a crash-cleanup floor, not a duration you size.
{
  "id": "claim_8fJ2",
  "status": "active",
  "target": { "model": "weatherReports", "id": "report_stockholm" },
  "action": "editing",
  "heldBy": "agent:forecaster",
  "participantKind": "agent",
  "createdAt": "1748160000000",
  "expiresAt": "1748160030000"
}

Methods

Each method below follows one fixed shape: signature · what it does · parameters · returns · example.

claim

ablo.<model>.claim({ id, ...options }): Promise<ClaimHandle<T>>  // handle; AsyncDisposable, auto-releases with `await using`
Claim a row so other writers serialize behind you until you’re done; reads stay open by default. The claim acquires through the server’s fair FIFO queue: if the target is free the lease is yours immediately, and if another participant holds it your claim waits in line and resolves only once it reaches the head — then re-reads so the claimed snapshot reflects what the previous holder committed. There’s no polling and no race window — the server decides the order, so two claimers can’t both think they won. Parameters
nametyperequireddescription
idstringyesThe row id — same id as retrieve / update.
options.actionstringnoPhase shown to observers (default 'editing').
options.fieldstringnoField-level target, for fine-grained claimed-state badges.
options.waitbooleannotrue (default) queues and waits for the lease. false is fail-fast — if another participant holds the row, reject immediately with AbloClaimedError('entity_claimed') instead of queuing (claim-or-skip, for work dedup where waiting would double-process).
options.maxQueueDepthnumbernoBackpressure: reject with AbloClaimedError('queue_too_deep') instead of joining a line already >= maxQueueDepth deep. Omit to wait however deep the queue is.
options.ttlDurationnoCrash-cleanup floor. Rarely set — the lease renews while your connection is alive, so it only matters once you go silent.
The high-level claim queues by default, so on contention you either get the row when your turn arrives or one of the queue errors (claim_lost, grant_timeout). Returns — a ClaimHandle<T> (an AsyncDisposable): handle.data is the fresh row snapshot taken once the lease is yours, and handle.release() gives the claim back. Bind it with await using so the claim auto-releases when the scope exits. Example
await using claim = await ablo.weatherReports.claim({ id: 'report_stockholm' });
const report = claim.data;
const weather = await weatherAgent.getWeather(report.location);
await ablo.weatherReports.update({ id: report.id, data: { forecast: weather } });
The claim releases when the await using scope exits — on return or throw.

Claim-gated reads

claim.state({ id }) always returns immediately. Model reads such as ablo.<model>.get(id) are local reads and stay available while a claim is held. Server/model reads can choose a claimed policy:
await ablo.weatherReports.retrieve({
  id: 'report_stockholm',
  ifClaimed: 'wait',
  claimedTimeout: 30_000,
});
  • ifClaimed: 'return' reads now and includes active work metadata.
  • ifClaimed: 'wait' waits for the active claim to clear before reading.
  • ifClaimed: 'fail' throws AbloClaimedError if the row is claimed.

claim.state

ablo.<model>.claim.state({ id })
Read who’s currently working on a row, for observers and UI. Synchronous and reactive (it reads the local coordination snapshot). Never blocks. Parameters
nametyperequireddescription
idstringyesThe row id.
Returns — the active claim state object, or null when the row is free. Example
const who = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
if (who) console.log(`${who.heldBy} is ${who.action}`);
Returns the active claim state when the row is held, or null when it’s free:
{
  "id": "claim_8fJ2",
  "status": "active",
  "target": { "model": "weatherReports", "id": "report_stockholm" },
  "action": "editing",
  "heldBy": "agent:forecaster",
  "participantKind": "agent",
  "expiresAt": "1748160030000"
}

claim.queue

ablo.<model>.claim.queue({ id })
Read the wait line behind a row — the FIFO of claims queued behind the current holder, in promotion order. Like claim.state, it’s synchronous and reactive (it reads the local coordination snapshot, kept current by the server’s queue-mutation frames), and reading never blocks. Where claim.state answers “who holds it,” claim.queue answers “who’s lined up next” — render “3rd in line”, or decide the wait isn’t worth it. Parameters
nametyperequireddescription
idstringyesThe row id.
Returns — a list envelope. data contains the queued claim state objects in promotion order (head first), excluding the active holder; [] when no one is waiting. Example
const { data: waiting } = ablo.weatherReports.claim.queue({ id: 'report_stockholm' });
console.log(`${waiting.length} ahead of you`);
console.log(waiting.map((i) => i.heldBy));

claim.release

ablo.<model>.claim.release({ id }): Promise<void>
Release a claim you hold. Usually implicit — the await using scope exiting releases for you, and TTL cleans up a crashed holder. Call this only to give a manually held claim back early (claimed, then decided not to write). Releasing promotes the head of the queue: the next waiter receives the claim. Parameters
nametyperequireddescription
idstringyesThe row id you hold a claim on. No-op if you don’t hold it.
Returns — resolves once the claim is released. Example
const claim = await ablo.weatherReports.claim({ id: 'report_stockholm', action: 'reviewing' });
const report = claim.data;
try {
  const ok = await reviewExternally(report);
  if (!ok) return; // abandon, no write
  await ablo.weatherReports.update({ id: report.id, data: { status: 'ready' } });
} finally {
  await ablo.weatherReports.claim.release({ id: report.id });
}

Writing under a claim

There is no separate “write” method on a claim — use the normal ablo.<model>.update({ id, data }). While you hold a claim on id, that update is automatically stale-guarded against the snapshot the claim took (readAt = snapshot watermark, onStale: 'reject') and attributed to the claim’s lease, so it rejects with AbloStaleContextError if the row changed under you.
await using claim = await ablo.weatherReports.claim({ id });
await ablo.weatherReports.update({ id: claim.data.id, data: { status: 'ready' } }); // guarded by the claim
Claims are enforced server-side: if you update/delete a row that another participant holds, the commit is rejected with AbloClaimedError (code: 'entity_claimed'). To proceed, claim the row yourself — the claim queues behind the current holder and re-reads once it’s yours, so your update lands on fresh data. You never conflict with your own claim, and reads are never gated.
try {
  await ablo.weatherReports.update({ id, data: { status: 'ready' } });
} catch (err) {
  if (err instanceof AbloClaimedError) {
    // someone else holds it — claim the row and retry from fresh state
  }
}

Errors

All extend AbloError (see Errors). Catch by type or inspect the code.
errorcodethrown whencarries
AbloClaimedErrorclaim_lostA held/queued claim was taken away (holder TTL lapse on disconnect, or revoke) while you were holding or waiting.claims?
AbloClaimedErrorgrant_timeoutThe optional timeoutMs elapsed while you were still queued for a grant.claims?
AbloClaimedErrorqueue_too_deepclaim was passed maxQueueDepth and the wait line was already that deep when you tried to join — fail-fast instead of waiting.claims?
AbloClaimedErrorclaim_conflictAn update/delete targets a row another participant holds — the server’s pre-commit check rejected it.
AbloClaimedErrorentity_claimedSame conflict, from the commit guard backstop.
AbloStaleContextErrorA guarded update (under a claim, or any write carrying readAt) targets a row that received deltas since the snapshot — your reasoning is stale.readAt, conflicts[]
AbloValidationErrormodel_claim_not_configuredclaim called on a model without collaboration wiring.
AbloValidationErrorentity_not_foundThe row id doesn’t exist locally or on load.
AbloStaleContextError.conflicts lists the (model, id, observedSyncId) rows that moved during your generation window — use it for selective regeneration (re-think only the slides that changed, not the whole deck) and for metrics.
try {
  await using claim = await ablo.weatherReports.claim({ id: 'report_stockholm' });
  const report = claim.data;
  const weather = await weatherAgent.getWeather(report.location); // slow gap
  await ablo.weatherReports.update({ id: report.id, data: { forecast: weather } });
} catch (err) {
  if (err instanceof AbloClaimedError && err.code === 'claim_lost') {
    // Our lease lapsed mid-flight (we stalled past the TTL). Re-claim and retry.
  } else if (err instanceof AbloStaleContextError) {
    // The row moved under us — re-read and regenerate from the fresh snapshot.
  } else throw err;
}