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:| layer | kind | what it does | enforces? |
|---|---|---|---|
Presence (claim.state, observers) | observation | Broadcasts 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) | pessimistic | Reserves 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. |
- No (the common case — a single quick
update): do nothing.ablo.<model>.updateis optimistically guarded by stale-context already; it rejects withAbloStaleContextErrorif the row moved under you. This is the default and needs no ceremony. - Yes (you’ll reason for seconds while holding the row):
claimit. The claim excludes other participants for the duration, queues contenders fairly, and — see below — your own writes under it stay stale-guarded too.
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 whatclaim.state() returns and what observers render.
| field | type | description |
|---|---|---|
id | string | The claim id (distinct from the target row id). |
status | ClaimStatus | '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). |
target | EntityRef | What is being coordinated ({ model, id, field? }). |
action | string | Human-readable phase — 'editing', 'writing', 'reviewing'. |
heldBy | string | Participant 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). |
position | number? | 0-based place in the FIFO line — present only when status: 'queued' (0 = next behind the holder). |
createdAt | string? | Ms-epoch the holder opened it. Optional — derived shapes may omit it. |
expiresAt | string | Ms-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. |
Methods
Each method below follows one fixed shape: signature · what it does · parameters · returns · example.claim
| name | type | required | description |
|---|---|---|---|
id | string | yes | The row id — same id as retrieve / update. |
options.action | string | no | Phase shown to observers (default 'editing'). |
options.field | string | no | Field-level target, for fine-grained claimed-state badges. |
options.wait | boolean | no | true (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.maxQueueDepth | number | no | Backpressure: reject with AbloClaimedError('queue_too_deep') instead of joining a line already >= maxQueueDepth deep. Omit to wait however deep the queue is. |
options.ttl | Duration | no | Crash-cleanup floor. Rarely set — the lease renews while your connection is alive, so it only matters once you go silent. |
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 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:
ifClaimed: 'return'reads now and includes active work metadata.ifClaimed: 'wait'waits for the active claim to clear before reading.ifClaimed: 'fail'throwsAbloClaimedErrorif the row is claimed.
claim.state
| name | type | required | description |
|---|---|---|---|
id | string | yes | The row id. |
null when the row
is free.
Example
null when it’s free:
claim.queue
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
| name | type | required | description |
|---|---|---|---|
id | string | yes | The row id. |
data contains the queued
claim state objects in promotion order (head first), excluding
the active holder; [] when no one is waiting.
Example
claim.release
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
| name | type | required | description |
|---|---|---|---|
id | string | yes | The row id you hold a claim on. No-op if you don’t hold it. |
Writing under a claim
There is no separate “write” method on a claim — use the normalablo.<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.
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.
Errors
All extendAbloError (see Errors). Catch by type or
inspect the code.
| error | code | thrown when | carries |
|---|---|---|---|
AbloClaimedError | claim_lost | A held/queued claim was taken away (holder TTL lapse on disconnect, or revoke) while you were holding or waiting. | claims? |
AbloClaimedError | grant_timeout | The optional timeoutMs elapsed while you were still queued for a grant. | claims? |
AbloClaimedError | queue_too_deep | claim was passed maxQueueDepth and the wait line was already that deep when you tried to join — fail-fast instead of waiting. | claims? |
AbloClaimedError | claim_conflict | An update/delete targets a row another participant holds — the server’s pre-commit check rejected it. | — |
AbloClaimedError | entity_claimed | Same conflict, from the commit guard backstop. | — |
AbloStaleContextError | — | A 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[] |
AbloValidationError | model_claim_not_configured | claim called on a model without collaboration wiring. | — |
AbloValidationError | entity_not_found | The 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.