@abloatai/ablo 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/README.md +33 -28
  3. package/dist/BaseSyncedStore.d.ts +83 -0
  4. package/dist/BaseSyncedStore.js +194 -2
  5. package/dist/Model.d.ts +42 -0
  6. package/dist/Model.js +103 -44
  7. package/dist/agent/session.js +3 -3
  8. package/dist/ai-sdk/coordination-context.js +4 -0
  9. package/dist/ai-sdk/index.d.ts +56 -47
  10. package/dist/ai-sdk/index.js +56 -47
  11. package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
  12. package/dist/ai-sdk/intent-broadcast.js +11 -4
  13. package/dist/ai-sdk/wrap.d.ts +14 -11
  14. package/dist/ai-sdk/wrap.js +11 -13
  15. package/dist/auth/credentialSource.d.ts +34 -0
  16. package/dist/auth/credentialSource.js +63 -0
  17. package/dist/auth/index.d.ts +2 -22
  18. package/dist/auth/index.js +4 -42
  19. package/dist/auth/schemas.d.ts +35 -0
  20. package/dist/auth/schemas.js +53 -0
  21. package/dist/client/Ablo.d.ts +160 -42
  22. package/dist/client/Ablo.js +145 -75
  23. package/dist/client/ApiClient.d.ts +20 -4
  24. package/dist/client/ApiClient.js +166 -28
  25. package/dist/client/auth.d.ts +14 -5
  26. package/dist/client/auth.js +60 -7
  27. package/dist/client/createInternalComponents.d.ts +2 -0
  28. package/dist/client/createInternalComponents.js +8 -1
  29. package/dist/client/createModelProxy.d.ts +130 -66
  30. package/dist/client/createModelProxy.js +152 -49
  31. package/dist/client/httpClient.d.ts +71 -0
  32. package/dist/client/httpClient.js +69 -0
  33. package/dist/client/identity.d.ts +2 -6
  34. package/dist/client/identity.js +49 -11
  35. package/dist/client/index.d.ts +1 -0
  36. package/dist/client/index.js +1 -0
  37. package/dist/client/registerDataSource.d.ts +3 -3
  38. package/dist/client/registerDataSource.js +11 -9
  39. package/dist/client/validateAbloOptions.js +1 -1
  40. package/dist/core/DatabaseManager.js +30 -2
  41. package/dist/core/openIDBWithTimeout.d.ts +36 -0
  42. package/dist/core/openIDBWithTimeout.js +88 -1
  43. package/dist/errorCodes.d.ts +70 -1
  44. package/dist/errorCodes.js +108 -9
  45. package/dist/errors.d.ts +2 -2
  46. package/dist/errors.js +72 -22
  47. package/dist/index.d.ts +17 -8
  48. package/dist/index.js +15 -6
  49. package/dist/keys/index.d.ts +16 -1
  50. package/dist/keys/index.js +26 -6
  51. package/dist/mutators/UndoManager.d.ts +158 -50
  52. package/dist/mutators/UndoManager.js +345 -22
  53. package/dist/mutators/inverseOp.d.ts +129 -0
  54. package/dist/mutators/inverseOp.js +74 -0
  55. package/dist/mutators/readerActions.d.ts +1 -1
  56. package/dist/mutators/undoApply.d.ts +42 -0
  57. package/dist/mutators/undoApply.js +143 -0
  58. package/dist/query/client.d.ts +10 -9
  59. package/dist/query/client.js +3 -6
  60. package/dist/react/AbloProvider.d.ts +23 -126
  61. package/dist/react/AbloProvider.js +62 -199
  62. package/dist/react/context.d.ts +31 -0
  63. package/dist/react/useAblo.d.ts +2 -2
  64. package/dist/react/useCurrentUserId.d.ts +1 -1
  65. package/dist/react/useCurrentUserId.js +1 -1
  66. package/dist/react/useMutators.js +19 -12
  67. package/dist/schema/ddl.d.ts +34 -3
  68. package/dist/schema/ddl.js +162 -4
  69. package/dist/schema/index.d.ts +5 -1
  70. package/dist/schema/index.js +13 -1
  71. package/dist/schema/model.d.ts +11 -0
  72. package/dist/schema/model.js +2 -0
  73. package/dist/schema/openapi.d.ts +28 -0
  74. package/dist/schema/openapi.js +118 -0
  75. package/dist/schema/plane.d.ts +23 -0
  76. package/dist/schema/plane.js +19 -0
  77. package/dist/schema/relation.d.ts +20 -0
  78. package/dist/schema/serialize.d.ts +4 -0
  79. package/dist/schema/serialize.js +4 -0
  80. package/dist/schema/sync-delta-row.d.ts +157 -0
  81. package/dist/schema/sync-delta-row.js +102 -0
  82. package/dist/schema/sync-delta-wire.d.ts +180 -0
  83. package/dist/schema/sync-delta-wire.js +102 -0
  84. package/dist/server/adapter.d.ts +156 -0
  85. package/dist/server/adapter.js +19 -0
  86. package/dist/server/commit.d.ts +82 -0
  87. package/dist/server/commit.js +1 -0
  88. package/dist/server/index.d.ts +14 -0
  89. package/dist/server/index.js +1 -0
  90. package/dist/server/next.d.ts +51 -0
  91. package/dist/server/next.js +47 -0
  92. package/dist/server/read-config.d.ts +60 -0
  93. package/dist/server/read-config.js +8 -0
  94. package/dist/server/storage-mode.d.ts +17 -0
  95. package/dist/server/storage-mode.js +12 -0
  96. package/dist/source/adapter.d.ts +65 -0
  97. package/dist/source/adapter.js +20 -0
  98. package/dist/source/adapters/drizzle.d.ts +43 -0
  99. package/dist/source/adapters/drizzle.js +185 -0
  100. package/dist/source/adapters/memory.d.ts +12 -0
  101. package/dist/source/adapters/memory.js +114 -0
  102. package/dist/source/adapters/prisma.d.ts +57 -0
  103. package/dist/source/adapters/prisma.js +176 -0
  104. package/dist/source/conformance.d.ts +32 -0
  105. package/dist/source/conformance.js +134 -0
  106. package/dist/source/contract.d.ts +144 -0
  107. package/dist/source/contract.js +99 -0
  108. package/dist/source/index.d.ts +62 -10
  109. package/dist/source/index.js +99 -0
  110. package/dist/source/migrations.d.ts +14 -0
  111. package/dist/source/migrations.js +39 -0
  112. package/dist/source/next.d.ts +33 -0
  113. package/dist/source/next.js +26 -0
  114. package/dist/sync/BootstrapHelper.d.ts +10 -0
  115. package/dist/sync/BootstrapHelper.js +10 -15
  116. package/dist/sync/ConnectionManager.d.ts +55 -1
  117. package/dist/sync/ConnectionManager.js +155 -16
  118. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  119. package/dist/sync/HydrationCoordinator.js +238 -39
  120. package/dist/sync/NetworkProbe.d.ts +58 -24
  121. package/dist/sync/NetworkProbe.js +118 -42
  122. package/dist/sync/SyncWebSocket.d.ts +45 -70
  123. package/dist/sync/SyncWebSocket.js +70 -36
  124. package/dist/sync/createIntentStream.js +10 -1
  125. package/dist/types/streams.d.ts +9 -0
  126. package/dist/utils/mobx-setup.js +1 -0
  127. package/dist/webhooks/events.d.ts +38 -0
  128. package/dist/webhooks/events.js +40 -0
  129. package/dist/webhooks/index.d.ts +10 -0
  130. package/dist/webhooks/index.js +10 -0
  131. package/dist/wire/errorEnvelope.d.ts +34 -0
  132. package/dist/wire/errorEnvelope.js +86 -0
  133. package/dist/wire/frames.d.ts +119 -0
  134. package/dist/wire/frames.js +1 -0
  135. package/dist/wire/index.d.ts +24 -0
  136. package/dist/wire/index.js +21 -0
  137. package/dist/wire/listEnvelope.d.ts +45 -0
  138. package/dist/wire/listEnvelope.js +17 -0
  139. package/docs/api.md +47 -44
  140. package/docs/cli.md +44 -44
  141. package/docs/client-behavior.md +30 -30
  142. package/docs/coordination.md +33 -36
  143. package/docs/data-sources.md +35 -15
  144. package/docs/examples/agent-human.md +45 -43
  145. package/docs/examples/ai-sdk-tool.md +20 -16
  146. package/docs/examples/existing-python-backend.md +16 -12
  147. package/docs/examples/nextjs.md +14 -12
  148. package/docs/examples/scoped-agent.md +1 -1
  149. package/docs/examples/server-agent.md +24 -21
  150. package/docs/guarantees.md +15 -13
  151. package/docs/index.md +2 -2
  152. package/docs/integration-guide.md +30 -30
  153. package/docs/interaction-model.md +19 -23
  154. package/docs/mcp/claude-code.md +3 -3
  155. package/docs/mcp/cursor.md +1 -1
  156. package/docs/mcp/windsurf.md +2 -2
  157. package/docs/mcp.md +6 -6
  158. package/docs/quickstart.md +41 -31
  159. package/docs/react.md +13 -9
  160. package/docs/schema-contract.md +12 -10
  161. package/docs/the-loop.md +21 -0
  162. package/examples/data-source/README.md +4 -5
  163. package/examples/data-source/customer-server.ts +27 -25
  164. package/llms.txt +28 -5
  165. package/package.json +43 -3
@@ -11,9 +11,12 @@
11
11
  * const sync = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
12
12
  *
13
13
  * const reports = sync.reports.list({ where: { status: 'todo' } });
14
- * await sync.reports.create({ title: 'Fix bug' });
15
- * await sync.reports.update(reportId, { status: 'ready' });
16
- * await sync.reports.delete(reportId);
14
+ * await sync.reports.create({ data: { title: 'Fix bug' } });
15
+ * await sync.reports.update({
16
+ * id: reportId,
17
+ * data: { status: 'ready' },
18
+ * });
19
+ * await sync.reports.delete({ id: reportId });
17
20
  */
18
21
  import { z } from 'zod';
19
22
  import { AbloClaimedError, AbloError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, translateHttpError, hasWireCode, toAbloError } from '../errors.js';
@@ -23,6 +26,7 @@ import { noopObservability, browserOnlineStatus, defaultSessionErrorDetector, no
23
26
  import { alwaysOnline } from '../adapters/alwaysOnline.js';
24
27
  import { validateAbloOptions } from './validateAbloOptions.js';
25
28
  import { exchangeApiKey } from '../auth/index.js';
29
+ import { createAuthCredentialSource } from '../auth/credentialSource.js';
26
30
  import { createInternalComponents } from './createInternalComponents.js';
27
31
  import { resolveParticipantIdentity } from './identity.js';
28
32
  import { Model } from '../Model.js';
@@ -646,6 +650,37 @@ function createDefaultMutationDispatcher(executor) {
646
650
  },
647
651
  };
648
652
  }
653
+ // ── Auth normalization ─────────────────────────────────────────────────────
654
+ /**
655
+ * The one resolver the credential lifecycle needs: an async `() => token | null`,
656
+ * or `null` when auth is static (a plain long-lived `apiKey` with no refresh —
657
+ * the common case). Only the short-lived per-user path sets this, via `getToken`
658
+ * (the primitive) or `authEndpoint` (sugar that POSTs for `{ token }`).
659
+ */
660
+ function resolveCredentialResolver(options) {
661
+ if (options.getToken)
662
+ return options.getToken;
663
+ if (options.authEndpoint) {
664
+ const endpoint = options.authEndpoint;
665
+ const fetchImpl = options.fetch ?? globalThis.fetch;
666
+ return async () => {
667
+ // The endpoint lives on the consumer's OWN backend and is authed by the
668
+ // user's session cookie (hence `credentials: 'include'`); it returns the
669
+ // `ek_` to carry to the sync-server. A non-OK response is terminal
670
+ // (`null` → sign out), matching the `getToken` contract.
671
+ const res = await fetchImpl(endpoint, {
672
+ method: 'POST',
673
+ credentials: 'include',
674
+ headers: { 'Content-Type': 'application/json' },
675
+ });
676
+ if (!res.ok)
677
+ return null;
678
+ const body = (await res.json());
679
+ return body.token ?? null;
680
+ };
681
+ }
682
+ return null;
683
+ }
649
684
  export function Ablo(options) {
650
685
  if (options.schema == null) {
651
686
  return createProtocolClient(options);
@@ -655,6 +690,12 @@ export function Ablo(options) {
655
690
  const authInput = { options, env };
656
691
  const configuredApiKey = resolveApiKey(authInput);
657
692
  const configuredAuthToken = resolveAuthToken(authInput);
693
+ // The client OWNS its credential lifecycle (not the React layer): this resolver
694
+ // drives both the reactive re-mint (FSM `credential_stale`) and the proactive
695
+ // refresh timer + wake/online/focus triggers. Null for the common static
696
+ // `apiKey` path — no refresh needed.
697
+ const credentialResolver = resolveCredentialResolver(options);
698
+ const authCredentials = createAuthCredentialSource(internalOptions.capabilityToken ?? configuredAuthToken);
658
699
  const configuredDatabaseUrl = resolveDatabaseUrl(authInput);
659
700
  assertBrowserSafety({
660
701
  apiKey: configuredApiKey,
@@ -724,7 +765,12 @@ export function Ablo(options) {
724
765
  // the schema-to-Model-class translation depends on private
725
766
  // helpers (`createDynamicModelClass`, `unwrapZodType`, etc.)
726
767
  // that aren't worth pulling into the components module.
727
- const { modelRegistry, objectPool, bootstrapHelper, database, syncClient, hydration, } = createInternalComponents({ schema, url, options: internalOptions });
768
+ const { modelRegistry, objectPool, bootstrapHelper, database, syncClient, hydration, } = createInternalComponents({
769
+ schema,
770
+ url,
771
+ options: internalOptions,
772
+ auth: authCredentials,
773
+ });
728
774
  registerModelsFromSchema(schema, modelRegistry);
729
775
  // 5. BaseSyncedStore handles the initialization orchestration
730
776
  // (open DB → hydrate IDB → connect WS → fetch bootstrap → hydrate again →
@@ -742,7 +788,15 @@ export function Ablo(options) {
742
788
  modelRegistry,
743
789
  schema,
744
790
  url,
791
+ auth: authCredentials,
745
792
  });
793
+ // Hand the credential lifecycle to the client (refresher + proactive refresh
794
+ // timer + wake/online/focus re-mint). Installed once here so refresh works for
795
+ // ANY consumer of `Ablo({ auth })` — not only those who render `<AbloProvider>`.
796
+ // The first mint happens in `ready()` so the first connection carries a token.
797
+ if (credentialResolver) {
798
+ store.startCredentialLifecycle(credentialResolver);
799
+ }
746
800
  // Wire the store back into the default executor's lazy getter (see
747
801
  // `storeHolder` above). The executor was constructed before the store
748
802
  // existed; this late binding closes the loop so commits dispatch over
@@ -812,16 +866,6 @@ export function Ablo(options) {
812
866
  // source of truth. No duplicate closure variables.
813
867
  let _readyPromise = null;
814
868
  let _refreshScheduler = null;
815
- let currentCapabilityToken = internalOptions.capabilityToken ?? configuredAuthToken ?? undefined;
816
- // Wire the cap token into HydrationCoordinator's HTTP path. Without
817
- // this, `ablo.<model>.load(...)` / `ablo.<model>.retrieve(...)` go
818
- // through `postQuery` with `credentials: 'include'` only — fine in
819
- // browsers (session cookies), but Node consumers (agent-worker)
820
- // have no cookies and the request lands with no credential at all.
821
- // The WS path was already wired (token rides the upgrade URL); this
822
- // closes the gap on HTTP. Closure-over-binding so cap rotation
823
- // (`applyRotatedToken` in the refresh scheduler below) propagates.
824
- hydration.setCapabilityTokenProvider(() => currentCapabilityToken ?? null);
825
869
  async function ready() {
826
870
  if (_readyPromise)
827
871
  return _readyPromise;
@@ -831,6 +875,20 @@ export function Ablo(options) {
831
875
  }
832
876
  _readyPromise = (async () => {
833
877
  try {
878
+ // Mint the FIRST access credential before we connect, so the initial
879
+ // WebSocket upgrade + bootstrap carry a valid bearer (no tokenless first
880
+ // connect that has to self-heal). Only when a refreshing resolver is
881
+ // wired AND no static credential is already present. Contract mirrors
882
+ // `getToken`: `null` ⇒ the login is gone (terminal — fail ready so the
883
+ // app shows sign-in); a THROW ⇒ transient (rethrown; autoStart swallows
884
+ // and the lifecycle's online/wake triggers retry).
885
+ if (credentialResolver && !authCredentials.getAuthToken()) {
886
+ const token = await credentialResolver();
887
+ if (!token) {
888
+ throw new AbloAuthenticationError('Auth resolver returned null before connect — the user is not signed in.', { code: 'auth_no_credentials' });
889
+ }
890
+ authCredentials.setAuthToken(token);
891
+ }
834
892
  // Register the caller's own database for write-back BEFORE bootstrap, so
835
893
  // the server resolves this org's data plane to the customer's DB rather
836
894
  // than serving an empty/wrong store. The org is derived server-side from
@@ -856,20 +914,15 @@ export function Ablo(options) {
856
914
  // Resolve identity against the LIVE token, not the construction-time
857
915
  // `configuredAuthToken`. Consumers using `getToken` (apps/web) never
858
916
  // pass `authToken` at construction — they call `setAuthToken()` before
859
- // `ready()`, which updates `currentCapabilityToken`. Reading the frozen
917
+ // `ready()`, which updates the shared credential source. Reading the frozen
860
918
  // `configuredAuthToken` here made `/auth/identity` fire with no Bearer
861
919
  // (→ `no_matching_provider` / `session_expired`) even though the JWT
862
- // was present. Mirrors `authHeaders()`'s `currentCapabilityToken ??
863
- // configuredAuthToken` precedence.
864
- configuredAuthToken: currentCapabilityToken ?? configuredAuthToken,
920
+ // was present. Mirrors every other transport by reading the shared
921
+ // credential source.
922
+ configuredAuthToken: authCredentials.getAuthToken() ?? configuredAuthToken,
865
923
  bootstrapHelper,
924
+ auth: authCredentials,
866
925
  logger,
867
- applyRotatedToken: (token) => {
868
- currentCapabilityToken = token;
869
- bootstrapHelper.setAuthToken(token);
870
- const ws = store.getSyncWebSocket();
871
- ws?.setCapabilityToken(token);
872
- },
873
926
  });
874
927
  const { userId, accountScope, teamIds, capabilityToken, syncGroups, participantKind, } = resolved;
875
928
  // Fail-loud guard: detect the degenerate "no real sync groups
@@ -895,8 +948,6 @@ export function Ablo(options) {
895
948
  '`["org:${orgId}", "user:${userId}"]`) or verify your auth ' +
896
949
  'provider populates them. See packages/sync-engine/src/client/identity.ts.', { participantKind, resolvedSyncGroups });
897
950
  }
898
- currentCapabilityToken = capabilityToken;
899
- bootstrapHelper.setAuthToken(capabilityToken);
900
951
  if (resolved.refreshScheduler) {
901
952
  _refreshScheduler = resolved.refreshScheduler;
902
953
  }
@@ -962,6 +1013,16 @@ export function Ablo(options) {
962
1013
  httpStatus: error.httpStatus,
963
1014
  error: error.message,
964
1015
  });
1016
+ // Clear the memo so a FUTURE `ready()` re-attempts bootstrap instead of
1017
+ // replaying this rejection forever. Bootstrap failures here are
1018
+ // transient by nature — offline, an IndexedDB open timeout, a bootstrap
1019
+ // fetch hiccup — and used to brick the engine until a full page reload
1020
+ // because line ~2013 (`if (_readyPromise) return _readyPromise`) handed
1021
+ // every later caller this same dead promise. Nulling it lets the
1022
+ // provider's online/wake/retry triggers drive a clean re-bootstrap.
1023
+ // (The terminal `_validationError` branch above intentionally stays
1024
+ // cached — config can't change without recreating the engine.)
1025
+ _readyPromise = null;
965
1026
  throw error;
966
1027
  }
967
1028
  })();
@@ -992,14 +1053,7 @@ export function Ablo(options) {
992
1053
  }
993
1054
  const fetchImpl = options.fetch ?? globalThis.fetch;
994
1055
  function authHeaders() {
995
- const headers = { 'Content-Type': 'application/json' };
996
- if (currentCapabilityToken) {
997
- headers.Authorization = `Bearer ${currentCapabilityToken}`;
998
- }
999
- else if (configuredAuthToken) {
1000
- headers.Authorization = `Bearer ${configuredAuthToken}`;
1001
- }
1002
- return headers;
1056
+ return authCredentials.withAuthHeaders({ 'Content-Type': 'application/json' });
1003
1057
  }
1004
1058
  function createClientTxId(idempotencyKey) {
1005
1059
  if (idempotencyKey && idempotencyKey.length > 0)
@@ -1048,11 +1102,15 @@ export function Ablo(options) {
1048
1102
  return inputOperations.map((op) => normalizeCommitOperation(op, commitOptions));
1049
1103
  }
1050
1104
  function modelClaimFromActive(intent) {
1105
+ const description = typeof intent.target.meta?.description === 'string'
1106
+ ? intent.target.meta.description
1107
+ : undefined;
1051
1108
  return {
1052
1109
  id: intent.id,
1053
1110
  actor: intent.heldBy,
1054
1111
  participantKind: intent.participantKind,
1055
1112
  action: intent.reason,
1113
+ ...(description ? { description } : {}),
1056
1114
  field: intent.target.field,
1057
1115
  status: 'active',
1058
1116
  expiresAt: intent.expiresAt,
@@ -1072,6 +1130,7 @@ export function Ablo(options) {
1072
1130
  actor: intent.heldBy,
1073
1131
  participantKind: intent.participantKind,
1074
1132
  action: intent.action,
1133
+ ...(intent.description ? { description: intent.description } : {}),
1075
1134
  field: intent.target.field,
1076
1135
  status: 'queued',
1077
1136
  position: intent.position,
@@ -1320,7 +1379,6 @@ export function Ablo(options) {
1320
1379
  const res = await fetchImpl(`${bootstrapHelper.baseUrl}/sync/query`, {
1321
1380
  method: 'POST',
1322
1381
  headers: authHeaders(),
1323
- credentials: 'include',
1324
1382
  body: JSON.stringify({
1325
1383
  queries: [
1326
1384
  {
@@ -1362,59 +1420,59 @@ export function Ablo(options) {
1362
1420
  }
1363
1421
  function model(name) {
1364
1422
  return {
1365
- retrieve(id, options) {
1366
- return retrieveModel(name, id, options);
1423
+ retrieve(params) {
1424
+ return retrieveModel(name, params.id, params);
1367
1425
  },
1368
- async create(data, mutationOptions) {
1369
- const id = mutationOptions?.id ?? createModelId();
1370
- await applyClaimedPolicy({ model: name, id }, mutationOptions);
1426
+ async create(params) {
1427
+ const id = params.id ?? createModelId();
1428
+ await applyClaimedPolicy({ model: name, id }, params);
1371
1429
  return commits.create({
1372
- intent: mutationOptions?.intent,
1373
- idempotencyKey: mutationOptions?.idempotencyKey,
1374
- readAt: mutationOptions?.readAt,
1375
- onStale: mutationOptions?.onStale,
1376
- wait: mutationOptions?.wait,
1430
+ intent: params.intent,
1431
+ idempotencyKey: params.idempotencyKey,
1432
+ readAt: params.readAt,
1433
+ onStale: params.onStale,
1434
+ wait: params.wait,
1377
1435
  operations: [
1378
1436
  {
1379
1437
  action: 'create',
1380
1438
  model: name,
1381
1439
  id,
1382
- data,
1440
+ data: params.data,
1383
1441
  },
1384
1442
  ],
1385
1443
  });
1386
1444
  },
1387
- async update(id, data, mutationOptions) {
1388
- await applyClaimedPolicy({ model: name, id }, mutationOptions);
1445
+ async update(params) {
1446
+ await applyClaimedPolicy({ model: name, id: params.id }, params);
1389
1447
  return commits.create({
1390
- intent: mutationOptions?.intent,
1391
- idempotencyKey: mutationOptions?.idempotencyKey,
1392
- readAt: mutationOptions?.readAt,
1393
- onStale: mutationOptions?.onStale,
1394
- wait: mutationOptions?.wait,
1448
+ intent: params.intent,
1449
+ idempotencyKey: params.idempotencyKey,
1450
+ readAt: params.readAt,
1451
+ onStale: params.onStale,
1452
+ wait: params.wait,
1395
1453
  operations: [
1396
1454
  {
1397
1455
  action: 'update',
1398
1456
  model: name,
1399
- id,
1400
- data,
1457
+ id: params.id,
1458
+ data: params.data,
1401
1459
  },
1402
1460
  ],
1403
1461
  });
1404
1462
  },
1405
- async delete(id, mutationOptions) {
1406
- await applyClaimedPolicy({ model: name, id }, mutationOptions);
1463
+ async delete(params) {
1464
+ await applyClaimedPolicy({ model: name, id: params.id }, params);
1407
1465
  return commits.create({
1408
- intent: mutationOptions?.intent,
1409
- idempotencyKey: mutationOptions?.idempotencyKey,
1410
- readAt: mutationOptions?.readAt,
1411
- onStale: mutationOptions?.onStale,
1412
- wait: mutationOptions?.wait,
1466
+ intent: params.intent,
1467
+ idempotencyKey: params.idempotencyKey,
1468
+ readAt: params.readAt,
1469
+ onStale: params.onStale,
1470
+ wait: params.wait,
1413
1471
  operations: [
1414
1472
  {
1415
1473
  action: 'delete',
1416
1474
  model: name,
1417
- id,
1475
+ id: params.id,
1418
1476
  },
1419
1477
  ],
1420
1478
  });
@@ -1426,14 +1484,29 @@ export function Ablo(options) {
1426
1484
  ready,
1427
1485
  waitForFlush,
1428
1486
  setAuthToken(token) {
1429
- // Same rotation path as the internal capability-token refresh
1430
- // (`applyRotatedToken` in `ready()`): update the closure binding the
1431
- // HTTP hydration provider reads, push to the bootstrap helper's header,
1432
- // and swap it on the live WebSocket. Decoupled from `ready()` so a
1433
- // refreshed JWT can be pushed at any point in the engine's lifetime.
1434
- currentCapabilityToken = token;
1435
- bootstrapHelper.setAuthToken(token);
1436
- store.getSyncWebSocket()?.setCapabilityToken(token);
1487
+ // The single credential source is read lazily by bootstrap HTTP,
1488
+ // lazy query HTTP, network probes, and WebSocket reconnect URL auth.
1489
+ // Updating it here is enough for the next request/connect to use the
1490
+ // refreshed token; no per-transport patching.
1491
+ authCredentials.setAuthToken(token);
1492
+ // A fresh credential is useless to a connection parked in offline /
1493
+ // backoff / auth_blocked until the next probe trigger — so kick one now.
1494
+ // Harmless while connected (the FSM ignores the nudge there).
1495
+ store.nudgeReconnect();
1496
+ },
1497
+ async getAuthToken() {
1498
+ // The live short-lived bearer (set via `setAuthToken`/`getToken` refresh)
1499
+ // is the canonical credential; fall back to a configured API key.
1500
+ return (authCredentials.getAuthToken() ??
1501
+ (await resolveApiKeyValue(configuredApiKey)) ??
1502
+ configuredAuthToken ??
1503
+ null);
1504
+ },
1505
+ setCredentialRefresher(refresher) {
1506
+ store.setCredentialRefresher(refresher);
1507
+ },
1508
+ nudgeReconnect() {
1509
+ store.nudgeReconnect();
1437
1510
  },
1438
1511
  sessions: {
1439
1512
  // Stripe `ephemeralKeys.create` shape: a BACKEND (holding `sk_`) mints a
@@ -1579,10 +1652,7 @@ export function Ablo(options) {
1579
1652
  async beginTurn(beginOptions) {
1580
1653
  const baseUrl = url.replace(/\/+$/, '');
1581
1654
  const turnUrl = `${baseUrl.replace(/^ws/, 'http')}/api/agent/turn`;
1582
- const headers = { 'Content-Type': 'application/json' };
1583
- if (currentCapabilityToken) {
1584
- headers.Authorization = `Bearer ${currentCapabilityToken}`;
1585
- }
1655
+ const headers = authCredentials.withAuthHeaders({ 'Content-Type': 'application/json' });
1586
1656
  const res = await fetch(turnUrl, {
1587
1657
  method: 'POST',
1588
1658
  headers,
@@ -200,12 +200,20 @@ export interface AgentModelMutationOptions extends Omit<ModelMutationOptions, 'i
200
200
  } | null;
201
201
  }
202
202
  export interface AgentModelClient<T = Record<string, unknown>> {
203
- retrieve(id: string, options?: AgentModelReadOptions): Promise<ModelRead<T>>;
204
- create(data: Record<string, unknown>, options?: AgentModelMutationOptions & {
203
+ retrieve(params: AgentModelReadOptions & {
204
+ readonly id: string;
205
+ }): Promise<ModelRead<T>>;
206
+ create(params: AgentModelMutationOptions & {
207
+ readonly data: Record<string, unknown>;
205
208
  readonly id?: string | null;
206
209
  }): Promise<CommitReceipt>;
207
- update(id: string, data: Record<string, unknown>, options?: AgentModelMutationOptions): Promise<CommitReceipt>;
208
- delete(id: string, options?: AgentModelMutationOptions): Promise<CommitReceipt>;
210
+ update(params: AgentModelMutationOptions & {
211
+ readonly id: string;
212
+ readonly data: Record<string, unknown>;
213
+ }): Promise<CommitReceipt>;
214
+ delete(params: AgentModelMutationOptions & {
215
+ readonly id: string;
216
+ }): Promise<CommitReceipt>;
209
217
  }
210
218
  export interface AgentRunContext {
211
219
  readonly task: Task;
@@ -228,5 +236,13 @@ export interface AbloApi {
228
236
  agent(id: string, options: AgentOptions): Agent;
229
237
  model<T = Record<string, unknown>>(name: string): ModelClient<T>;
230
238
  beginTurn(options: TaskCreateOptions): Promise<Turn>;
239
+ /**
240
+ * Resolve the active bearer credential this client authenticates with — the
241
+ * same token its own requests carry in `Authorization`. Returns `null` when
242
+ * no credential is configured. Async because the API key may be supplied as
243
+ * an async setter. Use it to authenticate a side-band request to the same
244
+ * server with the credential this client already holds — no re-mint.
245
+ */
246
+ getAuthToken(): Promise<string | null>;
231
247
  }
232
248
  export declare function createProtocolClient(options: AbloApiClientOptions): AbloApi;
@@ -171,32 +171,36 @@ export function createProtocolClient(options) {
171
171
  function createAgentModelClient(agentClient, name) {
172
172
  const base = agentClient.model(name);
173
173
  return {
174
- retrieve(id, options) {
174
+ retrieve(params) {
175
175
  // Reads are never blocked by a claim (coordination.md): a claim
176
176
  // serializes WRITERS, not readers. So — unlike the create/update/
177
177
  // delete paths below — retrieve does NOT apply the agent claimed
178
178
  // default; options pass through and the read path's `'return'`
179
179
  // default keeps a claimed row readable. A caller can still opt into
180
180
  // gating with an explicit `ifClaimed` (developer's choice).
181
- return base.retrieve(id, options);
181
+ return base.retrieve(params);
182
182
  },
183
- create(data, mutationOptions) {
184
- const id = mutationOptions?.id ?? createModelId();
185
- return withAgentIntent(agentClient, name, id, mutationOptions, (commitIntent) => base.create(data, {
186
- ...stripAgentRuntimeOptions(mutationOptions),
183
+ create(params) {
184
+ const id = params.id ?? createModelId();
185
+ return withAgentIntent(agentClient, name, id, params, (commitIntent) => base.create({
186
+ ...stripAgentRuntimeOptions(params),
187
187
  id,
188
+ data: params.data,
188
189
  intent: commitIntent,
189
190
  }));
190
191
  },
191
- update(id, data, mutationOptions) {
192
- return withAgentIntent(agentClient, name, id, mutationOptions, (commitIntent) => base.update(id, data, {
193
- ...stripAgentRuntimeOptions(mutationOptions),
192
+ update(params) {
193
+ return withAgentIntent(agentClient, name, params.id, params, (commitIntent) => base.update({
194
+ ...stripAgentRuntimeOptions(params),
195
+ id: params.id,
196
+ data: params.data,
194
197
  intent: commitIntent,
195
198
  }));
196
199
  },
197
- delete(id, mutationOptions) {
198
- return withAgentIntent(agentClient, name, id, mutationOptions, (commitIntent) => base.delete(id, {
199
- ...stripAgentRuntimeOptions(mutationOptions),
200
+ delete(params) {
201
+ return withAgentIntent(agentClient, name, params.id, params, (commitIntent) => base.delete({
202
+ ...stripAgentRuntimeOptions(params),
203
+ id: params.id,
200
204
  intent: commitIntent,
201
205
  }));
202
206
  },
@@ -562,14 +566,39 @@ export function createProtocolClient(options) {
562
566
  return waitForNoIntents(target, options);
563
567
  },
564
568
  };
565
- async function retrieveModel(modelName, id, options) {
566
- await applyClaimedPolicy({ model: modelName, id }, options);
567
- const query = await requestJson(`/v1/models/${encodeURIComponent(modelName)}/${encodeURIComponent(id)}`, {
569
+ async function listModel(modelName, options) {
570
+ const params = new URLSearchParams();
571
+ if (options?.limit !== undefined)
572
+ params.set('limit', String(options.limit));
573
+ if (options?.orderBy) {
574
+ const [col, dir] = Object.entries(options.orderBy)[0] ?? [];
575
+ if (col) {
576
+ params.set('order_by', col);
577
+ if (dir === 'desc')
578
+ params.set('order', 'desc');
579
+ }
580
+ }
581
+ // The collection route turns any non-reserved query param into an equality
582
+ // filter (`?status=todo`). The wire is AND-only equality — matches what a
583
+ // stateless reactor needs; richer predicates stay on the stateful path.
584
+ if (options?.where && typeof options.where === 'object') {
585
+ for (const [k, v] of Object.entries(options.where)) {
586
+ if (v !== undefined && v !== null && typeof v !== 'object')
587
+ params.set(k, String(v));
588
+ }
589
+ }
590
+ const qs = params.toString();
591
+ const res = await requestJson(`/v1/models/${encodeURIComponent(modelName)}${qs ? `?${qs}` : ''}`, { method: 'GET' });
592
+ return res.data ?? [];
593
+ }
594
+ async function retrieveModel(modelName, params) {
595
+ await applyClaimedPolicy({ model: modelName, id: params.id }, params);
596
+ const query = await requestJson(`/v1/models/${encodeURIComponent(modelName)}/${encodeURIComponent(params.id)}`, {
568
597
  method: 'GET',
569
598
  });
570
599
  const data = query.data;
571
600
  if (!data) {
572
- throw new AbloValidationError(`Model row not found: ${modelName}/${id}`, { code: 'model_not_found' });
601
+ throw new AbloValidationError(`Model row not found: ${modelName}/${params.id}`, { code: 'model_not_found' });
573
602
  }
574
603
  return {
575
604
  data,
@@ -622,22 +651,126 @@ export function createProtocolClient(options) {
622
651
  };
623
652
  }
624
653
  function model(name) {
654
+ // Durable lease + FIFO wait-line over HTTP (the existing claim routes). A
655
+ // claim is server state, not a subscription — acquire/hold/release are plain
656
+ // request/response, so a stateless agent participates in coordination too.
657
+ const claimPath = (id) => `/v1/models/${encodeURIComponent(name)}/${encodeURIComponent(id)}/claim`;
658
+ const isClaimHandle = (value) => typeof value === 'object' &&
659
+ value !== null &&
660
+ value.object === 'claim' &&
661
+ typeof value.claimId === 'string' &&
662
+ typeof value.release === 'function';
663
+ const claimMeta = (options) => {
664
+ if (!options?.description)
665
+ return options?.meta;
666
+ return { ...(options.meta ?? {}), description: options.description };
667
+ };
668
+ const acquireClaim = async (params) => {
669
+ const body = await requestJson(claimPath(params.id), {
670
+ method: 'POST',
671
+ body: JSON.stringify({
672
+ action: params.action ?? 'editing',
673
+ ...(params.ttl !== undefined ? { ttl: params.ttl } : {}),
674
+ ...(params.description !== undefined ? { description: params.description } : {}),
675
+ ...(claimMeta(params) ? { meta: claimMeta(params) } : {}),
676
+ // `wait` (default true) → queue behind the holder; false → fail-fast
677
+ // with AbloClaimedError (work-distribution dedup).
678
+ queue: params.wait ?? true,
679
+ }),
680
+ });
681
+ if (body.status === 'queued') {
682
+ throw new AbloClaimedError(`Target ${name}/${params.id} is held; queued at position ${body.position ?? 0}. ` +
683
+ `The HTTP client cannot await the grant without a WebSocket.`, { code: 'intent_queued' });
684
+ }
685
+ return body.intent?.id ?? body.id ?? body.intentId ?? createIntentId();
686
+ };
687
+ const releaseClaim = (params) => requestJson(claimPath(isClaimHandle(params) ? params.target.id : params.id), { method: 'DELETE' }).then(() => undefined);
688
+ async function claimImpl(params) {
689
+ const claimId = await acquireClaim(params);
690
+ const { data } = await retrieveModel(name, { id: params.id });
691
+ const release = () => releaseClaim(params);
692
+ return {
693
+ object: 'claim',
694
+ claimId,
695
+ target: {
696
+ model: name,
697
+ id: params.id,
698
+ ...(params.field ? { field: params.field } : {}),
699
+ ...(params.path ? { path: params.path } : {}),
700
+ ...(params.range ? { range: params.range } : {}),
701
+ ...(claimMeta(params) ? { meta: claimMeta(params) } : {}),
702
+ },
703
+ action: params.action ?? 'editing',
704
+ ...(params.description ? { description: params.description } : {}),
705
+ data,
706
+ release,
707
+ revoke: () => {
708
+ void release().catch(() => { });
709
+ },
710
+ [Symbol.asyncDispose]: release,
711
+ };
712
+ }
713
+ const intentsForEntity = async (params) => requestJson(`/v1/intents?model=${encodeURIComponent(name)}&id=${encodeURIComponent(params.id)}${params.field ? `&field=${encodeURIComponent(params.field)}` : ''}`, { method: 'GET' });
714
+ const claim = Object.assign(claimImpl, {
715
+ release: releaseClaim,
716
+ state: async (params) => {
717
+ const res = await intentsForEntity(params);
718
+ return res.intents?.[0] ?? null;
719
+ },
720
+ queue: async (params) => {
721
+ const res = await intentsForEntity(params);
722
+ return { object: 'list', data: res.queue ?? [] };
723
+ },
724
+ reorder: async (params) => {
725
+ await requestJson(`${claimPath(params.id)}/reorder`, {
726
+ method: 'POST',
727
+ // The reorder route's payload is `{ heldBy, intentId }[]` — Intent's id
728
+ // IS the intentId.
729
+ body: JSON.stringify({ order: params.order.map((i) => ({ heldBy: i.heldBy, intentId: i.id })) }),
730
+ });
731
+ },
732
+ });
733
+ const withMutationClaim = async (id, input, run) => {
734
+ const claimInput = input?.claim;
735
+ if (!claimInput)
736
+ return run(input);
737
+ if (isClaimHandle(claimInput)) {
738
+ return run({ ...input, intent: { id: claimInput.claimId }, claim: undefined });
739
+ }
740
+ const claimId = await acquireClaim({ id, ...claimInput });
741
+ try {
742
+ return await run({ ...input, intent: { id: claimId }, claim: undefined });
743
+ }
744
+ finally {
745
+ await releaseClaim({ id }).catch(() => { });
746
+ }
747
+ };
625
748
  return {
626
- retrieve(id, options) {
627
- return retrieveModel(name, id, options);
749
+ claim,
750
+ retrieve(params) {
751
+ return retrieveModel(name, params);
628
752
  },
629
- async create(data, mutationOptions) {
630
- const id = mutationOptions?.id ?? createModelId();
631
- await applyClaimedPolicy({ model: name, id }, mutationOptions);
632
- return mutateModel('create', name, id, data, mutationOptions);
753
+ list(options) {
754
+ return listModel(name, options);
633
755
  },
634
- async update(id, data, mutationOptions) {
635
- await applyClaimedPolicy({ model: name, id }, mutationOptions);
636
- return mutateModel('update', name, id, data, mutationOptions);
756
+ async create(params) {
757
+ const id = params.id ?? createModelId();
758
+ return withMutationClaim(id, params, async (options) => {
759
+ await applyClaimedPolicy({ model: name, id }, options);
760
+ return mutateModel('create', name, id, params.data, options);
761
+ });
637
762
  },
638
- async delete(id, mutationOptions) {
639
- await applyClaimedPolicy({ model: name, id }, mutationOptions);
640
- return mutateModel('delete', name, id, undefined, mutationOptions);
763
+ async update(params) {
764
+ return withMutationClaim(params.id, params, async (options) => {
765
+ await applyClaimedPolicy({ model: name, id: params.id }, options);
766
+ return mutateModel('update', name, params.id, params.data, options);
767
+ });
768
+ },
769
+ async delete(params) {
770
+ return withMutationClaim(params.id, params, async (options) => {
771
+ await applyClaimedPolicy({ model: name, id: params.id }, options);
772
+ return mutateModel('delete', name, params.id, undefined, options);
773
+ });
641
774
  },
642
775
  };
643
776
  }
@@ -652,6 +785,11 @@ export function createProtocolClient(options) {
652
785
  commits,
653
786
  model,
654
787
  agent: createAgent,
788
+ async getAuthToken() {
789
+ // Mirror `authHeaders()`: a configured API key wins, else the
790
+ // construction-time auth token. Resolve the (possibly async) key setter.
791
+ return (await resolveApiKeyValue(configuredApiKey)) ?? configuredAuthToken ?? null;
792
+ },
655
793
  async beginTurn(turnOptions) {
656
794
  const task = await tasks.create(turnOptions);
657
795
  let closed = false;