Skip to content
AbloAblo Docs
Esc
navigateopen⌘Jpreview
On this page

Debugging & Logs

Watch claims, queueing, and grants as they happen while you build.

By default the SDK is quiet — it logs only warnings and errors. When you’re building a multi-agent flow and want to see the coordination happen (who claimed what, who’s waiting in line, who got preempted), turn on Ablo’s diagnostic logging. Every line is prefixed [Ablo] so it’s obvious which output is ours in a console full of other tools.

Turn it on

import Ablo from '@abloatai/ablo';
import { schema } from './ablo/schema';

const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, debug: true });

CLI environment and target

Read-only diagnostics (status, whoami, logs, and connect locate/check) may inspect the application-facing chain: exported ABLO_API_KEY, .env.local, .env, then the stored credential. An exported value wins over project files. Mutations (push and connect apply/rotate/register/deregister) are intentionally stricter: they read the process environment, an explicit --env-file, or a stored compatibility credential. An ambient file cannot silently choose the branch a mutation acts on.

Use the two diagnostics according to the question:

npx ablo whoami    # strict: which project + branch does this credential target?
npx ablo status    # broad: target, database, schema, drift, and write blockers

For an old, CI, or recovery key stored under another variable name:

npx ablo whoami --key-env ABLO_API_KEY_LIVE

That explicit lookup checks the process, .env.local, and .env for the named variable, keeps the value out of argv, and either returns a server-confirmed identity or fails non-zero. Do not infer that a key is invalid from an older CLI’s generic identity error; rerun with the current CLI.

debug: true is the simple switch. For finer control use logLevel, or set it without touching code via the ABLO_LOG_LEVEL environment variable.

Ablo({ schema, apiKey })                       // quiet — warnings + errors only (default)
Ablo({ schema, apiKey, debug: true })          // everything (coordination + lifecycle)
Ablo({ schema, apiKey, logLevel: 'info' })     // coordination + connection, no per-model noise
ABLO_LOG_LEVEL=debug npm run dev               # same, from the environment

Levels

Level What it shows
silent nothing
error failures only
warn default: warnings + errors
info the above + the coordination trace (claims, grants, queueing) + connection state
debug the above + internal lifecycle (per-model registration, store hydration): the full firehose

Precedence: an explicit logLevel wins, then debug: true (⇒ debug), then ABLO_LOG_LEVEL, then the warn default. debug: false (or omitting it) just means “don’t raise the level.”

For watching coordination, logLevel: 'info' is the sweet spot — you get the claim trace without the per-model registration chatter that debug adds.

What you’ll see: the coordination trace

These lines (all at info) let you watch the handover you built:

[Ablo] claim: requesting records:doc_42 for "editing" (will queue if contended)
[Ablo] claim: queued for records:doc_42 — position 2 of 3, waiting
[Ablo] claim: granted 7f3c… — your turn (waited in queue)
[Ablo] claim: rejected records:doc_42 — held by agent_writer
[Ablo] claim: lost records:doc_42 (preempted or expired)
[Ablo] claim: released records:doc_42

Read it as the lifecycle of one claim:

  • requesting: your code (or an agent) called ablo.<model>.claim(...). (will queue if contended) appears when you passed { queue: true }.
  • queued … position N of M: the row was held, so you’re waiting in the FIFO line. This is the “an agent is waiting behind a claim” moment; it re-logs only when your position changes, so you can watch it advance.
  • granted … your turn: you reached the head of the line; the lease is now yours and the row may have changed while you waited.
  • rejected … held by <who>: your claim was refused because someone else holds it (and the model’s policy didn’t let you in).
  • lost: you held the lease and it was taken (preempted by a higher-priority writer, or it expired).
  • released: you (or await using’s scope exit) gave the lease back.

Where the logs run

The coordination trace and the proactive credential refresh run in the browser (and any client that holds a live socket) — that’s where the live coordination activity is. Server-side code that mints credentials or does one-shot reads won’t emit the trace; it has no live session to narrate.

Bring your own logger

Pass a logger to route Ablo’s output into your own logging stack (Pino, Sentry breadcrumbs, etc.). A custom logger bypasses debug/logLevel entirely — you decide what to do with each level.

Ablo({
  schema,
  apiKey,
  logger: {
    debug: (...a) => {},
    info: (...a) => myLogger.info({ ablo: a }),
    warn: (...a) => myLogger.warn({ ablo: a }),
    error: (...a) => myLogger.error({ ablo: a }),
  },
});

Read the coordination in code: the activity log

The console trace above is for you, at a terminal. To put the same activity inside your app — an activity feed, a “who’s editing” badge, a Sentry breadcrumb trail — read it programmatically. Same events, three layers; pick by audience:

Layer You get Best for
logger (above) [Ablo] text lines watching a terminal
observability typed ClaimEvent / ConflictEvent objects dashboards, alerting (Sentry / Datadog / OTel)
ClaimLog an ordered, reactive list of both rendering an activity feed on a page

The events

Every claim state change is a ClaimEvent; every notify-instead-of-abort stale write (a write that succeeded but whose premise had moved) is a ConflictEvent:

interface ClaimEvent {
  phase: 'acquired' | 'queued' | 'granted' | 'lost' | 'rejected' | 'expired';
  model?: string; id?: string; field?: string;          // the claimed row
  actor?: string; participantKind?: 'user' | 'agent' | 'system';
  position?: number;   // FIFO position, when queued
  reason?: string;     // why, on rejected
  claimId?: string;
}

interface ConflictEvent {
  clientTxId: string;
  rows: { model: string; id: string; fields: string[]; writtenBy?: 'user' | 'agent' | 'system' }[];
}

phase is past-tense — the state the claim just entered — and maps one-to-one to what arrives on the wire.

Collect them: ClaimLog

ClaimLog records both into an ordered list. Hand it to observability, then read it back:

import Ablo, { ClaimLog } from '@abloatai/ablo';

const log = new ClaimLog();
const ablo = Ablo({ schema, apiKey, observability: log });

// …run the agents…
console.log(`${log}`);   // a printable, ⚠-marked timeline
log.entries;             // ClaimLogEntry[] — every event, in order, with a `.line`
log.collisions();        // just the rejected/lost claims + stale writes

It’s also the simplest way to assert coordination in a test — no log scraping:

expect(log.collisions()).toHaveLength(0);   // no one stepped on anyone

Show it on a page: reactive

ClaimLog.onChange fires on every event and returns an unsubscribe — the exact shape useSyncExternalStore wants, so a live feed is a few lines:

import { useSyncExternalStore } from 'react';
import { ClaimLog } from '@abloatai/ablo';

function ActivityFeed({ log }: { log: ClaimLog }) {
  const entries = useSyncExternalStore(log.onChange, () => log.entries);
  return (
    <ul>
      {entries.map((e) => (
        <li key={e.seq} className={e.collision ? 'text-amber-600' : undefined}>{e.line}</li>
      ))}
    </ul>
  );
}

ClaimLog lives in browser memory: it starts empty on load and shows events that arrive while mounted. For a feed that survives reload, persist entries yourself — but for a live coordination panel, the in-memory log is exactly right.

For “who holds this row right now” (a badge, not a feed), don’t use ClaimLog — read the reactive claim state directly. It re-renders on change with no extra wiring:

const holder = useAblo((ablo) => ablo.records.claim.state({ id }));   // Claim | null

See React and Coordination for the claim-read APIs.

Route to your own backend

ClaimLog is one implementation of the observability slot. To push events into Sentry, Datadog, or OpenTelemetry instead, spread noopObservability and override just the two coordination hooks:

import Ablo, { noopObservability } from '@abloatai/ablo';

const ablo = Ablo({
  schema, apiKey,
  observability: {
    ...noopObservability,
    captureClaim: (e) => {
      if (e.phase === 'rejected') Sentry.captureMessage(`claim blocked: ${e.model}/${e.id} by ${e.actor}`);
    },
    captureConflict: (e) => Sentry.captureMessage(`stale write tx ${e.clientTxId} on ${e.rows.length} row(s)`),
  },
});

ClaimLog implements the full SyncObservabilityProvider, so it drops straight into the observability slot. The surface exports ClaimLog, formatClaim, formatConflict, and noopObservability, plus the types ClaimEvent, ConflictEvent, ClaimLogEntry, and SyncObservabilityProvider.

Both transports, from 0.21.0. Observability fires on the WebSocket and on the stateless HTTP transport (claim acquired, plus coordination-conflict rejections on every write door). Before 0.21.0 only WebSocket emitted, so a ClaimLog on an HTTP client, such as a headless server-agent eval, stayed silent even though coordination still worked.

Errors

Ablo’s thrown errors are typed and self-describing — String(err) (or logging it) yields one clean line, never a stack dump:

AbloValidationError [model_required_field_missing]: A required field was absent. (see https://docs.abloatai.com/errors#model_required_field_missing) [request_id: req_8Fk2aQ]

Branch on err.code (stable) — never on the message (rewordable). See Client Behavior for the full error model and which codes are safe to retry.

Diagnosing capability_scope_denied

The same stable code covers two different enforcement layers, so inspect error.details.origin:

  • capability_allowlist: the branch/session credential did not grant the operation. requiredCapability.scope names the missing model.verb, and details.resolvedOperations shows the grants the server actually resolved.
  • database_row_level_security: Ablo’s capability gate allowed the operation, but Postgres rejected it under the customer table’s RLS policy. details.databaseSessionContext shows the organization, project, branch, participant kind, user principal, and the complete built-in-plus-custom session-setting values configured for that transaction. customSessionSettings isolates only the mappings declared by the schema; an empty object there does not mean built-in settings such as app.current_org_id were absent.

Do not respond by changing a tenant policy to USING (true) or granting BYPASSRLS. Compare the row’s tenant value with databaseSessionContext.sessionSettings.app.current_org_id. On CREATE, Ablo server-stamps the authenticated organization into the model’s row-local tenancy field; a missing value is a server/version fault to report with requestId, not a requirement to make the column nullable or open the policy.

Every rejected live commit carries requestId on the thrown error and request_id in its JSON form and warning line:

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

try {
  await ablo.records.create({
    data,
  });
} catch (error) {
  if (error instanceof AbloError) {
    console.error(error.code, error.requestId, error.requiredCapability, error.details);
  }
}

An awaited model write rejects with that complete typed error. onMutationFailure remains the notification channel for deliberately unawaited optimistic writes; it is not required to recover details from an awaited write.

Local reads versus a confirmed server read

list() without a completeness option may return the current local pool immediately. That is why it can be empty while Postgres contains rows: it is not evidence that the replication source has no history.

Use:

await ablo.records.list({ type: 'complete' });

type: 'complete' waits for a server round trip and returns the confirmed result. type: 'unknown' returns the local result immediately and refreshes it in the background. The distinction is freshness/completeness, not claimed versus unclaimed data.

Was this page helpful?